Charles Forsyth

Charles Forsyth

Differences between useState() and useReducer()

Intro

For JavaScript developers transitioning to React, understanding how state is managed is important. React provides us with two useful hooks for managing state: useState() and useReducer(). This article aims to provide some basic examples to help you understand how these hooks can be used and how they compare.

Rule of thumb

Before we delve into the details, let me start by saying in general, useState() should be used for simple state (value types and simple flat objects), while useReducer() should be used for more complex state tracking multiple value or reference types and even more complex objects.

Mutation

It is important to point out that we should never directly mutate state or prop variables in our React functional components. Always treat Props and State variables as immutable.

TL;DR

If you'd like to skip the read, and want to view a working code example instead, check out my sandbox on codesandbox.io.

A simple useState() example

Here is a simple example demonstrating how to invoke the useState() hook:

const initialState = { firstName: "Joe", lastName: "Smith" };
const [state, setState] = useState(initialState);

The second line destructures the tuple returned from useState() into two variables: state and setState. The state variable is assigned the state object, and the setState variable holds the dispatch method used to mutate that state. Because it's a tuple, you can provide any names you want for these variables. Here we've chosen state and setState. Note: typical convention is to prefix the dispatch method with the word "set".

Now let's look at one of the TypeScript type definitions for useState():

function useState<S = undefined>(): [
  S | undefined,
  Dispatch<SetStateAction<S | undefined>>
];

From this definition we glean that useState() is a generic function, and accepts a type argument (S) that defines the shape of the tuple returned.

In our example above, TypeScript is able to infer the type automatically, so we don't have to explicitly define the type as { firstName: string, lastName: string}.

ProTip: In your code, it is good practice to be explicit with your state typing. Below is how I would typically type this state object.

Here's an example of explicitly typing your state:

export interface IMyState {
  firstName: string;
  lastName: string;
}

const initialState = { firstName: "Joe", lastName: "Smith" };
const [state, setState] = useState<IMyState>(initialState);

Mutating your state

The SetStateAction type used by the Dispatch method has the following type definition:

type SetStateAction<S> = S | ((prevState: S) => S);

As you can see from this TypeScript type definition, we can either pass the new state as a value of type S, or we can pass a callback function that will make use of the prevState argument of type S and return a value of type S.

Callback or not?

In most cases, unless you need to read the current state, when using the dispatch function, all you have to do is pass the new state to it. But in cases where you need to read the current state in order to act on it a specific way, you'll always want to pass a callback function, typically written inline as an arrow function.

Beware of stale closures

When dealing with state, we have to be aware of problems related to stale closures. That is, variables that you set at block-level scope, that are referred to in child functions will create a closure. This includes your state and dispatch variables. Essentially this caches the variable's value at the time the function is defined, not at run time.

Setting state directly

If your mutate operation doesn't require knowing the previous value, then you can simply set the state directly, like this:

const [state, setState] = useState<IMyState>({
  firstName: "Joe",
  lastName: "Smith",
});

const handleClick = (firstName: string, lastName: string) => () => {
  setState({ firstName, lastName });
};

Setting state with a callback

But if your mutation requires knowledge of the current state, then you should use the callback pattern:

// Boolean state example
const [checked, setChecked] = useState(false);

const handleClick = () => {
  setState((prevState) => !prevState);
};

The simplest way to implement useReducer()

Let's first review the useReducer() method signature. Below is one of the 5 overloaded TypeScript definitions for useReducer() hook:

function useReducer<R extends Reducer<any, any>>(
  reducer: R,
  initialState: ReducerState<R>,
  initializer?: undefined
): [ReducerState<R>, Dispatch<ReducerAction<R>>];

You'll notice that the reducer type R extends Reducer<any, any>.

And this is the TypeScript definition for the Reducer callback method:

type Reducer<S, A> = (prevState: S, action: A) => S;

This is the signature we need to follow when creating our reducer function. You'll notice that S defines the type for the prevState argument, while A defines the type of the action argument. Also, S is the type that our method should return.

Here is an over-simplified implementation demonstrating how to invoke the useReducer() hook:

export interface INameState {
  firstName: string;
  lastName: string;
}

const nameReducer = (_prevState: INameState, action: INameState) => {
  return { ...action };
};

const initialState = { firstName: "Joe", lastName: "Smith" };

const [state, dispatch] = useReducer(nameReducer, initialState);

const handleClick = (firstName: string, lastName: string) => () => {
  dispatch({ firstName, lastName });
};

First we're defining INameState type that will be the shape of our state. This is the same shape we used in the previous example with the useState() hook.

We then declare the nameReducer arrow function that will be used by React to mutate state when we call the dispatch function that useReducer() provides to us.

We then create an initialState variable and set it with some initial state.

After that is an example of how we might invoke the useReducer() hook (within a React Component). It returns a tuple having the state value and dispatcher function we can invoke. In our example we're simply naming these variables state and dispatch. These variable names are typical, but you can name them anything you want (this is the beauty of returning a tuple instead of an object).

Finally, the handleClick function is an example of how we would mutate our state through the dispatch method. As you can see, it is invoked by passing the values provided to it.

Not the best way

If your state is simple like this, please consider using useState() instead. This example was only for illustration purposes and to help the reader understand the similarities between useState() and useReducer().

Caveats and pitfalls

The reducer function must return a value matching the type defined by the state. Be careful not to mutate the prevState directly within your reducer function, but instead make a copy and mutate the copy, then return that copy.

Reducer function must be idempotent

It is very important to realize that the reducer function that you write is idempotent, (aka "pure"). React helps us with this by calling the reducer twice, while in strict mode, and in the development environment. This helps us discover problems with our reducer. See official docs here

A more advanced implementation of useReducer()

The above example was simplified to illustrate how to implement the useReducer() hook and compare it with useState(). However, this isn't how most developers typically implement their reducer function or useReducer().

Below is a more advanced, yet still simplified, implementation of useReducer(). In this example, we'll be adding and removing random keys from our state, which is a simple array of key objects having an id and name.

export interface IKey {
  id: number;
  name: string;
}

type ActionType =
  | { type: "ADD"; payload: { id: number; name: string } }
  | { type: "REMOVE_FIRST" }
  | { type: "DELETE"; id: number };

const keysReducer = (prevState: IKey[], action: ActionType) => {
  switch (action.type) {
    case "ADD":
      const { id, name } = action.payload;
      if (prevState.some((item) => item.id === id)) return prevState;
      return [...prevState, { id, name }];
    case "REMOVE_FIRST":
      return prevState.slice(1);
    case "DELETE":
      return prevState.filter((item) => item.id !== action.id);
    default:
      return prevState;
  }
};

const [state, dispatch] = useReducer(keysReducer, [] as IKey[]);

const handleGenerateClick = () => {
  dispatch({
    type: "ADD",
    payload: { id: getSequentialNumber(), name: getRandomKey() },
  });
};

const handleRemoveFirstClick = () => {
  dispatch({ type: "REMOVE_FIRST" });
};

const handleDeleteItemClick = (id: number) => {
  dispatch({ type: "DELETE", id });
};

In this contrived example, we create an interface named IKey that types the records we'll be storing in our state.

Then we create a discriminating union named ActionType that defines the signatures that the dispatch method will accept pivoting off the type property of each to 'discriminate' between these type signatures.

You'll notice that we have three different type values, ADD, REMOVE_FIRST and DELETE, and that these are strings. In most cases, developers use Enums here instead of strings, to protect against typos.

Following that, we declare the keysReducer function that acts on the action.type to perform various methods on the state, returning a copy of the state.

Note: Be careful not to mutate the prevState directly in your reducer method. Instead, make a copy and mutate that, then return the new state.

You'll notice that when we invoke the useReducer() method we're passing the keysReducer function and an empty array, typed as an array of IKey objects to initialize our state. The useReducer() function returns a tuple having the read-only immutable state and the dispatch method that accepts values of ActionType.

Conclusion

As we have seen, useState() and useContext() hooks are very similar and can both be used to manage our state. The big difference is that we can isolate and customize the function used to mutate state when we implement the useReducer() hook.

Check out the live example on CodeSandbox

All of the examples above have been put into a small sandbox on codesandbox.io. Please feel free to fork it and play around with these.

Feedback

If you have suggestions, feedback or comments, please feel free to drop me an email at charles@charelsforsyth.com.