DEV Community

Chris
Chris

Posted on

React useState with History

I recently came across a post about a React Hook that keeps track of past states, something like useState with History. In addition to the current state and the state update function, it returns an array of all states (previous states plus current state) as the third parameter. The implementation looked like this:

function useStateHistory<T>(
  initialValue?: T | (() => T)
): [T | undefined, (state: T) => void, Array<T | undefined>] {
  const stateHistoryRef = React.useRef<Array<T | undefined>>([]);
  const [state, setState] = React.useState<T | undefined>(initialValue);

  React.useEffect(() => {
    stateHistoryRef.current = [...stateHistoryRef.current, state];
  }, [state]);

  return [state, setState, stateHistoryRef.current];
}
Enter fullscreen mode Exit fullscreen mode

First of all, I like this implementation because it is simple and easy to grasp what is going on. The useState hook manages the current state, the useEffect hook reacts to changes in the current state and stores each state change in an array of states defined by the useRef hook.

However, thinking about it a more deeply, the useEffect hook is actually redundant and can be omitted if we convert useState into a useReducer which allows us to define a reducer function to update the state. Inside the reducer we can simply store the new state into the array of useRef.

const stateHistoryRef = React.useRef<Array<T | undefined>>([]);
const [state, setState] = React.useReducer(
  (oldState: T | undefined, newState: T | undefined) => {
    stateHistoryRef.current = [...stateHistoryRef.current, oldState];
    return newState;
  },
  typeof initialValue === "function"
    ? (initialValue as () => T)()
    : initialValue
);
Enter fullscreen mode Exit fullscreen mode

However, there is a caveat with this implementation. React actually calls the state reducer function twice. This behavior is intentional to make unexpected side effects more apparent. The reducer function should be pure, i.e. it should return the same output for the same input and should not have any side effects, such as changing the value of a ref within the reducer.

To make the reducer pure, we need to remove the useRef and manage the state history within the reducer function itself. This means that instead of returning a single state, the useReducer will return an array of all states and take care of merging the old state with the new one. In our useStateHistory hook we then simply take the last element of the state history array and return it as the current state, the remaining states are the history.

function useStateHistory<T>(
  initialValue?: T | (() => T)
): [T | undefined, (state: T) => void, Array<T>] {
  const [allStates, setState] = React.useReducer(
    (oldState: T[], newState: T) => {
      return [...oldState, newState];
    },
    typeof initialValue === "function"
      ? [(initialValue as () => T)()]
      : initialValue !== undefined
      ? [initialValue as T]
      : []
  );

  const currentState = allStates[allStates.length - 1];
  const stateHistory = allStates.slice(0, allStates.length - 1);
  return [currentState, setState, stateHistory];
}
Enter fullscreen mode Exit fullscreen mode

To be honest, these changes are minuscule and I don't expect them to improve any performance. I just like to think about the objective and how it can be achieved in a different way, in this case with only one hook instead of three. I assembled an example on CodeSandbox to compare the different implementations of the hooks.

What is your opinion on this? Would you rather use more hooks and have a simple implementation, or use as few hooks as possible with a possibly more complicated implementation?

Discussion (0)