State Reducer Pattern
July 23, 2021 •4 min read
Other React patterns:
We sometimes start off with a simple component for a specific scenario, but end up reusing it elsewhere.
As an example, we have this simple component that toggles back and forth.
const actionTypes = {
toggle: "toggle",
};
function toggleReducer(state, { type }) {
switch (type) {
case actionTypes.toggle: {
return { on: !state.on };
}
default: {
throw new Error(`Unsupported type: ${type}`);
}
}
}
function useToggle() {
const initialState = { on: true };
const [state, dispatch] = React.useReducer(toggleReducer, initialState);
const { on } = state;
const toggle = () => dispatch({ type: actionTypes.toggle });
return {
on,
toggle,
};
}
const Toggle = () => {
const { toggle, on } = useToggle();
return (
<>
<Animation on={on} />
<Switch on={on} onClick={toggle} />
</>
);
};
During the lifetime of a component, requirements may come up not originally accounted for.
We can keep customising the component for each new change, which would require adding extra props and logic to the original reducer.
But this can get tedious after a few changes. We can instead use a design pattern known as Inversion of Control to handle any new use cases.
Inversion of Control
Inversion of Control gives users complete control to update the internal state of a component from the outside.
For this example, we want to re-use the existing component, but limit the number of times it can be toggled as per the example below:
Custom Reducer
The original component implements a simple reducer for the toggle functionality.
We can give users the ability to update the internal state of the component by allowing them to provide their own custom reducer.
Users can also avoid repeating all the logic of the original reducer, by combining custom logic with the original reducer, as per the example below:
const [timesClicked, setTimesClicked] = React.useState(0);
const clickedTooMuch = timesClicked >= 5;
function customReducer(state, action) {
if (action.type === actionTypes.toggle && clickedTooMuch) {
return { on: state.on };
}
return toggleReducer(state, action);
}
We update the useToggle
hook which defaults to the original reducer, or uses a custom reducer if its passed in.
function toggleReducer(state, { type, initialState }) {
switch (type) {
case actionTypes.toggle: {
return { on: !state.on };
}
case actionTypes.reset: {
return initialState;
}
default: {
throw new Error(`Unsupported type: ${type}`);
}
}
}
function useToggle({ reducer = toggleReducer } = {}) {
const initialState = {on: false}
const [state, dispatch] = React.useReducer(reducer, initialState);
const { on } = state;
const toggle = () => dispatch({ type: actionTypes.toggle });
return {
on,
toggle,
};
}
function customReducer(state, action) {
if (action.type === actionTypes.toggle && clickedTooMuch) {
return { on: state.on };
}
return toggleReducer(state, action);
}
const { on, getToggleProps, getResetterProps } = useToggle({
reducer: customReducer,
});
Bringing it all together
Here's the full code implementing the State Reducer and Prop Collections and Getters pattern:
const callAll =
(...fns) =>
(...args) =>
fns.forEach((fn) => fn?.(...args));
const actionTypes = {
toggle: "toggle",
reset: "reset",
};
function toggleReducer(state, { type, initialState }) {
switch (type) {
case actionTypes.toggle: {
return { on: !state.on };
}
case actionTypes.reset: {
return initialState;
}
default: {
throw new Error(`Unsupported type: ${type}`);
}
}
}
function useToggle({ reducer = toggleReducer } = {}) {
const initialState = { on: false };
const [state, dispatch] = React.useReducer(reducer, initialState);
const { on } = state;
const toggle = () => dispatch({ type: actionTypes.toggle });
const reset = () => dispatch({ type: actionTypes.reset, initialState });
function getToggleProps({ onClick, ...props } = {}) {
return {
"aria-pressed": on,
onClick: callAll(onClick, toggle),
...props,
};
}
function getResetterProps({ onClick, ...props } = {}) {
return {
onClick: callAll(onClick, reset),
...props,
};
}
return {
on,
reset,
toggle,
getToggleProps,
getResetterProps,
};
}
const Toggle = () => {
const [timesClicked, setTimesClicked] = React.useState(0);
const clickedTooMuch = timesClicked >= 4;
function customReducer(state, action) {
if (action.type === actionTypes.toggle && clickedTooMuch) {
return { on: state.on };
}
return toggleReducer(state, action);
}
const { on, getToggleProps, getResetterProps } = useToggle({
reducer: customReducer,
});
return (
<Wrapper>
<Text checked={on} limit={clickedTooMuch} />
<Animation on={on} />
<Switch
{...getToggleProps({
disabled: clickedTooMuch,
on: on,
onClick: () => setTimesClicked((count) => count + 1),
})}
/>
<ResetBtn {...getResetterProps({ onClick: () => setTimesClicked(0) })}>
Reset
</ResetBtn>
</Wrapper>
);
};
Further Reading
- The blog by Kent C. Dodds for this and other React patterns.