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.