React Advanced State
All state management in this application makes heavy use of React's useState Hook. On the other hand, React's useReducer Hook enables one to use more sophisticated state management for complex state structures and transitions. Since the knowledge about reducers in JavaScript splits the community in half, we won't cover the basics here. However, if you haven't heard about reducers before, check out this guide about reducers in JavaScript.
In this section, we will move the stateful stories
from React's useState
hook to React's useReducer
hook. Using useReducer
over useState
makes sense as soon as multiple stateful values are dependent on each other or related to one domain. For example, stories
, isLoading
, and error
are all related to the data fetching. In a more abstract version, all three could be properties in a complex object (e.g. data
, isLoading
, error
) managed by a reducer instead. We will cover this in a later section. In this section, we will start to manage the stories
and its state transitions in a reducer.
First, introduce a reducer function outside of your components. A reducer function always receives a state
and an action
. Based on these two arguments, a reducer always returns a new state:
src/App.jsx
const getAsyncStories = () =>
new Promise((resolve) => ... );
const storiesReducer = (state, action) => {
if (action.type === 'SET_STORIES') {
return action.payload;
} else {
throw new Error();
}
};
A reducer action
is always associated with a type
and as a best practice with a payload
. If the type
matches a condition in the reducer, return a new state based on incoming state and action. If it isn't covered by the reducer, throw an error to remind yourself that the implementation isn't covered. The storiesReducer
function covers one type
and then returns the payload
of the incoming action without using the current state to compute the new state. The new state is therefore simply the payload
.
In the App component, exchange useState
for useReducer
for managing the stories
. The new hook receives a reducer function and an initial state as arguments and returns an array with two items. The first item is the current state and the second item is the state updater function (also called dispatch function):
src/App.jsx
const App = () => {
...
const [stories, dispatchStories] = React.useReducer(
storiesReducer,
[]
);
...
};
The new dispatch function can be used instead of the setStories
function, which was previously returned from useState
. Instead of setting the state explicitly with the state updater function from useState
, the useReducer
state updater function sets the state implicitly by dispatching an action for the reducer. The action comes with a type
and an optional payload
:
src/App.jsx
const App = () => {
...
React.useEffect(() => {
setIsLoading(true);
getAsyncStories()
.then((result) => {
dispatchStories({
type: 'SET_STORIES',
payload: result.data.stories,
});
setIsLoading(false);
})
.catch(() => setIsError(true));
}, []);
const handleRemoveStory = (item) => {
const newStories = stories.filter(
(story) => item.objectID !== story.objectID
);
dispatchStories({
type: 'SET_STORIES',
payload: newStories,
});
};
...
};
The application appears the same in the browser, though a reducer and React's useReducer
hook are managing the state for the stories now. Let's bring the concept of a reducer to a minimal version by handling more than one state transition. If there is only one state transition, a reducer wouldn't make sense.
So far, the handleRemoveStory
handler computes the new stories. It's valid to move this logic into the reducer function and manage the reducer with an action, which is another case for moving from imperative to declarative programming. Instead of doing it ourselves by saying how it should be done, we are telling the reducer what to do. Everything else is hidden in the reducer:
src/App.jsx
const App = () => {
...
const handleRemoveStory = (item) => {
dispatchStories({
type: 'REMOVE_STORY',
payload: item,
});
};
...
};
Now the reducer function has to cover this new case in a new conditional state transition. If the condition for removing a story is met, the reducer has all the implementation details needed to remove the story. The action gives all the necessary information (here an item's identifier) to remove the story from the current state and return a new list of filtered stories as state:
src/App.jsx
const storiesReducer = (state, action) => {
if (action.type === 'SET_STORIES') {
return action.payload;
} else if (action.type === 'REMOVE_STORY') {
return state.filter(
(story) => action.payload.objectID !== story.objectID
);
} else {
throw new Error();
}
};
All these if-else statements will eventually clutter when adding more state transitions into one reducer function. Refactoring it to a switch statement for all the state transitions makes it more readable and is seen as a best practice in the React community:
src/App.jsx
const storiesReducer = (state, action) => {
switch (action.type) {
case 'SET_STORIES':
return action.payload;
case 'REMOVE_STORY':
return state.filter(
(story) => action.payload.objectID !== story.objectID
);
default:
throw new Error();
}
};
What we've covered is a minimal version of a reducer in JavaScript and its usage in React with the help of React's useReducer Hook. The reducer covers two state transitions, shows how to compute the current state and action into a new state, and uses some business logic (removal of a story) for a state transition. Now we can set a list of stories as state for the asynchronously arriving data and remove a story from the list of stories with just one state managing reducer and its associated useReducer
hook. To fully grasp the concept of reducers in JavaScript and the usage of React's useReducer Hook, visit the linked resources in the exercises. We will continue expanding our implementation of a reducer in the next section.
Exercises:
- Compare your source code against the author's source code.
- Recap all the source code changes from this section.
- Optional: If you are using TypeScript, check out the author's source code here.
- Read more about reducers and useReducer in React.
- Extract the action types
'SET_STORIES'
and'REMOVE_STORY'
as variables and reuse them in the reducer and the dispatch functions. This way, you will avoid introducing typos in your action types. - Optional: Leave feedback for this section.
Interview Questions:
- Question: What is useReducer in React?
- Answer: useReducer is a React hook that manages complex state logic in function components by dispatching actions to update state.
- Question: How does useReducer differ from useState in React?
- Answer: While useState is simpler for managing individual state variables, useReducer is more suitable for complex state logic where multiple values depend on each other.
- Question: What is the basic structure of the useReducer hook?
- Answer: It returns the current state and a dispatch function for triggering state updates, taking a reducer function and an initial state as arguments.
- Question: What is a reducer function in useReducer?
- Answer: The reducer function is responsible for specifying how the state should change in response to dispatched actions, based on the current state and the action.
- Question: How is state updated using useReducer?
- Answer: State is updated by dispatching actions, and the reducer function determines the new state based on the current state and the action type.
- Question: Can useReducer replace all use cases of useState in React?
- Answer: While useReducer is powerful, it's not necessary for all scenarios. useState is simpler and more suitable for managing individual state variables.