Redux and Modern Redux Toolkit with Thunks - vonschappler/Ultimate-React GitHub Wiki
Redux is a 3rd-party application, used to manage global state. For being a standalone library it can be integrated with React applications - using the react-redux library, other framkeworks and even being used with Vanilla JavaScript.
The basic concept of Redux is storing all global state inside a globally accessible store, making it easy to update using "actions" (just like the useReducer hook)
Whenver the global store is updated, all components that consume the global state are re-rendered, making the use of Redux really similar to using the Context API with useReducer
There are today two ways for writing code using Redux, which are compatible with each other
- Classic Redux
- Modern Redux Toolkit
Local State | Global State | |
---|---|---|
UI State |
|
|
Remote State |
|
|
Redux is then used mostly when there is lots of state that updates requently, mostly when it comes to UI State, since nowadays we have better options to manage those in Remote State, like for example, React Query.
An event handler function inside a component will dispact and action to the global store, which contains the current state of the components and individual reducers for each state stored inside it, making this store the "single source of truth" for all global state in the application.
After the action is dispatched and processed by the reducers, a new state is stored inside the store and all components which consume those updated states will be re-rendered, if we assume we are using Redux with React (since as mentioned before, Redux is a stand alone application which can be used with any framework or even Vanilla JavaScript)
When using Redux, we make use of action creator funcions, which make the process of creating actions automatic, instead of always writing action objects manually, the function creates those objects, making this process easier, because developers won't need to remember every action type string. Keep in mind that this is not a feature of Redux, making use of those something optional, but this is how most developers make use of Redux.
The big goal of all this process is to make the state update logic separated from the rest of the the application.
In order to use Classic Redux, a few defaults are required:
- Using a usually named "store.js" (or any other extension depending on which framework is used), a initial global state is defined (similar to what is done with userReducer)
- A reducer function is created in almost the same way as it's done when creating reducer functions to use with the useReducer hook. The only difference here is that with redux, the "default" case inside the switch-case block of the function should return the current state instead of returning an error.
- The store must be defined in the code. For that, it's required the use of Redux library, which can be used by the command:
npm install redux
- The method called createStore is used to create an store. That method recieved the reducer function as argument and it returns as a result the store itself, which access to other functions like the dispatch, for example.
- With all the above in hands, it's time do define the action creator functions, which are simple functions which are going to return actions. Keep in mind that Redux works perfectly fine without those functions, but as an covention it's advised to create them.
- Because the goal of Redux is to centralize all global state in a single place, in case it's necessary to manage two or more state changes, the function combineReducers is used of this purpose.
- First, by convention, reducer functions and initial states for each stage which requires manangement should be created, as well as their related action creator functions.
- Then using the method combineReducers those are combined into an object, where the keys of the object will make reference to the state name while the values for each key, will reference each reducer function.
- Finally the store should be created using the combined reducers created on the step above.
- A more practical way of organizing modern code base is structuring them by features, meaning that it's a nicer and better way of working with React application if the source code is structured by feature, where each sub-folder of the application features folder consists in all files related to the same feature. Before we get to this point, it's time to present a code snippet which related to all the explanation above on how to use Classic Redux:
import { createStore, combineReducers } from 'redux';
const initialStateA = {
state_01: 0,
state_02: '',
};
const inititalStateB = {
state_03: '',
state_04: 1,
};
function stateReducerA(state = initialStateA, action) {
switch (action.type) {
// note the the action can be written in any way, but as a convension, we use the domain/action, ie., users/add in a more
// real case usage
// the data returned from each action makes deference to the operations/changes made to the current state, meaning that logical operations should be described there
case 'domainA/action1':
return { ...state, state_01: action.payload };
case 'domainA/action2':
return { ...state, state_02: action.payload };
case 'domainA/action3':
return {
...state,
state_01: action.payload.state1,
state_02: action.payload.state2,
};
default:
return state;
}
}
function stateReducerB(state = initialStateB, action) {
switch (action.type) {
case 'domainB/action':
return {
...state,
state_03: action.payload.state3,
state_04: action.payload.state4,
};
default:
return state;
}
}
function changeState_01(newValue) {
return { type: 'domainA/action01', payload: newValue };
}
function changeState_02(newValue) {
return { type: 'domainA/action02', payload: newValue };
}
function changeState_01_02(newValue01, newValue02) {
return {
type: 'domainA/action03',
payload: {
state1: newValue01,
state2: newValue02,
},
};
}
function changeState_03_04(newValue03, newValue04) {
return {
type: 'domainB/action',
payload: {
state3: newValue03,
state4: newValue04,
},
};
}
const rootReducer = combineReducers({
stateA: stateReducerA,
stateB: stateReducerB,
});
const store = createStore(rootReducer);
// usage example for testing purposes
store.dispatch(changeState_01(500));
console.log(store.getState());
store.dispatch(changeState_02('Some value'));
console.log(store.getState());
store.dispatch(changeState_01_02(600, 'Some new value'));
console.log(store.getState());
store.dispatch(changeState_03_04('Some another new value', 700));
console.log(store.getState());
As mentioned before, this way of structring the code consists in simply spliting files "per feature" inside the application folder structure. In short, what is done here is to create individual files for each important feature of the application, placing the ones related to each other inside a <feature_name>
sub-folder.
As an example, imagine an application where one of its features is to manage user accounts. This mean the application should then have inside the features folder and inside it a sub-folder called users, wich will contain all files related to it.
To apply this idea over the snippet of code presented above, it would imply that we'd end in 3 separated files, at least:
// file features/featureA/domainASlice.js
const initialStateA = {
state_01: 0,
state_02: '',
};
export default function stateReducerA(state = initialStateA, action) {
switch (action.type) {
case 'domainA/action1':
return { ...state, state_01: action.payload };
case 'domainA/action2':
return { ...state, state_02: action.payload };
case 'domainA/action3':
return {
...state,
state_01: action.payload.state1,
state_02: action.payload.state2,
};
default:
return state;
}
}
export function changeState_01(newValue) {
return { type: 'domainA/action01', payload: newValue };
}
export function changeState_02(newValue) {
return { type: 'domainA/action02', payload: newValue };
}
export function changeState_01_02(newValue01, newValue02) {
return {
type: 'domainA/action03',
payload: {
state1: newValue01,
state2: newValue02,
},
};
}
// file features/featureB/domainBSlice.js
const inititalStateB = {
state_03: '',
state_04: 1,
};
export default function stateReducerB(state = initialStateB, action) {
switch (action.type) {
case 'domainB/action':
return {
...state,
state_03: action.payload.state3,
state_04: action.payload.state4,
};
default:
return state;
}
}
export function changeState_03_04(newValue03, newValue04) {
return {
type: 'domainB/action',
payload: {
state3: newValue03,
state4: newValue04,
},
};
}
// file store.js
import { createStore, combineReducers } from 'redux';
import storeReducerA from './path/to/domainASlice.js';
import storeReducerB from './path/to/domainBSlice.js';
const rootReducer = combineReducers({
stateA: stateReducerA,
stateB: stateReducerB,
});
const store = createStore(rootReducer);
export default store;
In order to connect Redux and React, a 3rd-party package (react-redux) needs to be installed into the application, by using the command:
npm install react-redux
This package allows us to access the Provider component (which is similar to the provider API), which is used to wrap the whole application as shown on the snippet below:
import React from 'react';
import ReactDOM from 'react-dom/client';
import { Provider } from 'react-redux';
import './index.css';
import App from './App';
import store from './store';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<Provider store={store}>
<App />
</Provider>
</React.StrictMode>
);
After passing the store to the application like so, that will us to consume the global state provided by Redux on any component by using the hook useSelector provided also by react-redux 3rd-party package, like shown in the snippet below:
import { useSelector, useDispatch } from 'react-redux';
import { changeState_03_04 } from './path/to/domainBSlice.js';
function ComponentB() {
const { state_03, state_04 } = useSelector((store) => store.stateB);
const dispatch = useDispatch();
const handleUpdate = () => {
dispatch(changeState_03_04());
};
return (
<>
<h2>
{state_03}: {state_04}
</h2>
<button onClick={handleUpdate}>Update stateB</button>
</>
);
}
export default ComponentB;
The useSelector hook receives as argument a callback function, from which we pass in the glogalState returned by the store.js file and return anything we want from it, creating any logic necessary inside the callback function. From the component it's also possible to dispatch the actions exported used to update this global state, as also shown in the snippet of code abovem by making use of the hook useDispatch, which gives access to the dispatch function from react-redux, which receives as argument the action creator functions which we wish to dispatch.
We make use of Middlewares when we require to execute any asynchronous operation in Redux. The main reason for this is that, although it's possible to make data fetching inside components, this approach is not ideal, since the best practices of coding suggest that the component should be as clear from logic as possible, which implies that the fetching data logic should be outside a component.
Also we need to note that the store should not do any asynchronous operations, because the reducers should be pure functions, meaning that running async calls inside the reducers is also not part of the best practices (and may not even be allowed).
So, in short, a Middleware in Redux is a function that "sits" between dispatching the action and the store, allowing us to run code after dispatching, but before reaching the reducer in the store. This makes middlewares the perfect place for asynchronous code, API calls, timers, logging, as well as the place to run any side effects.
Although we can write Middleware functions ourselves, when to comes to Redux, the most popular middleware is called Redux Thunk.
To use the Thunk Middleware, three steps are required:
- Install the package using:
npm install redux-thunk
- Apply the middleware to the store, as displayed in the snippet of code below
// file store.js
import { applyMiddleware, createStore, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import storeReducerA from './path/to/domainASlice.js';
import storeReducerB from './path/to/domainBSlice.js';
const rootReducer = combineReducers({
stateA: stateReducerA,
stateB: stateReducerB,
});
const store = createStore(rootReducer, applyMiddleware(thunk));
export default store;
- Use the middleware inside the action creator functions, as displayed in the snippet of code below
// file features/featureB/domainBSlice.js
// some code here
export function changeState_03_04(newValue03, newValue04) {
return async function(dispatch, getState) {
const res = await fetch(`custom.api.url?value1=${newValue3}&value2=${newValue4}`)
const data = await res.json()
const state3 = data.state3
const state4 = date.state4
dispatch({type: 'domainB/action', payload {state3, state4}})
}
}
// some more code here
Redux comes with a nice developer toolkit which allows us to debug our code. In order to use this tookit, trhee steps are required:
- Install the the browser extension Redux DevTools
- Install the corresponding package on the application using the command:
npm install @redux-devtools/extension
- Change the file store.js, by wrapping the middleware function insde the function composeWithDevTools provided by the package installed on the step above
import { applyMiddleware, createStore, combineReducers } from 'redux';
import thunk from 'redux-thunk';
import { composeWithDevTools } from '@redux-devtools/extension';
import storeReducerA from './path/to/domainASlice.js';
import storeReducerB from './path/to/domainBSlice.js';
const rootReducer = combineReducers({
stateA: stateReducerA,
stateB: stateReducerB,
});
const store = createStore(
rootReducer,
composeWithDevTools(applyMiddleware(thunk))
);
export default store;
Redux toolkit is the modern and preferred way of writing Redux Code. The reason for this it's because this toolkit is an "opinionated" approach, which forces developers to use Redux coding best practices.
Both Modern and Classic Redux are compatible each other, meaning that both ways of writing Redux code can be used together. But Modern Redux have some advantages over classic Redux:
- We need less "boilerplate" in oder to write code
- We can write code that "mutates" state inside reducers, which will convert them back to immutable using a library called "Immer"
- Action creators are automatically created from reducers
- Automatically setup thunk middleware and DevTools.
To use this toolkit, first we need to install the package using the command below:
npm install @reduxjs/toolkit
The snippets of code below is a simple example of how to write a store using the Redux Toolkit.
The snipets are a conversion from older snippets from Classic Redux to Redux Toolkit:
// file store.js
import { configureStore } from '@reduxjs/toolkit';
import storeReducerA from './path/to/domainASlice.js';
import storeReducerB from './path/to/domainBSlice.js';
const store = configureStore({
reducer: {
stateA: storeReducerA,
stateB: storeReducerB,
},
});
export default store;
// file features/featureA/domainASlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
state_01: 0,
state_02: '',
};
const stateASlice = createSlice({
name: 'stateA',
initialState,
reducers: {
changeState_01(state, action) {
state.state_01 = action.payload;
},
changeState_02(state, action) {
state.state_02 = action.payload;
},
changeState_01_02: {
prepare(newValue01, newValue02) {
return {
payload: {newValue01, newValue02},
};
},
reducer(state, action) {
state.state_01 = action.payload.newValue01;
state.state_02 = action.payload.newValue02;
};
};
};
});
export const { changeState_01, changeState_02, changeState_01_02 } = stateASlice.actions;
export default stateASlice.reducer;
// file features/featureB/domainBSlice.js
import { createSlice } from '@reduxjs/toolkit';
const inititalState = {
state_03: '',
state_04: 1,
};
const stateBSlice = createSlice({
name: 'stateB',
initialState,
reducers: {
changeState_03_04: {
prepare(newValue01, newValue02) {
return {
payload: {newValue03, newValue04},
};
},
reducer(state, action) {
state.state_03 = action.payload.newValue03;
state.state_04 = action.payload.newValue04;
};
};
};
})
export const { changeState_03_04 } = stateBSlice.actions;
export default stateBSlice.reducer;
Context API + useReducer | Redux |
---|---|
Built into react | Requires addition packages (larger bundle size) |
Easy to setup a single context | More work to setup initially |
Additional state "slice" requires a new context setup from scratch (which may cause what we call "prodiver hell" in App.js) | Once set up, it's easy to create additional state "slices" |
No mechanism for async operations | Supports middlewares for async operations |
Performing optmiziation is a pain | Performance is optmized out of the box |
Only relies on React DevTools | Relies on excelent DevTools |
Based on the comparison above, here are some guidelines on when to use each global state management technique:
- Use the Context API + useReducer when...
- it's necessary to manage global state in small applications;
- the global state shared does not change too often (color theme, preferred language, authenticated user, etc.);
- it's necessary to solve a simple prop drilling problem;
- it's necessary to manage state in a local sub-tree of the application (for example the compound component pattern).
- Use Redux when...
- it's necessary to manage global state in large applications;
- the global state needs to be updated frequently (shpping cart, current opened tabs, complex filters or search, etc);
- the global state is formed by a complex state with nested objects and arrays.