Simple State
The useState
hook provides the most basic way to handle state in a React component. While it's absolutely sufficient in many cases, it's problematic as use cases get more complex. Given the following example:
function MyComponent(props) {
const [timer, setTimer] = useState({ start: 0, end: 0 });
const start = () => setTimer({ start: Date.now(), end: 0 });
const stop = () => setTimer((state) => ({ ...state, end: Date.now() }));
return <div>...</div>;
}
Sure, it's still a very basic example but already introduces a problem: the state and its logic are tightly coupled with the component. You can extract it into a custom hook for better separation of concerns, testability and reusability.
function useTimer() {
const [timer, setTimer] = useState({ start: 0, end: 0 });
const start = useCallback(() => setTimer({ start: Date.now(), end: 0 }), []);
const stop = useCallback(
() => setTimer((state) => ({ ...state, end: Date.now() })),
[]
);
return { timer, start, stop };
}
A note about useCallback()
The start()
and stop()
functions are now wrapped in a useCallback()
hook to return stable references. While useCallback()
should not be overused, for custom hooks I prefer to return stable values/functions whenever possible. You never know how the hook will be used and if the returned values/functions are used as dependencies in other hooks.
The state handling is now decoupled from our component but is still coupled with React. As you know, React is a JavaScript library for building user interfaces. So why should this kind of state be tightly coupled with React? Let's decouple it by moving the logic into a reducer.
Reducers
For the reducer we create an action type, our initial state and the reducer function. For easier usage we also create a useTimer()
hook that wraps the useReducer()
call.
Our state is now decoupled from both, the component and React.
type Action = { type: "start" } | { type: "stop" };
const initialState = { start: 0, end: 0 };
function timerReducer(state = initialState, action: Action) {
switch (action.type) {
case "start":
return { start: Date.now(), end: 0 };
case "end":
return { ...state, end: Date.now() };
default:
return state;
}
}
function useTimer() {
return useReducer(timerReducer);
}
But from the initial three lines of code for our state we went to a 20 line file. And using the timer in the component requires more work. Instead of just calling start()
we now need to call dispatch()
and provide the action object.
const [timer, dispatch] = useTimer();
const onStart = () => dispatch({ type: "start" });
<button onClick={onStart}>Start</button>;
Can we make it easy again, please?
Better reducers
Global state management libraries like Redux Toolkit or NgRx for Angular already got this right: they moved away from the traditional reducers pattern, that requires a lot of boilerplate code, and instead provide an easy-to-use way to create reducers by defining functions. Here's an example from Redux Toolkit:
const initialState = { start: 0, end: 0 };
const timerSlice = createSlice({
name: "timer",
initialState,
reducers: {
start: (state) => ({ start: Date.now(), end: 0 }),
end: (state) => {
state.end = Date.now();
},
},
});
What if we use this API for managing local state? We can still decouple state handling from components but without adding a lot of boilerplate code. react-use is a collection of essential React hooks. It provides a hook named useMethods()
which is a wrapper around useReducer()
with an easier API for writing reducers:
const initialState = { start: 0, end: 0 };
const timerMethods = (state) => ({
start: () => ({ start: Date.now(), end: 0 }),
stop: () => ({ ...state, end: Date.now() }),
});
function useTimer() {
return useMethods(timerMethods, initialState);
}
The component can now use the useTimer()
hook and gets stable references of the start
and stop
actions:
function MyComponent() {
const [timer, { start, stop }] = useTimer();
return (
<div>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</div>
);
}
So much easier to use, isn't it?
Reducers with context
Below is a simple component that increments a counter by the props.incrementBy
value. Because of that dynamic increment value we have to wrap the increment
function and pass the incrementBy
property to it.
function MyComponent(props) {
const [counter, { increment }] = useCounter();
const onIncrement = () => increment(props.incrementBy);
return <button onClick={onIncrement}>+</button>;
}
That's an extra step you have to take, and you may have more than just one action. Also, you need to wrap onIncrement
in a useCallback()
if you need stable references. What if we extend the reducer with some kind of context instead? The context could be passed as second argument:
const counterMethods = (state, ctx) => ({
increment: () => state + (ctx.incrementBy ?? 1),
});
Components can then pass the context values without the need for wrapping methods:
function MyComponent(props) {
const [counter, { increment }] = useCounter({
incrementBy: props.incrementBy,
});
return <button onClick={increment}>+</button>;
}
I don't know if this is a good idea, but it would make using reducers with dynamic values much easier.
Summary
Reducers are a great way to handle not just global state but local state in React. Hooks like useMethods()
from react-use
make it even easier to use reducers in React components.