Reducers and DX ð§âð
As an introduction to this topic, let's make a small reminder of what a reducer is.
Reducers are functions that take the current state and an action as arguments, and return a new state result.
With that in mind, we can already get an idea that we can work with complex data structures or not, and be able to keep track of how the flow of our state works (aka the famous one way data binding ðŽ).
That said, let's go into a little more detail about how we can improve our reducers ðĪ .
I highly recommend reading the new documentation of the useReducer hook.
Where we are right now ðĩ
Right now we are at a point where we don't know what's going on in our codebase, and worse, we don't know where the problems are coming from.
We started to analyze all the code in detail and we realized from what we were told this morning at the standup that the error occurs when a user performs certain actions, so, we who are very smart, with that little information we can already get an idea that some data in one of these actions is being lost or corrupted.
We get down to work and see that the code has a Switch with 900lines, with very nested state property assignments and side-effects ðĪŊ
Not so cool reducer (code example):
import { useReducer } from "react";
const ADD_PRODUCT = 'ADD_PRODUCT';
const UPDATE_PRODUCT_STATUS = 'UPDATE_PRODUCT_STATUS';
const INITIAL_PRODUCTS_STATE = { products: {} };
// this does not scale, is difficult to track and is very bug prone...
const reducer = (state = INITIAL_PRODUCTS_STATE, action) => {
switch (action.type) {
case ADD_PRODUCT:
return {
...state,
products: {
...state.products,
[action.key]: action.product,
},
};
case UPDATE_PRODUCT_STATUS:
return {
...state,
products: {
...state.products,
[action.key]: {
...state.products[action.key],
status: action.newStatus,
},
},
};
default:
return state;
}
};
/*
It might seem that our component is super easy, pretty and has hardly any lines of code, but it really hides a dark secret that can make us cry and pull our hair out in the near future...
What would happen if tomorrow we want to change the name of our action in the reducer? ðĪ
And what happens if a component makes a mistake in dispatching the wrong action? ðĪ
And what happens if we change the component that manages the products? ðĪ
And if I have to make a change in the state in which I have to access a very deep property which has a nesting level that is more than 3 levels? ðĪ
*/
function Example_withBadReducer() {
const [state, dispatch] = useReducer(reducer, initialState);
return (
<div>
<p>Current products are {JSON.stringify(state.products)}</p>
<ProductHandler dispatch={dispatch} />
</div>
);
}
/*
In the context of ProductHandler, what is the value of the "dispatch" property? What can I do with that prop? This component is highly coupled to the internal logic of the parent component, which is a bad thing.
*/
function ProductHandler({ dispatch }) {
return (
<>
<button
onClick={() =>
dispatch({
type: "ADD_PRODUCT",
payload: {
key: "my_permission",
status: "PENDING",
},
})
}
>
Initialize permission
</button>
<button
onClick={() =>
dispatch({
type: "UPDATE_PRODUCT_STATUS",
payload: { key: "my_permission", newStatus: "FULL" },
})
}
>
Change permission status
</button>
</>
);
}
Where we want to go ð
We do not want to have the best code in the world (in fact we want it but it is quite complicated), but to have the code that best suits our circumstances and that we are not afraid to touch in the future.
But, what characteristics should a code have so that we are not afraid to work with it? there are more but let's put those for now
- Easy to understand and identify
- Easy to maintain and scale
- Easy to debug
Let's take note of these 3 characteristics and see what we can do to achieve it ðŦĄ.
Reducer with steroids (code example):
dependencies:
userImmer
+immer
*
import { useMemo } from "react";
import { useImmerReducer } from "use-immer";
/*
Maybe this reminds us of Redux, but it is a mistake to link this only with Redux, since this is a pattern within the paradigm of functional languages, but that's another topic that we will not deal with now ðĪŠ
*/
function useSelectors([state], mapStateToSelectors) {
return useMemo(
() => mapStateToSelectors(state),
[state, mapStateToSelectors]
);
}
function useActions([, dispatch], mapDispatchToActions) {
return useMemo(
() => mapDispatchToActions(dispatch),
[dispatch, mapDispatchToActions]
);
}
/*
Pure functions that can be unit tested without any difficulty ðĨ°
The only drawback we have here is that our functions are coupled to exist within the context of immer, but under my point of view this is a lesser evil
*/
const ACTION_HANDLERS = {
ADD_PRODUCT: (state, { payload }) => {
state.products[payload.key] = payload.status;
},
UPDATE_PRODUCT_STATUS: (state, { payload }) => {
state.products[payload.key] = payload.newStatus;
},
};
const initialState = { products: {} };
function reducer(state = initialState, action) {
const handler = ACTION_HANDLERS[action.type];
return handler ? handler(state, action) : state;
}
function Example_withImmer() {
const productsReducer = useImmerReducer(reducer, initialState);
// May seem a bit verbose, but, if it does, perhaps you should not use a reducer ðĨĩ
const { addProduct, changeProductStatus } = useActions(
productsReducer,
(dispatch) => ({
addProduct: (product) =>
dispatch({ type: "ADD_PRODUCT", payload: product }),
changeProductStatus: ({ key, newStatus }) =>
dispatch({
type: "UPDATE_PRODUCT_STATUS",
payload: { key, newStatus },
}),
})
);
const { getProducts } = useSelectors(productsReducer, (state) => ({
// here is where we should use `reselect`, `useCallback`/`useMemo` or a stable reference to our custom selectors function
getProducts: () => state,
// getProductById: (id) => state.products?.[id]
}));
return (
<div>
<p>Current products are {JSON.stringify(getProducts())}</p>
<ProductHandler
addProduct={addProduct}
changeProductStatus={changeProductStatus}
/>
</div>
);
}
/*
> Is an example to compare with the case we have been analyzing ðĪ
Instead of prop drilling the dispatch function, here we can see how our code now has a semantic relationship with our action, instead of making an act of faith and interpreting without certainty that the dispatch type is correct (e.g: a typo)*.
Another problem of using the dispatch in a child component, is that this component should not necessarily know anything about the parent, it should only execute without taking into account the logic that its ancestor is handling.
> * if we use TypeScript, many of these problems would disappear in dev/build time, since the compiler will tell us that something is wrong.
*/
function ProductHandler({ addProduct, changeProductStatus }) {
return (
<>
<button
onClick={() => addProduct({ key: "my_permission", status: "PENDING" })}
>
Initialize permission
</button>
<button
onClick={() =>
changeProductStatus({ key: "my_permission", newStatus: "FULL" })
}
>
Change permission status
</button>
</>
);
}
As we can see in this new scenario, the code is much cleaner and more compact, we have the facility to update our state in a mutable way with the benefit that it will actually be done in an immutable way, without forgetting that we are no longer using an imperative control mechanism ejem switch ejem (and that is O(n)), but now we use a variant of a "hashmap" in JavaScript thanks to the properties of the objects (giving us O(1)).
So, if we analyze this new version with the requirements we have previously set ourselves:
- Easy to understand and identify â > By binding actions with their handlers we can give a name and a purpose to the functions that will generate a change in our state, in this way, we comply with the principle of least privilege, also known as PoLP, basically our components will have the bare minimum privileges it needs to perform its intended function (and not the ability to be able to perform other actions without us knowing it, as in the previous case with the dispatch as a prop).
- Easy to maintain and scale â > In our reducer, by making use of the object properties, we can find out much faster and without having to affect the rest of the cases if we add or remove any action, and thanks to the use of immer we can find out what purpose this action has in our state. And how wonderful it is to see that your child component is actually no longer coupled to the implementation of the parent, but is bound to the properties that it receives (much easier to modify, change and test).
- Easy to debug â > Unwittingly, thanks to the previous modifications, we have managed to obtain a code that is much easier to debug and analyze, we have pure functions that are not complex at all and in the stacktrace we will have also available the name of the function which has caused the problem.
In this scenario we find that we cannot normalize the data coming from the backend, or at least for now, so, thanks to using immer
* we drastically reduce the probability of screwing it up.
Tip: Please always normalize the data, working with a flat structure is less prone to failure than having a data structure with deep-nested properties. These people explain it very well, although if you can have a BFF it is always much better than delegating the responsibility of managing the domain structures to the client side. (in another post we can discuss about how and where such data normalization can best be done ðĪ)
Conclusion ð
As a personal conclusion, I think that reducers are a very powerful pattern but at the same time it is very complex to perform correctly, so I think that following these small guidelines within a professional environment can help a lot to identify what the code does and above all to avoid possible errors.
Top comments (0)