Skip to content

Local component state with Redux Toolkit-like reducers

Reducers are not only useful for global state management. They can also be used for local component state. And compared to plain useReducer() we can reduce boilerplate code by adapting the API of modern reducer libraries like Redux Toolkit (RTK).

Published
Mar 11, 2023
Updated
Mar 11, 2023
Reading time
5 min read

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.