9. Redux and redux saga

9. Redux and redux saga

This file was copied from the packages/webapp/src/stories/docs folder. Its accuracy as of August 8, 2024 has not been verified

| Resources | | --------- | | Redux official tutorial | | Redux saga doc | | Redux saga takeLeading | | Redux saga takeLatest | | Redux toolkit createEntityAdapter and state normalization | | Redux memorized selector |

1. Redux Data flow

Express endpoint api.litefarm.org/crop/31

{ crop_common_name: 'Apricot', crop_genus: 'Prunus', crop_group: 'Fruit and nuts', crop_id: 31, crop_photo_url: 'https://litefarm.nyc3.cdn.digitaloceanspaces.com/default_crop/v2/apricot.webp', crop_subgroup: 'Pome fruits and stone fruits', crop_translation_key: 'APRICOT', }

Copy

Redux Saga action getCropByIdSaga and toolkit action getCropByIdSuccess

getCropByIdSuccess normalizes and stores crop in Redux store

entitiesReducer: { cropEntitiesReducer:{ ids: [31], entities: { 31:{ crop_common_name: 'Apricot', crop_genus: 'Prunus', crop_group: 'Fruit and nuts', crop_id: 31, crop_photo_url: 'https://litefarm.nyc3.cdn.digitaloceanspaces.com/default_crop/v2/apricot.webp', crop_subgroup: 'Pome fruits and stone fruits', crop_translation_key: 'APRICOT'} } }

Copy

Consume store data through selector in a Container

Defined cropsSelector in cropSlice.js

export const cropsSelector = createSelector( [cropSelectors.selectAll, loginSelector], (crops, { farm_id }) => { return crops.filter((crop) => crop.farm_id === farm_id || !crop.farm_id); }, );

Copy

Consume data in container CropCatalogue.js

function CropCatalogue({history}){ const crops = useSelector(cropsSelector); console.log(crops) }

Copy

[ { crop_common_name: 'Apricot', crop_genus: 'Prunus', crop_group: 'Fruit and nuts', crop_id: 31, crop_photo_url: 'https://litefarm.nyc3.cdn.digitaloceanspaces.com/default_crop/v2/apricot.webp', crop_subgroup: 'Pome fruits and stone fruits', crop_translation_key: 'APRICOT', } ]

Copy

Everytime user login to a farm, the app would fetch all farm data in the background, normalize and cache everything in the store.

entitiesReducer acts as frontend database/database cache and selectors act as frontend database queries.

If user goes to cropCatalogue page, catalogue page will first render all crops in cropReducer(cache).

const crops = useSelector(cropsSelector)

Copy

Then an useEffect hook would fetch all latest crops and store crops into cropReducer

useEffect(()=>{ dispatch(getCrops()) },[])

Copy

If the value of useSelector(cropsSelector) changes, in other word, if a crop is modified by another user, useSelector will trigger a rerender, and catalogue page will update the modified crop.

2. Redux toolkit

cropSlice.js

import { createEntityAdapter, createSlice } from '@reduxjs/toolkit'; import { createSelector } from 'reselect'; import { pick } from '../util/pick'; /** * Everytime we upsert a crop in cropReducer, we need to make sure we are not adding non-crop fields into the reducer. * Pick function would return a crop object without irrelevant fields that are not in cropProperties * @param obj * @returns {*} */ const getCrop = (obj) => { return pick(obj, [ 'ca', 'crop_common_name', 'crop_genus', 'crop_group', ...cropProperties ]); }; const cropToBeAdded = { crop_common_name: 'Apricot', crop_genus: 'Prunus', crop_group: 'Fruit and nuts', crop_id: 31, crop_photo_url: 'https://litefarm.nyc3.cdn.digitaloceanspaces.com/default_crop/v2/apricot.webp', crop_subgroup: 'Pome fruits and stone fruits', crop_translation_key: 'APRICOT'} /** * The tooltip cropAdapter.upsertOne(cropReducer, crop) would take crop.crop_id (31) as key and crop (cropToBeAdded) as * value and then put update cropReducer.entities and cropReducer.ids in such way: * const {crop_id} = cropToBeAdded; * entities[crop_id] = {...entities[crop_id], ...cropToBeAdded} * if(!ids.includes(crop_id) ids.push(crop_id) */ const addOneCrop = (state, { payload }) => { cropAdapter.upsertOne(state, getCrop(payload)); }; const entitiesBeforeAddOneCropUpdate = {31: {crop_genus: 'genus', additionalField: 'field'}} const entitiesAfterAddOneCropUpdate = {31: {...cropToBeAdded, additionalField: 'field'}} /** * * cropAdapter.upsertMany(cropReducer, crops) takes many crops and run cropAdapter.upsertOne(cropReducer, crop) * one by one */ const addManyCrop = (state, { payload: crops }) => { cropAdapter.upsertMany( state, crops.map((crop) => getCrop(crop)), ); }; /** * * It's important to set the correct id (usually primary key); * If primary key is compound key, we can use crop => `${crop.pk1}-${crop.pk2}` as id */ const cropAdapter = createEntityAdapter({ selectId: (crop) => crop.crop_id, }); const cropSlice = createSlice({ name: 'cropReducer', initialState: cropAdapter.getInitialState({ loading: false, error: undefined, loaded: false }), reducers: { getCropsSuccess: addManyCrop, getAllCropsSuccess: (state, { payload: crops }) => { addManyCrop(state, { payload: crops }); state.loaded = true; }, postCropSuccess: addOneCrop, selectCropSuccess(state, { payload: crop_id }) { state.crop_id = crop_id; }, }, }); /** * We use put(getCropsSuccess(crops)) (in saga function) and dispatch(postCropSuccess) (in container) to update cropReducer */ export const { getCropsSuccess, postCropSuccess, getAllCropsSuccess, } = cropSlice.actions; export default cropSlice.reducer;

Copy

3. Async calls with redux saga and axios

All saga functions involve post put patch delete should use takeLeading listener.

All saga functions that only involve get should use takeLatest listener. (example use case: get all crops from server to fill cropReducer as cache)

All saga functions that do not involve any async calls should use takeEvery listener. (example use case: increase counter on button click)

4. state normalization

5. selector

Most slices would have at least 4 selectors cropReducerSelector, cropEntitiesSelector, cropsSelector, and cropSelector

cropSlice.js

export const cropReducerSelector = (state) => state.entitiesReducer[cropSlice.name]; const useSelectorReturns = { entities: {31: cropApricot, [crop_id]: crop}, ids: [31, crop_id] }

Copy

cropsSelector select all crops of a farm

const cropSelectors = cropAdapter.getSelectors((state) => state.entitiesReducer[cropSlice.name]); export const cropsSelector = createSelector( [cropSelectors.selectAll, loginSelector], (crops, { farm_id }) => { return crops.filter((crop) => crop.farm_id === farm_id || !crop.farm_id); }, ); const useSelectorReturns = [...crops]

Copy

cropSelectors are a list util selectors

cropSelector select a single crop

export const cropSelector = (crop_id) => (state) => cropSelectors.selectById(state, crop_id); const useSelectorReturns = crop

Copy

cropEntitiesSelector returns an object of key(crop_id) value(crop) pairs

export const cropEntitiesSelector = cropSelectors.selectEntities; const useSelectorReturns = {31: cropApricot, [crop_id]: crop}

Copy

Steps for a backend - frontend story from a data flow perspective

  • Create knex migration to create/modify tables in database

  • Create objection models

  • Endpoints and empty controllers

  • Backend jest unit testing

  • Implement controllers

  • Create authorization/validation middlewares

  • Run tests and make sure everything passes

  • Create pure frontend components in storybook

  • res.data normalizer, redux slice, redux selectors to hold and access the data

  • Redux saga for get/post/put/patch request

  • Container component to connect saga actions/selector with pure components

  • Connect Route component with container components with react router

  • Manual testing

Related content