An Interest In:
Web News this Week
- March 20, 2024
- March 19, 2024
- March 18, 2024
- March 17, 2024
- March 16, 2024
- March 15, 2024
- March 14, 2024
A Practical Introduction to Using Redux with React
Table of Contents
- Introduction
- What is Redux?
- What is the State?
- How to Modify the State?
- Unidirectional Data Flow
- Setting Up Redux in a React App
- Using React Hooks to Read the State
- Using React Hooks to Dispatch Actions
- Using "json-server" for the Local Fake API
- Async Actions
- Multiple Reducers
- Feature Folders and Ducks
- Using the "Ducks" Pattern in Our Example App
Introduction
In this tutorial I'd like to explain briefly what is Redux and how to set it up in a React project.
This tutorial would be useful for you if you've learned React already and would like to learn how Redux can help managing the app's state globally.
What is Redux?
Redux is a state management library. Commonly, it is used together with React, but it can be used with other view libraries too.
Redux helps us to keep the state of the whole app in a single place.
What is the State?
I would describe "state" as the data that is used to render the app at any given time. We keep this data in a JavaScript object. For example, in a simple app which renders a list of muffins, the state could look like this:
let state = { muffins: [ { name: 'Chocolate chip muffin' }, { name: 'Blueberry muffin' } ]}
How to Modify the State?
To modify the state from within a component we dispatch an action:
// SomeComponent.jsdispatch({ type: 'muffins/add', payload: { muffin: { name: 'Banana muffin' }, },});
Dispatching actions is the only way to change the state.
An action is represented by an object with the type
property. The type
property is the action's name. You can add any other property to this object (this is how you pass the data to reducer).
There are no formal rules as to how you should name your actions. Give your actions descriptive and meaningful names. Don't use ambiguous names, like receive_data
or set_value
.
It is a common practice to share actions through the action creator functions. Such functions create and return the action objects. We store action creators outside of the component files (e.g., src/redux/actions.js). This makes it easy to see what actions are available in the app and to maintain and reuse them.
// actions.jsexport function addMuffin(muffin) { return { type: 'muffins/add', payload: { muffin }, };}// SomeComponent.jsdispatch(addMuffin({ name: 'Banana muffin' }));
Once an action is dispatched, Redux calls the reducer with the previous state and the dispatched action object as the arguments. Reducer is a function which decides how to change the state according to a given action. We create this function and register it with Redux.
This is how a basic reducer looks like:
let initialState = { muffins: [ { id: 1, name: 'Chocolate chip muffin' }, { id: 2, name: 'Blueberry muffin' }, ],};function reducer(state = initialState, action) { switch (action.type) { case 'muffins/add': let { muffin } = action.payload; return { ...state, muffins: [...state.muffins, muffin] }; default: return state; }}
When this reducer identifies the muffins/add
action it adds the given muffin to the list.
IMPORTANT. The reducer copies the previous state object instead of mutating it. The rule is that the state must be immutable (read-only). The reducer should copy any object that it would like to change before changing it. This includes the root object and any nested objects.
We need to copy the state for Redux to be able to check (using shallow checking) if the state returned by the reducer is different from the previous state. Check this for more details about shallow checking: How do shallow and deep equality checking differ?. It is important to follow this rule for Redux to respond to our state changes correctly. Also, when using redux with react-redux this helps react-redux to decide which components should be re-rendered when the state changes.
The other important rule is that the reducer function should be pure. Given the same input it should always produce the same output without causing any side effects. A side affect is something that reads or changes the environment around the function. Examples of side effects are reading or writing a global variable, running a network request, etc. This rule helps us reproduce the look and behavior of the app given a particular state object.
Also, both of these rules make sure that Redux's time travel feature works properly with our app. Time travel allows us to easily undo actions and then apply them again. This helps a lot with debugging using Redux DevTools.
To summarize:
- Our app has a single state.
- To change this state we dispatch actions.
- The reducer function handles the dispatched actions and changes the state accordingly.
- Redux and react-redux check the state returned by the reducer for changes using shallow checking.
Unidirectional Data Flow
So, we've learned the following about Redux: we dispatch an action from the view layer (e.g., a React component), reducer gets this action and changes the state accordingly, the store notifies the view layer about the state change and the view layer renders the app according to the latest state. And the cycle repeats when we need to change the state again.
So, the data in a Redux app flows in a single way circular pattern. It is also called a unidirectional data flow. This is how we could represent it using a diagram:
This pattern makes it easier to understand how a Redux app works.
Setting Up Redux in a React App
In this post we will be building a simple app which lists a number of muffins.
I've initialized a basic React app using create-react-app:
npx create-react-app my-react-redux
I removed extra code and rendered a hard-coded list of muffins. This is what I've got: View on GitHub
Let's go ahead and store the muffins in the state.
First, let's install "redux" and "react-redux" packages:
npm i -S redux react-redux
Remember, Redux can be used with other view libraries. So we need the "react-redux" package to connect React components with Redux store.
Next, we should prepare the Redux store. The store is an object which keeps the app's state and provides the API for working with it. It allows us to:
- read the state
- dispatch actions to change the state
- and subscribe/unsubscribe to/from the state changes
IMPORTANT. Your app should have a single store.
Let's go ahead and set up the store for our example app.
Let's keep the Redux functionality in the folder called "redux":
mkdir src/redux
Let's write the store initialization code in the file src/redux/store.js:
// File: src/redux/store.jsimport { createStore } from 'redux';const initialState = { muffins: [ { id: 1, name: 'Chocolate chip muffin' }, { id: 2, name: 'Blueberry muffin' }, ],};const reducer = (state = initialState, action) => { switch (action.type) { default: return state; }};const store = createStore(reducer);export default store;
We use the createStore
function from the redux
package to create the store. When the store initializes, it obtains the initial state by calling our reducer function with undefined
for the state and a dummy action (e.g., reducer(undefined, { type: 'DUMMY' })
).
Now we should provide the store to the React components.
For this, we open the src/index.js and wrap the <App />
component into the <Provider />
component from the "react-redux" package:
import React from 'react';import ReactDOM from 'react-dom';import { Provider } from 'react-redux';import './index.css';import App from './components/App';import store from './redux/store';ReactDOM.render( <Provider store={store}> <App /> </Provider>, document.getElementById('root'));
The <Provider />
component provides the store to the child component tree using React context. Now we can use the React hooks or the connect
function from the "react-redux" package to get the state and dispatch actions from any component in the tree.
Using React Hooks to Read the State
Instead of hard-coding the muffin list in the "Muffins.js", let's use the useSelector
hook from "react-redux" to select the muffins array from the state.
// file: src/redux/selectors.jsexport const selectMuffinsArray = (state) => state.muffins;
// file: src/components/Muffins/Muffins.jsimport React from 'react';import { useSelector } from 'react-redux';import { selectMuffinsArray } from '../../redux/selectors';const Muffins = () => { const muffins = useSelector(selectMuffinsArray); return ( <ul> {muffins.map((muffin) => { return <li key={muffin.id}>{muffin.name}</li>; })} </ul> );};export default Muffins;
The useSelector
hook expects a selector function as the first argument. We create selector functions to provide a reusable API for selecting different parts of the state.
We use the state in many components. If we select things from the state directly (e.g. let muffins = state.muffins
) and at some point we change the structure of the state (e.g., state.muffins
becomes state.muffins.items
) we'd have to edit each component where we access the state properties directly. Using selector functions we can change the way we select the state in a single place (in our example, it is the "selectors.js" file).
Using React Hooks to Dispatch Actions
Let's add a "Like" button to each muffin in the list.
First, let's add the "likes" property to the state (number of likes).
// file: src/redux/store.jsconst initialState = { muffins: [ { id: 1, name: 'Chocolate chip muffin', likes: 11 }, { id: 2, name: 'Blueberry muffin', likes: 10 }, ],};
Next, let's render the number of likes and the "Like" button.
// file: src/components/Muffins/Muffins.js<li key={muffin.id}> {muffin.name} <button>Like</button> <i>{muffin.likes}</i></li>
Now, let's get the dispatch
function in the component using the useDispatch
hook from "react-redux".
// file: src/components/Muffins/Muffins.jsimport { useSelector, useDispatch } from 'react-redux';// ...const dispatch = useDispatch();
Let's define an action for the "Like" button.
// File: src/redux/actions.jsexport const likeMuffin = (muffinId) => ({ type: 'muffins/like', payload: { id: muffinId },});
Next, let's create the "click" event handler for the "Like" button:
// {"lines": "2,4-9,12"}// file: src/components/Muffins/Muffins.jsimport { likeMuffin } from '../../redux/actions';// ...{ muffins.map((muffin) => { const handleLike = () => { dispatch(likeMuffin(muffin.id)); }; return ( <li key={muffin.id}> {muffin.name} <button onClick={handleLike}>Like</button>{' '} <i>{muffin.likes}</i> </li> ); });}
If we click this button, nothing happens, because we didn't create a reducer for the action that is dispatched (muffins/like
).
So, let's go ahead and reduce this action.
// {"lines": "4-14"}// file: src/redux/store.jsconst reducer = (state = initialState, action) => { switch (action.type) { case 'muffins/like': const { id } = action.payload; return { ...state, muffins: state.muffins.map((muffin) => { if (muffin.id === id) { return { ...muffin, likes: muffin.likes + 1 }; } return muffin; }), }; default: return state; }};
It's important NOT to mutate the state. So, I copy the state object, copy the muffins array (the map method returns a new array). Finally, I copy only the muffin which is being changed. I don't touch the other muffins to signify that they do not change.
Now, if we click the "Like" button, the muffins/like
action is dispatched and the reducer changes the state accordingly. The number of likes of the chosen muffin increments.
Using "json-server" for the Local Fake API
"json-server" is a fake REST API server which is really easy to set up. We can use it to mock API endpoints while working on a front end app. I'd like to use this server for the examples in this post. So let me show you how to install and run it.
To install:
npm i -D json-server
To tell the server what data it should serve we create a JSON file. Let's call it db.json.
{ "muffins": [ { "id": 1, "name": "Chocolate chip muffin", "likes": 11 }, { "id": 2, "name": "Blueberry muffin", "likes": 10 } ]}
Now let's open package.json and add the script which will start this server:
// {"lines": "2"}"scripts": { "json-server": "json-server --watch db.json --port 3001"}
To run it:
npm run json-server
The server should start on http://localhost:3001.
To stop it, focus on the terminal window where you started it and press CTRL + C
.
We can use the following routes ("json-server" generates them by looking at db.json)
GET /muffinsPOST /muffinsPUT /muffins/{id}DELETE /muffins/{id}
Async Actions
Please check the section Using "json-server" for the Local Fake API.
Usually, we run network requests to get and edit the data. Let's see how to do it the Redux way.
By default, Redux allows us to dispatch an action only in the form of an object with the type
property.
However, Redux allows us to alter the way it dispatches actions using a middleware function. One such function is called "redux-thunk".
Let's install and register this middleware function with Redux.
npm i -S redux-thunk
// file: src/redux/store.jsimport { createStore, applyMiddleware } from 'redux';import thunk from 'redux-thunk';// ...const store = createStore(reducer, applyMiddleware(thunk));
applyMiddleware
is a utility function which takes a list of middleware functions and groups them in a single middleware function which we pass to createStore
as the second argument.
Also, let's empty the muffins array in the initial state, because we are going to load muffins from the fake API.
// file: src/redux/store.jsconst initialState = { muffins: [],};
"redux-thunk" allows us to dispatch not only objects, but also functions:
dispatch((dispatch, getState) => { let state = getState(); // do something async and dispatch(/* some action */);});
The thunk function gets the original dispatch
function as the first argument and the getState
function as the second argument.
So, what we can do with a thunk function is, for example, to fetch the data from the network and when the data is ready we can dispatch an action object with this data, so reducer can add this data to the state.
Let's create the actions.js file and add the async action creator function for loading muffins.
// file: src/redux/actions.jsexport const loadMuffins = () => async (dispatch) => { dispatch({ type: 'muffins/load_request', }); try { const response = await fetch('http://localhost:3001/muffins'); const data = await response.json(); dispatch({ type: 'muffins/load_success', payload: { muffins: data, }, }); } catch (e) { dispatch({ type: 'muffins/load_failure', error: 'Failed to load muffins.', }); }};
A thunk function can be either sync or async. We can dispatch multiple actions in this function. In our example we dispatch the muffins/load_request
action to signify that the request starts. We can use this action to show a spinner somewhere in the app. Then, when the request succeeds we dispatch the muffins/load_success
action with the fetched data. Finally, if the request fails, we dispatch the muffins/load_failure
action to show the error message to the user.
Now, let's create the reducers for these actions.
// file: src/redux/store.jsconst reducer = (state = initialState, action) => { switch (action.type) { // ... case 'muffins/load_request': return { ...state, muffinsLoading: true }; case 'muffins/load_success': const { muffins } = action.payload; return { ...state, muffinsLoading: false, muffins }; case 'muffins/load_failure': const { error } = action; return { ...state, muffinsLoading: false, error }; // ... }};
Let's dispatch the loadMuffins
action in the Muffins
component, when it mounts.
// file: src/components/Muffins/Muffins.jsimport React, { useEffect } from 'react';import { loadMuffins } from '../../redux/actions';// ...const dispatch = useDispatch();useEffect(() => { dispatch(loadMuffins());}, []);
We are loading muffins in the effect hook, because dispatching an action is a side effect.
Finally, let's handle the loading and error states.
Create the following selector functions:
// file: src/redux/selectors.jsexport const selectMuffinsLoading = (state) => state.muffinsLoading;export const selectMuffinsLoadError = (state) => state.error;
And render the loading and error messages:
// file: src/components/Muffins/Muffins.jsconst muffinsLoading = useSelector(selectMuffinsLoading);const loadError = useSelector(selectMuffinsLoadError);// ...return muffinsLoading ? ( <p>Loading...</p>) : loadError ? ( <p>{loadError}</p>) : muffins.length ? ( <ul> {muffins.map((muffin) => { // ... })} </ul>) : ( <p>Oh no! Muffins have finished!</p>);
Now, let's check if we did everything correctly.
We should run the local "json-server" and the app.
In one terminal window:
npm run json-server
And in the other:
npm start
In the browser you should see the list of muffins which is, now, fetched from the fake API server.
Multiple Reducers
Usually, in a large app, state won't be that simple. It will look like a huge tree of data.
The reducer function will become bloated.
So, it's a good idea to split the reducer into multiple smaller reducers where each reducer handles only a part of the state.
For example, in order to handle the state from the picture above, it would be a good idea to create 3 reducers:
const muffinsReducer = (state = initialMuffinsState, action) => { // ...};const notificationsReducer = (state = initialNotificationsState, action) => { // ...};const cartReducer = (state = initialCartState, action) => { // ...};
and combine them using the utility function called combineReducers
:
const rootReducer = combineReducers({ muffins: muffinsReducer, notifications: notificationsReducer, cart: cartReducer,});const store = createStore(rootReducer);
combineReducers
creates a root reducer function which calls each sub reducer when the action is dispatched and combines the parts of the state they return into a single state object:
{ muffins: ..., notifications: ..., cart: ...}
Combining reducers makes it easy to modularize the reducer logic.
Feature Folders and Ducks
The Redux documentation recommends structuring Redux functionality as feature folders or ducks.
Feature Folders
Instead of grouping all actions and reducers by the type of code (for example, all the app's actions in actions.js and all reducers in reducers.js), we could group them by feature.
Let's say there are two features: "users" and "notifications". We could keep their actions and reducers in separate folders. For example:
redux/ users/ actions.js reducers.js notifications/ actions.js reducers.js store.js
Ducks
The "ducks" pattern says that we should keep all Redux logic (actions, reducers, selectors) for a specific feature in its own file. For example:
redux/ users.js notifications.js store.js
Using the "Ducks" Pattern in Our Example App
In the app we've got different Redux functionality around muffins. We can group this functionality into a duck. In other words, let's just move everything related to mufffins into a JavaScript file and call it src/redux/muffins.js.
Let's move the actions, selectors and the reducer to this file:
export const likeMuffin = (muffinId) => ({ type: 'muffins/like', payload: { id: muffinId },});export const loadMuffins = () => async (dispatch) => { dispatch({ type: 'muffins/load_request', }); try { const response = await fetch('http://localhost:3001/muffins'); const data = await response.json(); dispatch({ type: 'muffins/load_success', payload: { muffins: data, }, }); } catch (e) { dispatch({ type: 'muffins/load_failure', error: 'Failed to load muffins.', }); }};export const selectMuffinsArray = (state) => state.muffins;export const selectMuffinsLoading = (state) => state.muffinsLoading;export const selectMuffinsLoadError = (state) => state.error;const initialState = { muffins: [],};const reducer = (state = initialState, action) => { switch (action.type) { case 'muffins/like': const { id } = action.payload; return { ...state, muffins: state.muffins.map((muffin) => { if (muffin.id === id) { return { ...muffin, likes: muffin.likes + 1 }; } return muffin; }), }; case 'muffins/load_request': return { ...state, muffinsLoading: true }; case 'muffins/load_success': const { muffins } = action.payload; return { ...state, muffinsLoading: false, muffins }; case 'muffins/load_failure': const { error } = action; return { ...state, muffinsLoading: false, error }; default: return state; }};export default reducer;
Now, in the src/redux/store.js, let's create the root reducer using the combineReducers
function:
// {"lines": "6-10"}// File: src/redux/store.jsimport { createStore, applyMiddleware, combineReducers } from 'redux';import thunk from 'redux-thunk';import muffinsReducer from './muffins';const rootReducer = combineReducers({ muffins: muffinsReducer,});const store = createStore(rootReducer, applyMiddleware(thunk));export default store;
Now, the app's state looks like this:
{ muffins: { muffins: [], muffinsLoading: boolean, error: string }}
Since the structure of the state has changed, to make the app work, we should update the parts of the code where we read the state. Luckily, we use selector functions to select parts of the state object instead of working with the state object directly. So, we only have to update the selector functions:
// File: src/redux/muffins.jsexport const selectMuffinsState = (rootState) => rootState.muffins;export const selectMuffinsArray = (rootState) => selectMuffinsState(rootState).muffins;export const selectMuffinsLoading = (rootState) => selectMuffinsState(rootState).muffinsLoading;export const selectMuffinsLoadError = (rootState) => selectMuffinsState(rootState).error;
Finally, let's update the import statements:
// {"lines": "6,7"}// File: src/components/Muffins/Muffins.jsimport { selectMuffinsArray, selectMuffinsLoading, selectMuffinsLoadError,} from '../../redux/muffins';import { likeMuffin, loadMuffins } from '../../redux/muffins';
That's it! We used the "ducks" pattern to move the Redux functionality around managing the muffins state into a single file.
Original Link: https://dev.to/ddmytro/a-practical-introduction-to-using-redux-with-react-1a0m
Dev To
An online community for sharing and discovering great ideas, having debates, and making friendsMore About this Source Visit Dev To