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