Skip to content

Local component state with Redux Toolkit-like reducers

Published On
Read Time
4 min

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.