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
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
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
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
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
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
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.