The React doc recommends useReducer
for handling complex state values. But to me they are equally powerful. Let me show you how.
useReducer can replace useState
First, the simpler case: any useState
can be implemented using useReducer
. In fact, the useState
hook itself is implemented by a reducer.
Let's create a simple React state with useState
. The state contains a count
number.
type State = { count: number };
const [state, setState] = React.useState<State>({ count: 0 });
We can re-implement the same with useReducer
.
type Action = {
type: 'COUNT_CHANGED',
count: number,
};
const reducer: React.Reducer<State, Action> = (
prevState: State,
action: Action
): State => {
switch (action.type) {
case "COUNT_CHANGED":
return { ...prevState, count: action.count };
}
};
const [state, dispatch] = React.useReducer(reducer, { count: 0 });
Except for more lines of code, they function exactly the same.
Here useReducer
takes in two parameters.
- The first being a
reducer
function:(prevState, action) => newState
. Upondispatch
ing anaction
, it updates (reduces) the prevState to a newState. - The second is the initial state, same to the one passed into
useState
.
We have only one action called COUNT_CHANGED
. So the following two lines will trigger the same state update:
// with useState
setState({ count: 1 });
// with useReducer
dispatch({ type: 'COUNT_CHANGED', count: 1 });
useState can replace useReducer, too
One claimed advantage of useReducer
is its ability to handle complex state values. Let's create an example here. Let's say we have a root-level form component that contains three input components, and we want each input to handle its own value. The UI looks like below:
<UserForm>
<FirstNameInput />
<LastNameInput />
<AgeInput />
</UserForm>
We create a reducer below to handle 3 input values:
// A complex state with user name and age
type UserState = {
name: {
first: string,
last: string,
},
age: number,
};
// Three action types to update each state value
type Action =
| {
type: "FIRST_NAME_CHANGED";
first: string;
}
| {
type: "LAST_NAME_CHANGED";
last: string;
}
| {
type: "AGE_CHANGED";
age: number;
};
const reducer: React.Reducer<UserState, Action> = (
prevState: UserState,
action: Action
): UserState => {
switch (action.type) {
case "FIRST_NAME_CHANGED":
return { ...prevState, name: { ...prevState.name, first: action.first } };
case "LAST_NAME_CHANGED":
return { ...prevState, name: { ...prevState.name, last: action.last } };
case "AGE_CHANGED":
return { ...prevState, age: action.age };
}
};
And now use it in our UserForm
component. Note that dispatch
is passed into each Input
so they can trigger actions to update their own field.
const UserForm = () => {
const [state, dispatch] = React.useReducer(reducer, {
name: { first: "first", last: "last" },
age: 40
});
return (
<React.Fragment>
<FirstNameInput value={state.name.first} dispatch={dispatch} />
<LastNameInput value={state.name.last} dispatch={dispatch} />
<AgeInput value={state.age} dispatch={dispatch} />
</React.Fragment>
)
}
Done. This is how useReducer
can work for complex states. Now how to convert to useState
?
A naive way is passing down one big state object to each Input
. We have to pass down the entire state because each Input
needs to know the 'full picture' of current state for it to properly construct a new state. Something like below:
// This is a bad example.
const UserForm = () => {
const [state, setState] = React.useState({
name: { first: "first", last: "last" },
age: 40
});
return (
<React.Fragment>
<FirstNameInput state={state} setState={setState} />
<LastNameInput state={state} setState={setState} />
<AgeInput state={state} setState={setState} />
</React.Fragment>
)
}
This is bad for several reasons:
- No separation of duties: Each
Input
now requires full state as its props to work. Making it harder to refactor and unit test. - Poor performance: Any state change will trigger all
Input
s to re-render.
In fact, these are exactly the reasons why React team suggests using useReducer
for this kind of complex state.
But that doesn't mean we can't use useState
to achieve the same result. It just requires a bit more crafting.
function Counter() {
const { state, setFirstName, setLastName, setAge } = useComplexState({
name: { first: "first", last: "last" },
age: 40
});
return (
<React.Fragment>
<FirstNameInput value={state.name.first} setFirstName={setFirstName} />
<LastNameInput value={state.name.last} setLastName={setLastName} />
<AgeInput value={state.age} setAge={setAge} />
</React.Fragment>
)
}
// A custom hook that returns setter functions for each field.
// This is similar to what the reducer above is doing,
// we simply convert each action into its own setter function.
function useComplexState(initialState: UserState): any {
const [state, setState] = React.useState<UserState>(initialState);
const setFirstName = first =>
setState(prevState => ({
...prevState,
name: { ...prevState.name, first }
}));
const setLastName = last =>
setState(prevState => ({
...prevState,
name: { ...prevState.name, last }
}));
const setAge = age => setState(prevState => ({ ...prevState, age }));
return { state, setFirstName, setLastName, setAge };
}
In fact, we can completely rewrite useReducer
with only useState
:
const useReducerImplementedByUseState = (reducer, initialState) => {
const [state, setState] = React.useState<State>(initialState);
const dispatch = (action: Action) => setState(prevState => reducer(prevState, action));
return [state, dispatch];
};
// above implementation
const [state, dispatch] = useReducerImplementedByUseState(reducer, initialState);
// is same with
const [state, dispatch] = useReducer(reducer, initialState);
In conclusion,
- For simple value state, do
useState
for it uses less lines. - For complex state, use whichever you feel like at the moment 🤪
Do you prefer useState
or useReducer
in your projects? Share your thoughts in the comment below ❤️
Top comments (0)