Increased loading speed: Replacing Redux with React Hooks
Since I started my adventure with React many things have changed. I'm in love with all the new development features but sometimes I think it's growing too fast and it's really hard to track all interesting things at once. Recently, React introduced great hooks you probably already heard about, and if you did, then you know they provide great functionality. If you worked with global state management in React, you must have used Redux or MobX or a similar but less common package. As long as we have the latest useContext and useReduced React Hooks, we can forget about external libraries and I would like to show you how.
We will build a really basic application with three to four components connected by Redux (based on a public free API). Then, we are going to switch this connection from Redux to the newest native React functionality.
In a separate article, I would like to show you how you can make sure your code works properly and avoid many issues thanks to using TypeScript for this application. So I will go a bit deeper into properly constructed data flow through those context
, useReducer
, and all other components.
Create a React App
For this purpose, we are going to use the Create React App (CRA) environment as it’s most reliable and efficient for such use cases. If you are not familiar with CRA yet, I would suggest you make a quick read on their documentation, but in the end, it all comes down to three simple commands:
1
2
3
`npx create-react-app my-app`
`cd my-app`
`npm start`
…and that’s it, the magic happened. You can start coding your React project.
There’s one more thing we’d like to do before we start, which is installing a node-sass
library. Thanks to this library we can import our *scss
files directly into components. I'm not going to describe how they are styled as this is not our point of interest but for better UI & UX in our work, they will be included in the final solution.
Structure
Let’s begin with writing some fundamentals for our work. Let’s say we want to make a list with all English Premier League football teams, and a second list next to it where we will display the latest fixtures of clicked teams. It will be a really simple application with just two areas.
After the CRA installation, let's create the following structure in the src
folder:
1
2
3
4
5
6
7
8
9
├── src
│ ├── components
│ │ ├── Teams
│ │ │ ├── components
│ │ │ │ ├── Team
│ │ │ │ │ └── index.js
│ │ │ └── index.js
│ │ ├── Statistics
│ │ │ └── index.js
1
2
3
4
5
6
7
8
9
10
11
12
<section className="Teams app-panel">
<h2>Teams</h2>
{teams && (
<div className="Team__list">
{teams.map(team => (
<Team key={team.id} team={team}/>
))}
</div>
)}
</section>
Single Team component is a combination of icon (small logo of the team) and button which will trigger some action. But don't worry about it for now as well.
1
2
3
4
5
6
7
8
9
10
11
12
<p className="Team">
<img src={team.img} alt={team.shortName} />
<button
type="button"
className={`Team-link${current === team.id ? ' active' : ''}`}
name={team.shortName}
onClick={() => onTeamSelect(team.id)}
>
{team.name}
</button>
</p>
Our Statistic component will also display a title and loop of team fixtures as well as some information for the initial state where no team is selected yet.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<section className="Statistics app-panel">
<h2>Statistics</h2>
{teams && (
!current
? <p><i>Please select team to display information</i></p>
: <table className="Statistics__list">
<tbody>
{currentTeam.stats.map(match => (
<tr key={match.id}>
<td>({match.competition.name})</td>
<td>{match.homeTeam.name} {match.score.fullTime.homeTeam} - {match.score.fullTime.awayTeam} {match.awayTeam.name}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
And in the end we can go to our main App.js
file provided by CRA and render our components.
1
2
3
4
<main className="PremierLeague">
<Teams />
<Statistics />
</main>
I strongly believe the code is self-explanatory. You may have noticed that some variables and objects haven’t been described. We will discuss them later after we connect our store. For now, to avoid confusion, you can imagine they are just passed props to our components. Every step of this article will have a codesandbox.io entire code included so you can always reference a working example over there 🙂
Props Drilling
Those two areas have to be connected and share their states, so we are facing a situation where the local component state is not enough.
Of course, we can always pass our props through the components tree, but it is really important to remember: whenever your application grows, it will make keeping all things clean much harder.
You can find that solution under the props drilling name. The example below illustrates the situation for our (quite flat) structure:
The most common solution for the developers to deal with these problems used to be Redux with its Provider. If you are reading this article, I believe you know what Redux can do for you, but to be clear it’s a global state container that can keep all your app information and then thanks to the Provider from the react-redux
library (which uses standard React Context API
), pass them to whichever component needs it. So now we have the desired functionality:
Redux
Redux is still in expansion
I would like to focus on the latest build in React hooks. They can do pretty much the same thing without any external libraries needed. I’m not going into Redux any deeper than it’s required for this example, but if you still have some doubts about it, take a look at the documentation.
It is still a powerful and consistently updated library with many great features and even has its own hooks like useDispatch and useStore (introduced in mid-2019) that extract just the functionalities that you’re actually using. We are going to use the method that is the most common among developers and we are going to use HOC connect
to make our components 'talk' with the store.
But it is React itself that forces Redux to do so
I mean, since React is still moving with the times, today we have some really useful built-in hooks, which inspired the Redux team to implement their own.
So let me introduce you to the great and powerful cooperation of useReducer with useContext. To make it smoother to understand and see real differences, we are going to start with creating a global state based on Redux and then we'll switch it to the newest React functionality.
Let's do some Redux(ing)
Let’s start with building our store. We want to keep our teams’ array in the global state, as well as the current team ID for reference, whenever a user selects one of the teams.
Extend our folders structure like this:
1
2
3
4
5
6
7
8
9
10
├── src
│ ├── components
│ ├── store
│ │ ├── teams
│ │ │ ├── actions.js
│ │ │ └── reducers.js
│ │ ├── current
│ │ │ ├── actions.js
│ │ │ └── reducers.js
│ │ └── index.js
Let's write some basic actions, starting with teams:
1
2
3
4
5
6
7
8
9
10
11
12
13
export const ADD_TEAM_STATS = 'ADD_TEAM_STATS';
export const ADD_TEAMS = 'ADD_TEAMS';
export const addTeamStats = (stats, id) => ({
type: ADD_TEAM_STATS,
stats,
id,
});
export const addTeams = teams => ({
type: ADD_TEAMS,
teams,
});
addTeams
is for saving an API response containing them to the global store, and addTeamStats
will store the latest team fixtures whenever we ask for it. Then, based on the id
it will place it in the proper team object.
Commonly, we write our action
names as constants, and based on that names we are building object used by reducers
below whenever one of this action
is dispatched
:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import { ADD_TEAM_STATS, ADD_TEAMS } from './actions';
export const teamsReducer = (state = null, action) => {
switch (action.type) {
case ADD_TEAMS:
return state ? [...state, ...action.teams] : action.teams;
case ADD_TEAM_STATS:
const newState = [...state];
let found = newState.find(team => team.id === action.id);
if (found) {
found['stats'] = action.stats;
}
return newState;
default:
return state;
}
};
ADD_TEAMS
will just spread new teams to old ones (if they exist), while ADD_TEAM_STATS
will store a stats
object to the corresponding id
of the team whenever it already exists in the store.
A thing to note here is that I do not extend the store to make room for another stats
object. I decided it would be easier and more reliable to keep the teams’ data inside the already pulled team object from API (as is the addTeamStats
purpose). I think it’s a more clean way, but if you like and think it is better to separate those, feel free to make another stats
array with stats objects extended from the team id to easily find them.
Having them saved in the global store lets us avoid making another call whenever a user clicks the same Team for the second time. This will render the currently saved values in real-time without any further API calls required. Test it yourself on one of my codesanbox provided and see how fast it will display data when you switch the team and then go back (click) to the previous one. To me, this caching data feature is what global states all about.
Then we can fill our actions
and reducers
for the current team state accordingly:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
actions.js
export const SET_CURRENT_TEAM = 'SET_CURRENT_TEAM';
export const setCurrentTeam = teamId => ({
type: SET_CURRENT_TEAM,
teamId,
});
reducers.js
import { SET_CURRENT_TEAM } from './actions';
export const current = (state = null, action) => {
switch (action.type) {
case SET_CURRENT_TEAM:
return action.teamId;
default:
return state;
}
};
Finally we have to combine them all together and send them to our application. Put this code in the store index.js
file:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import React from 'react';
import { createStore, combineReducers } from 'redux';
import { Provider } from 'react-redux';
import { currentReducer } from './current/reducres';
import { teamsReducer } from './teams/reducres';
export const AppStore = ({ children }) => {
const store = createStore(
combineReducers({
teams: teamsReducer,
current: currentReducer,
}),
);
return (
<Provider store={store}>
{children}
</Provider>
);
};
So we have a really basic store example here. After importing our reducers we have to combine them using the combineReducers()
function which takes an object with all our reducers.
The rest is a plain Redux createStore
function. We want to pass the state returned from that function down the tree by the aforementioned Provider to all rendered children
which are passed to our store component.
A child of a Provider
So, I hope it is clear that to make it all connected, we have to make our application a child of a Provider. So let's extend our App.js
file to return:
1
2
3
4
5
6
<AppStore>
<main className="PremierLeague">
<Teams />
<Statistics />
</main>
</AppStore>
AppStore
is how we named our store component and everything inside it should be possible to connect. We won't know without trying, so let’s do it. We’ll start with the Teams
list and we need to export it by the aforementioned connect
HOC:
1
2
3
4
5
6
7
const mapStateToProps = state => ({ state });
const mapDispatchToProps = dispatch => ({
onAddTeams: teams => dispatch(addTeams(teams)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Teams);
So as you may have noticed we are getting the whole state here and map it to props, where we can destructure our values…
const Teams = ({ state: { teams }, onAddTeams }) => {}
... and we also define our own prop onAddTeams
which is a function for dispatching our addTeams
action.
With those values, now our Teams
component finally looks like a working one because it has the properties required to render teams.
The same operation will connect our single Team component to handle click function and fire proper action
as well as Statistics
component to display all fixtures whenever they appear in store:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Team
const Team = ({ team, state: {current}, onTeamSelect }) => {
[...]
const mapStateToProps = state => ({ state });
const mapDispatchToProps = dispatch => ({
onTeamSelect: teamId => dispatch(setCurrentTeam(teamId)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Team);
Statistics
const Statistics = ({ state: { teams, current }, onAddTeamStats }) => {
[...]
const mapStateToProps = state => ({ state });
const mapDispatchToProps = dispatch => ({
onAddTeamStats: (stats, id) => dispatch(addTeamStats(stats, id)),
});
export default connect(mapStateToProps, mapDispatchToProps)(Statistics);
From this point, all our components are connected to the store and talk to it. For each of them, we mapped the state from Redux to component props as well as a custom function to dispatch an action. All we have to do now is to provide some data to render them and see results.
API Connection
There are many libraries out there (like Axios) that may help you build the best request, but I used plain JS fetch
as it's enough for our purpose. To keep all things clear and consistent, create a new folder in your project src
in place of the helpers’ functionality:
1
2
3
4
├── src
│ ├── services
│ │ ├── apiConnection
│ │ │ └── index.js
I decided to use a free football API provided by football-data.org. They require authentication by the X-Auth-Token
header. You can receive your token by email after filling in some form information (a quick sign-up).
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
export const apiConnection = async (endpoint = '') => {
return await fetch(`https://api.football-data.org/v2/${endpoint}`, {
method: 'GET',
headers: {
'X-Auth-Token': process.env.REACT_APP_API_KEY,
},
})
.then(r => {
if (!r.ok) {
throw Error(`${r.statusText}`);
}
return r.json();
})
.catch(error => {
throw Error(`${error}`);
});
};
So we've built a base helper to handle API connection. It takes only one param as an endpoint because it will be different for every component.
API_KEY
is stored as an environment variable. Codesandbox.io is hidden from visitors, but if you would like to clone the repository and try it yourself, you'll need to rename .env.example
file to .env
and switch the inside value to your own.
You may also want to extend your UX for some errors displaying etc. to improve your user experience but for our simple example we will just throw Error
to somehow communicate about problems with frontend.
Connect components to API and store it's values in Redux
Now we can use this function to get our teams data from API in a Teams
component's useEffect
hook:
1
2
3
4
5
6
7
8
9
useEffect(() => {
if (!teams) {
return;
}
apiConnection('competitions/2021/teams').then(r => {
onAddTeams(r.teams);
})
}, [teams]);
Please note we have teams
as a dependency to refresh our effect whenever they change. First thing is to return the effect whenever teams appear - to avoid making another unnecessary API request.
It’s also a good practice to prevent further requests whenever our component is already waiting for a response. We can use a basic state hook and return an effect only when we face the desired status:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const [status, setStatus] = useState('idle');
useEffect(() => {
// Do nothing when request is in progress or teams already stored
if (teams || status === 'loading') {
return;
}
setStatus('loading');
apiConnection('competitions/2021/teams').then(r => {
onAddTeams(r.teams);
setStatus('success');
});
}, [teams, status]);
So when our component is in the loading
state, it will also return because we know that an API call is already in progress. Whenever it successfully responds, we can change the status of our app. Our effect will not fire anymore because the teams
just appeared and another conditional is returning it.
Do not forget to update your effect dependencies on this status
to avoid any issues with state refreshing.
You have to remember that such a simple solution works well for small projects like this one. When you plan to write something more complex with more things dependent on this API request, then I would suggest writing additional action
and reducer
to keep the status
of this part as a separate place in the global store. Thanks to this, every component waiting for the response can display the loading status, but also errors or anything for better user information.
The second component which also connects to our API and handles data is Statistics
. To determine which team fixtures should be displayed, we’re going to save the current
value in our store. As long as it exists, we can check if there is a team object stored with the same ID value as our current
one. Then if we find it, we need to check if that object has a stats
(because if it doesn’t, we need to call API to get it) object and store it in this team object for later use:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const [status, setStatus] = useState('idle');
const [currentTeam, setCurrentTeam] = useState(null);
useEffect(() => {
// Try to find team provided by current prop from redux store
const found = teams && current &&
teams.find(team => team.id === current);
// If the team at given id was found in store and it's different than
// existing we have stored already then we want to update state of it.
if (found && found !== currentTeam) {
setCurrentTeam(found);
}
}, [current, currentTeam, teams]);
useEffect(() => {
// Do nothing when :
// - 'current' value is not provided
// - request is in progress.
// - or current team was found and has stats already so we can load them from our memory (store).
if (!current || status === 'loading' || (currentTeam && 'stats' in currentTeam)) {
return;
}
setStatus('loading');
// Handle Api connection to retrieve latest matches for selected team
apiConnection(`teams/${current}/matches?status=FINISHED`)
.then(r => {
onAddTeamStats(r.matches, current);
setStatus('idle');
});
}, [current, currentTeam, status, teams]);
An interesting thing to note is that I used the newest ES 'stats' in currentTeam
construction to check if a stats
key exists in a team object. It's still equivalent to ways like Object.keys().includes()
or or any others.
Don't forget about UX
It’s a good place to stop for a while and think about user experience. As you can "render in mind", you can notice that we only inform our app about its state. The user still doesn't know if anything is happening in the background. So let's implement a basic loader and use it for our components:
1
2
3
4
├── src
│ ├── components
│ │ ├── Loading
│ │ │ └── index.js
Put there whatever loader you like. You can copy one from this example - just go to codesandbox.io to find it 😉
Then we can use it in our Teams and Statistics like eg:
1
2
3
4
5
6
7
8
9
10
11
12
Statistics
<section className="Statistics app-panel">
<h2>Statistics</h2>
{teams.length === 0 &&
<Loading message={'Waiting for teams load'} />}
{status === 'loading' &&
<Loading message={`Downloading ${currentTeam.name} data`} />}
[...Fixtures table]
</section>
Note that we have two loaders here. The first one appears when a component is waiting for teams
data. This data is required for a different Teams
component. As I told you before, in case the app grows, it's good to keep that information in store as well to make other components know that this data is not ready yet for them to use. This will also help us inform our user that this component is waiting for some background action to be finished.
The second loader appears when the API connection is in progress (has loading
status). The rest of the component's code we can leave untouched.
So far I only presented the functional code point of view, because additional styles and the rest of the code would make this article too big. All that code - styled a bit for better UX - you can find below on codesandbox:
useReducer + useContext
We plan to switch the functionality for the global state from an external Redux library to built-in React hooks without any changes to the way the components are rendered.
We will try to keep variables’ names and namespaces as consistent as possible. We can leave our actions and reducers untouched, trying to mimic the Redux ones and make them usable for the new useReducer hook.
So our main issue here is to write a new store based on that hook. Let’s make a few changes to our index.js
file in the store
folder (AppStore
component):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
export const AppContext = React.createContext({});
export const AppStore= ({ children }) => {
const [state, dispatch] = useReducer(combineReducers({
teams: teamsReducer,
current: currentReducer,
}), {});
return (
<AppContext.Provider value={{ state, dispatch }}>
{children}
</AppContext.Provider>
);
};
We started by removing the redux Provider and used a plain React Context instead (1st line). Then, we replaced the createStore
function with the useReducer hook. Those reducers return an array with two values: state and dispatch function. Dispatch should look familiar to you because we used it as a parameter for the Redux’ mapDispatchToProps
function via connect
HOC in the above code. In this case, we need to pass this dispatch function along with the store state through context value.
Combining our reducers
If you observed closely you might have noticed that we still didn't remove the combineReducers
function. This is a function provided by the Redux library, so what is going on here?
The reducer's combining is quite simple and based on retrieving states from every entry to allocate them by a given key. We can do more or less the same as Redux does and place it in another helper function. Let's create a combineReducres
folder with an index.js
file inside of the services
folder with a following function:
1
2
3
4
5
export const combineReducers = (reducerDict) =>
(state, action) => Object.keys(reducerDict)
.reduce((acc, curr) => ({
...acc, [curr]: reducerDict[curr](state[curr], action),
}), state);
You can find many different implementations of that functionality across the Internet, and they are all based on the original Redux combining functionality.
I used and modified some of the existing ones as well to meet the ES6 standards (from thchia).
This will make our combined state look the same way as it does in Redux. As you can see, there are minor changes to the store building and one small helper function.
Our store is now ready and we can connect our components to it. Starting with the Teams
component, we need to clean it from any Redux functionality. Remove mapStateToProps, mapDispatchToProps
and their connect
to the component, and then revert it to plain export as usual without any props passing to it:
export const Teams = () => {…}
Instead of taking data from props (populated by Redux before), we are going to pull them from the context where we stored them (in the last step).
const { state: {teams, current}, dispatch } = useContext(AppContext);
Lastly, we need to switch the dispatching functionality. Since we don't map it to our component props (like Redux does), we can use the trigger dispatch function directly in our promise response. All we need is to change onAddTeams(r.teams);
to dispatch(addTeams(response.teams));
I know you may be surprised but actually, it's all we have to do to make this component work. Take a look at the diff to realize there are only small adjustments:
We need to make the same adjustments to the Statistics
component, so:
- Remove
connect
functionality; - Switch data from props handling to useContext hook and connect it to AppStore context;
- Change action dispatching to strict using the dispatch function;
That's it. We changed or removed around 30 lines of code. We ended up with less, tighter, and more readable code, without any external libraries needed and still having the same functionality.
You can test all these changes yourself on in the codesandbox.io environment:
Increasing our app performance
To compare the performance of the Redux and Hooks solutions, I would like to show you a React Profiler API. It was introduced in React 16.9 for the first time. It provides a Profiler component that helps to measure component timing for every single render. It takes two required props which are id
(name of your profiler, as you can have many of them), and onRender
which is a callback for component updates. It resolves several parameters which you can check in the React documentation. We are interested in two of them which are actualDuration
(showing us "Time spent rendering the Profiler and its descendants for the current update.") and phase
(which returns "mount" | "update"), because we only want to check and read values for the first time the app is rendered.
Implementing Profiler to the AppStore
To implement Profiler to our AppStore, you should change the App.js
file in both projects like below:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
export const App = () => {
const callback = (id, state, actual) => state === 'mount' &&
console.log(`Render time: ${actual}`);
return (
<Profiler id={'App'} onRender={callback}>
<AppStore>
<main className="PremierLeague">
<Teams ></Teams>
<Statistics ></Statistics>
</main>
</AppStore>
</Profiler>
);
};
After that, you should be able to read the time your app required for the first render. Let's go to the browser, open devtools at console tab, refresh several times, and (eye)catch average values. You can easily notice that our Hooks implementation is always faster - sometimes even twice as fast. I left those Profilers in my code at codesandbox.io, so you can test it on your own and take a look at the console results.
It is still hard to investigate what causes those rendering time's differences. The React Profiler component is a great feature for measuring smaller chunks of an application or even single components to identify parts that may benefit from optimizations like memoization. But if you’d like to catch all components at once, or even better, in a tree view, you have to use the browser’s built-in DevTools Profiler for React. It is available as an extension in some browsers.
Thanks to that profiler, you can even measure timings for specific actions.
Just start recording a profile at a point you want to check performance. Then stop it whenever your use case ends. In my example, I just stopped recording when the components appeared on my screen. Actually there is no need to wait for that, as it will be the second component render. We are only interested in the first 'mount' one.
After getting results, focus on the longest rendering components and try to use useMemo for memoization of their functionality or, whenever possible, try to use lazy()
for dynamic importing.
Profiling the components
I made 5 tests for our Redux app as well as for our Hooks app in the latest Chrome browser. You can see the results below :
You can see the whole app tree rendered here. From the top we can notice:
- App
- Profiler (which is not intended to be in production mode)
- AppStore
- Provider
- ReactRedux.Provider
- ConnectFunction (for both Teams and Statistics)
- ReactRedux.Provider (for both Teams and Statistics) (which is consumer I believe)
- Finally our two components Teams and Statistics
For 5 tries, I recorded the following render times: 9.4, 8.9, 8.7, 8.4, 8.1 which gives us around 8.7 as an average.
Now, let's do the same for the Hooks implementation:
We can see that the structure of the project is flatter and the functionality is much less distributed. It's hidden under hooks rather than exposed as standalone components (or High Order Components). It looks much more readable and has fewer steps. When compared to the Redux tree it looks like this:
- App
- Profiler
- AppStore
- Context.Provider
- Finally our two components Teams and Statistics
For 5 tries, I recorded the following render times: 5.8, 6.1, 6, 6.4, 6.3 which gives us around 6.1 as an average.
React Hooks: Our results
The difference is huge (around 30%). The code is more clean and way better structured, but using the built-in React functionality we can save tons of time. We’re dealing with small chunks of data, but you can imagine it in large, complex applications - as we cannot forget we are profiling just two empty areas. So from a frontend point of view, you will have a much faster application and whenever it is getting bigger your users will feel a real difference in rendering time.
Next, you can try to play with measurements by yourself. Try to make your calculations based on the React Profiler API with some of your components, or for overall purposes with great visualizing effect use Profiler from your DevTools like shown on the above screenshots.
You can try test reducers and their impact on rendering time to have a better insight into the real differences. Both ready-to-use projects you can find on codesanbox.io linked above (or you can clone the GitHub repo linked from codesandbox). Do your tests, make your conclusions and let me know what you think.
As a summary, you may take a look one more time at how little work is required to switch between Redux and useReducer. The actions and reducers haven’t changed and the store object required only a few minor modifications. The resulting code is cleaner, faster, and easier to read while improving your user experience and loading times.
HOLD ON! We haven't finished yet.
As I promised, now that we have our application ready, we can launch TypeScript support and check the places where we can improve our code and make it safe from possible issues. Our current code is working as expected, but in reality, it is still sensitive to some problems. Let's say for example we didn't provide the process.env.REACT_APP_API_KEY
variable and we are just using it directly. You can imagine what can happen when it's missing. To see how to prevent situations like this, take a look at our next article (will be soon) and read more.