Global State Management with React Hooks
You might not need Redux for global state management. Build your own Hooks-based solution instead!
Global state management with React Hooks
Learn how to use React custom Hooks πͺ to manage global state across the app without the need of the Context API
or libraries like Redux or MobX π€―.
This is not a boring theory tutorial but, it's a hands on one πͺ, so we're going to build this demo app - this uses a custom Hook solution to manage global state and perform side effects (async tasks), before updating it with a Redux-like approach. π
This is the gitHub repo with these 2 branches:
master branch: custom Hook solution without side effects
async branch: custom Hook solution with side effects
The content of this tutorial is based on this course: React 16: The Complete Course (incl. React Router 4 & Redux) | Udemy
π Big thanks to the reviewers:
Fabiha Khatun - UK based Software Engineer
Table of contents:
Intro:
Get up to speed with Hooks:
Building a global store
What is global state?
In the React world, the UI is made up of components, which are small units of code that render a view, and all of them are part of a component tree.
What if we'd like to access some piece of data in different parts of the app? We'd be forced to keep state in a parent component that wraps the interested parts of the component tree.
Then, we would pass the data down via props to the interested parts, but that would lead to prop drilling.
But hang on, what is prop drilling? This is when the same prop is passed through a long chain of components, making it repetitive and difficult to maintain.
In the following image, we can can see a function called toggleFav
being passed down 5 levels as a prop, this doesn't look like good architecture, does it?
Is Context API the solution to manage it?
There is a solution that is widely adopted in the React community to fix the propr drilling issue, by using the built in Context API
, but this has 2 downsides:
It's not meant and optimised for passing down high frequency changing data, like the
isFavourite
boolean property of a product item in an ecommerce app, but it was meant for passing down more static things liketheme
variables,login status
,language
, and so on.After any of the data passed down via props through the Context API changes, all the components wrapped by the Provider that uses useContext will re-render, no matter if they use that specific piece of data or not. This could be patched by using the
useMemo
hook, but using that function will be costly - this will slow down the performance of the component tree re-rendering cycle and bloat your code.
When I say high frequency, I mean a property changing at least twice in the lifecycle of the app. Usually, these changes are triggered by user input, like clicking a heart icon on a product card, to marking it is as a favourite item.
If you're interested in seeing the classic usage of the Context API
and its limitations, check out this great article by Kent C. Dodds.
So, to anwser the question Is Context API the solution?
It might not be... until you watch this amazing video by Jack Herrington, which makes React Context fast and usable as a state management solution! check it out π
To sum it up, using Context is a way to avoid prop drilling (by making the state available in a context), but it's still keeping state inside a React component, that is part of the component tree, and this way, the architecture is tied to that limitation of always having to choose a parent component to hold that state.
State management solution libraries
This challenge of keeping state in React without hitting the problem of prop drilling, has been addressed initialy by the Redux library, by keeping state outside components.
Storing the state of the app outside the component tree was what made this library the go-to management solution for React, in other words, decoupling the app state from the UI components.
How does Redux work? A store is created and then, components can subscribe to store value changes and dispatch actions that modify those values. Other libraries need to be added on top of Redux to perform asynchronous operations (or called side effects) before updating the state, like Redux-Saga and Redux-Thunk.
There are other state management solutions out there as well, like Zustand, Jotai, Recoil (still in beta), Rematch and MobX, amongst others.
To have an idea of the popularity of the mentioned libraries, check out the chart here @rematch/core vs jotai vs mobx vs react-redux vs recoil vs zustand | npm trends
Using libraries could be a great idea, but in this tutorial, you'll learn how to use a built in React tool, which are Hooks
, to solve our prop drilling problem.
So, the plan for this tutorial is to use a React custom Hook to π keep state outside of components π to manage global state with it.
What are Hooks?
If you already feel comfortable using Hooks, feel free to jump to this section.
Hooks are functions that start with the name use
and then the name of something else, like State
, giving the full name useState
, as an example.
There are:
built in Hooks, like
useState
, anduseEffect
,useCallback
, etc, that are built in, inside the React library code and we can import them from there. They help updating the state or do things when somestate
orprops
change on functional components, amongst other things.useState
, anduseEffect
made the full switch from class based components, to functional components possible.You can have a look at some Hooks documentation here
Custom Hooks that we can use in components and other custom Hooks, and they're helpful to move stateful logic and side effects outisde functional components so they can be re-used and, at the same time, make the components leaner.
There are certain rules when using these Hooks:
In the coming sections, we'll take a look at 2 custom Hooks to understand them in depth, all with code examples and github repositories.
Spotting duplicated code
What if we have two components, Posts.js
and Widget.js
, that need to show a list of posts from an API with different markup and posts coming from the same API?
Post.js
only displays the first 9 posts from the API.
// src/components/Posts.jsimport { useEffect, useState } from "react";import React from 'react';const Posts = props => {const [posts, setPosts] = useState([]);useEffect(() => {const fetchPosts = async() => {const rawPosts = await fetch("https://jsonplaceholder.typicode.com/posts");const postsArray = await rawPosts.json();setPosts(postsArray);}fetchPosts();}, [setPosts]);// then apiData is used in the template to display the first 9 postsconst posts = apiData.slice(0,9).map(item => {return <li>{item.title}</li> <li>{item.body}</li>;});return (<div className="Posts"><h2>Posts component</h2><ul>{postsJSX}</ul></div>);};export default Posts;
And Widget.js
displays the 10th and 11th posts from the list:
// src/components/Widget.jsimport { useEffect, useState } from 'react';import React from 'react';const Widget = (props) => {const [posts, setPosts] = useState([]);useEffect(() => {const fetchPosts = async () => {const rawPosts = await fetch('https://jsonplaceholder.typicode.com/posts');const postsArray = await rawPosts.json();setPosts(postsArray);};fetchPosts();}, [setPosts]);// then apiData is used in the template to show the 10th and 11th postsconst posts = posts.slice(9, 11).map((item) => {return <li>{item.title}</li>;});return (<div className="Widget"><h2>Widget component</h2><ol>{postsJSX}</ol></div>);};export default Widget;
Note: array destructuring is used to assign the variables apiData
and setApiData
, so if you're not familiar with it, check out this MDN Doc
If we look at both components, we see a lot of code duplication:
// duplicated codeconst [posts, setPosts] = useState([]);useEffect(() => {const fetchPosts = async () => {const rawPosts = await fetch('https://jsonplaceholder.typicode.com/posts');const postsArray = await rawPosts.json();setPosts(postsArray);};fetchPosts();}, [setPosts]);
So we need to outsource that repeated code into an function that lives outisde the components.
The logic we need to abstract needs to:
Call an API when the first render of
Posts.js
orWidget.js
components happenedWhen the asynchronous call to the API is finished, the state of both components need to be updated (so they can acually show the posts on the screen).
If we put all of the logic into a normal function, like this:
// WRONG APPROACH!! β οΈexport const duplicatedCode = () => {const [posts, setPosts] = useState([]);useEffect(() => {const fetchPosts = async () => {const rawPosts = await fetch('https://jsonplaceholder.typicode.com/posts');const postsArray = await rawPosts.json();setPosts(postsArray);};fetchPosts();}, [setPosts]);};
When trying to compile this code, we're gonna get an error saying:
React Hook "useState" is called in function "duplicatedCode" that is neither a React function component nor a custom React Hook function. React component names must start with an uppercase letter. React Hook names must start with the word "use"
The problem here is that the piece of logic we want to abstract contains 2 built in Hooks:useState
and useEffect
, which are tied to the components lifecycle and state, and they're not meant to be used in regular functions.
**The function has no way to know what is going in terms of state, and re-renders inside the components calling it **
So, the problem is the function needs to know more about what's going on inside of the component.
And that is what custom Hooks solve! π
Custom Hooks can have any built in Hooks inside of it, like useEffect
and useState
(and also other custom Hooks, like useWhatever
) so everytime the component that uses the custom Hook re-renders, useEffect
runs inside the the Hook, and everytime we set up state inside the Hook using useState
, it is the same as setting up state inside of the component π€―
π That's our connection problem solved! π
It can take you some time around to wrap your head around this idea π₯΄, but over time it will become natural π.
First custom Hook example
The code below is stored in this repo's master branch
Here is a custom Hook called usePosts
that will solve our code duplication problem:
// src/hooks/usePosts.js// import built in React Hooksimport { useEffect, useState } from 'react';// this is the custom Hookconst usePosts = () => {const [posts, setPosts] = useState([]);useEffect(() => {const fetchPosts = async () => {const rawPosts = await fetch('https://jsonplaceholder.typicode.com/posts');const postsArray = await rawPosts.json();setPosts(postsArray);};fetchPosts();}, [setPosts]);return posts;};export default usePosts;
Here is the code explained step by step:
We need to import the built in Hooks
useEffect
anduseState
from the React library.Then, we create a function (Yes! custom Hooks are functions) BUT, we need to start its name with
use
.We initialize the state as an empty array by typing:
const [apiData, setApiData] = useState([]);
and that is the same as initializing state inside the componentPosts.js
orWidget.js
.Once the component
Posts.js
orWidget.js
has been rendered, the second time it re-renders, theuseEffect's
callback function is called again, and that is when the API is hit.
As a side note, if we had another useEffect
inside Posts.js
for example, that function will be called at the same time as the useEffect
inside usePosts
.
We return the
apiData
array, as we want to use it for displaying some posts in the UI. The good thing is that when the variableapiData
is updated insideusePosts
hook, that will trigger a re-render onPosts.js
orWidget.js
and the updated value ofusePosts
will be reflected on the template!Then we export the Hook, so we can call it inside components.
π‘Useful notes about the useEffect
dependacy array:
setPosts
is added to the dependency array of theuseEffect
hook, because it is a function that is being defined outsideuseEffect
. This addition doesn't cause an infinite loop becausesetPosts
is always the same function's pointer every time theusePosts
function runs, becausesetPosts
is a function that updates theposts
state (a function returned when callinguseState
hook). React guarantees thatsetPosts
will always be the same function's pointer on re-renders.If you have functions as a dependency (functions not returned by the built in React hook) of
useEffect
, make sure you wrap them withuseCallback
hook, so the they are the same object when the component re-renders, like this:// useCallback approachconst SomeComponent = () => {// when there are re-renders, someFunction will be the same JS pointerconst someFunction = π useCallback(() => {// function body here},[]);useEffect(() => {someFunction();// some other code here}, [π someFunction])}// workaroundconst SomeComponent = () => {useEffect(() => {// we define the function inside the useEffect hookconst someFunction() {// function body here}// and then we call itsomeFunction();}, [])}
Now that our usePosts
custom Hook is ready, let's use it inside of our components!
// src/components/Posts.jsimport "./Posts.css"import usePosts from "../hooks/usePosts";const Posts = () => {const posts = π usePosts();const postsJSX = posts.slice(0, 9).map(item => {return (<li><h3>{item.title}</h3><p>{item.body}</p></li>);});return (<div className="Posts"><h2>Posts component</h2><ul>{postsJSX}</ul></div>);}export default Posts;
// src/components/Widget.jsimport "./Widget.css";import usePosts from "../hooks/usePosts";const Widget = () => {const posts = π usePosts();const postsJSX = posts.slice(9, 11).map(item => {return (<li><h3>{item.title}</h3><p>{item.body}</p></li>);});return (<div className="Widget"><h2>Widget component</h2><ol>{postsJSX}</ol></div>);}export default Widget;
The components look much leaner, and many other components can use that custom Hook to query for data, that's great!
The only downside is that for every component rendered on the screen using the custom Hook, a new request to the API is made. One way to check for this it to open the Network
tab, and we'll see the GET
requests made to the API:
When developing this demo app, there will be 4 post request made when the 2 components are on the screen, so 2 requests per component that uses the usePosts
hook π€. Don't worry, that's because useEffect
runs twice in development mode.
To see how the app behaves in production (with no double useEffect
runs), we can build it and serve it:
// run these commands at the root level of your react app$ npm run build$ npm install -g serve$ serve -s build
And see the amount of http
requests per component:
So, in production, we have one http request per component that uses the usePosts
. That is not ideal, and we'd like to have just one http
call and store the response in memory. The code to fix this can be found in this section.
A more complex hook example
There are more things Hooks can do:
Get some arguments to configure it
Return objects, arrays, anything!
π‘ Remember: Hooks are functions, so they can take any arguments and return anything.
The code of this more complex Hooks example can be found on this repo.
Let's imagine we have these two components, App.js
and NewTask.js
that connect to an API to read and create some tasks respectively:
// /src/App.js// it fetches taks from the backend databse and displays themimport React, { useEffect, useState } from 'react';import Tasks from './components/Tasks/Tasks';import NewTask from './components/NewTask/NewTask';function App() {const [isLoading, setIsLoading] = useState(false);const [error, setError] = useState(null);const [tasks, setTasks] = useState([]);const fetchTasks = async (taskText) => {setIsLoading(true);setError(null);try {const response = await fetch('https://react-http-6b4a6.firebaseio.com/tasks.json');if (!response.ok) {throw new Error('Request failed!');}const data = await response.json();const loadedTasks = [];for (const taskKey in data) {loadedTasks.push({ id: taskKey, text: data[taskKey].text });}setTasks(loadedTasks);} catch (err) {setError(err.message || 'Something went wrong!');}setIsLoading(false);};useEffect(() => {fetchTasks();}, []);const taskAddHandler = (task) => {setTasks((prevTasks) => prevTasks.concat(task));};return (<React.Fragment><NewTask onAddTask={taskAddHandler} /><Tasksitems={tasks}loading={isLoading}error={error}onFetch={fetchTasks}/></React.Fragment>);}export default App;
// /src/components/NewTask.js// it adds new tasks to the database in the backend// and updates the App's component state `tasks`import { useState } from 'react';import Section from '../UI/Section';import TaskForm from './TaskForm';const NewTask = (props) => {const [isLoading, setIsLoading] = useState(false);const [error, setError] = useState(null);const enterTaskHandler = async (taskText) => {setIsLoading(true);setError(null);try {const response = await fetch('https://react-http-6b4a6.firebaseio.com/tasks.json',{method: 'POST',body: JSON.stringify({ text: taskText }),headers: {'Content-Type': 'application/json',},});if (!response.ok) {throw new Error('Request failed!');}const data = await response.json();const generatedId = data.name; // firebase-specific => "name" contains generated idconst createdTask = { id: generatedId, text: taskText };props.onAddTask(createdTask);} catch (err) {setError(err.message || 'Something went wrong!');}setIsLoading(false);};return (<Section><TaskForm onEnterTask={enterTaskHandler} loading={isLoading} />{error && <p>{error}</p>}</Section>);};export default NewTask;
The code that is common to both components is:
setIsLoading(true);setError(null);try {const response = // GET || POST request hereif (!response.ok) {throw new Error('Request failed!');}const data = await response.json();// do something with the data and update the app `tasks` state} catch (err) {setError(err.message || 'Something went wrong!');}setIsLoading(false);
The custom Hook we can create to move this logic looks like this:
// /src/hooks/use-http.jsimport { useState, useCallback } from 'react';const useHttp = () => {const [isLoading, setIsLoading] = useState(false);const [error, setError] = useState(null);const sendRequest = useCallback(async (requestConfig, applyData) => {setIsLoading(true);setError(null);try {const response = await fetch(requestConfig.url, {method: requestConfig.method ? requestConfig.method : 'GET',headers: requestConfig.headers ? requestConfig.headers : {},body: requestConfig.body ? JSON.stringify(requestConfig.body) : null,});if (!response.ok) {throw new Error('Request failed!');}const data = await response.json();applyData(data);} catch (err) {setError(err.message || 'Something went wrong!');}setIsLoading(false);}, []);return {isLoading,error,sendRequest,};};export default useHttp;
The Hook is returning an object with 3 keys:
isLoading
, of typeboolean
error
, of typestring
ornull
;sendRequest
, a function that calls the api, that can be called inside the component whenever it suits it. The function takes two arguments:a.
requestConfig
, an object that configures thehttp
call with the appropiateurl
,method
,headers
andbody
.b. A callback function called
applyData
, that can update the UI by changing the state of the app.
You might notice the useCallback
built in hook here:
const sendRequest = useCallback(//more code here)
As discussed in this section, that is done to make sure the sendRequest
function is the same object pointer on every component re-render. That way, sendRequest
can be safely added to a useEffect
dependency array, like this:
useEffect(() => {sendRequest;}, [sendRequest]);
The above useCallback
usage is done in case we want to comply with the dependency array standards (we could leave the dependency array empty as well, and it would work too).
Here is how the components use the useHttp
custom Hook:
// /src/App.jsimport React, { useEffect, useState } from 'react';import Tasks from './components/Tasks/Tasks';import NewTask from './components/NewTask/NewTask';import useHttp from './hooks/use-http';function App() {const [tasks, setTasks] = useState([]);π const { isLoading, error, sendRequest: fetchTasks } = useHttp();useEffect(() => {const transformTasks = (tasksObj) => {const loadedTasks = [];// massage the API datafor (const taskKey in tasksObj) {loadedTasks.push({ id: taskKey, text: tasksObj[taskKey].text });}// change state to reflect changes in the UIsetTasks(loadedTasks);};fetchTasks({ url: 'https://react-http-6b4a6.firebaseio.com/tasks.json' },transformTasks);}, [fetchTasks]);const taskAddHandler = (task) => {setTasks((prevTasks) => prevTasks.concat(task));};return (<React.Fragment><NewTask onAddTask={taskAddHandler} /><Tasksitems={tasks}loading={isLoading}error={error}onFetch={fetchTasks}/></React.Fragment>);}export default App;
Side note: const { isLoading, error, sendRequest: π fetchTasks } = useHttp();
means that sendRequest
is being renamed to fetchTasks
.
// /src/components/NewTask.jsimport Section from '../UI/Section';import TaskForm from './TaskForm';import useHttp from '../../hooks/use-http';const NewTask = (props) => {π const { isLoading, error, sendRequest: sendTaskRequest } = useHttp();const createTask = (taskText, taskData) => {const generatedId = taskData.name; // firebase-specific => "name" contains generated idconst createdTask = { id: generatedId, text: taskText };props.onAddTask(createdTask);};const enterTaskHandler = async (taskText) => {sendTaskRequest({url: 'https://react-http-6b4a6.firebaseio.com/tasks.json',method: 'POST',headers: {'Content-Type': 'application/json',},body: { text: taskText },},createTask.bind(null, taskText));};return (<Section><TaskForm onEnterTask={enterTaskHandler} loading={isLoading} />{error && <p>{error}</p>}</Section>);};export default NewTask;
There is a tricky part here:
// /src/components/NewTask.jsconst enterTaskHandler = async (taskText) => {...createTask.bind(null, taskText);πβ // What's the .bind thing? π€...}
Well, our applyData
callback function expects only one argument named data
:
// /src/hooks/use-http.jsconst sendRequest = useCallback(async (requestConfig, applyData) => {...applyData(data); π //expects just one argument...}
and the function createTask
we pass as applyData
takes two arguments: taskText
and taskData
// /src/components/NewTask.jsconst createTask = (taskText, taskData) => { π // expects 2 argumentsprops.onAddTask(createdTask);};
So we've got a problem: how we can possibly pass an extra argument taskText
to applyData
?
The .bind
method lets us pre-configure (not execute) the function, so it takes the extra parameter taskText
we need!
Here is some documentation about the .bind
method:
From Function.prototype.bind() - JavaScript | MDN: The
*The bind()
function creates a new bound function, which is an *exotic function object* (a term from ECMAScript 2015) that wraps the original function object. Calling the bound function generally results in the execution of its wrapped function.
createTask.bind(null, taskText);
The first argument is the context, which is null
, beause we don't want to change it, and the first argument is taskText
which will be the extra argument.
To avoid the usage of this bind()
method, the other option could be defining createTask
inside enterTaskHandler
, like this:
// /src/components/NewTask.jsimport Section from '../UI/Section';import TaskForm from './TaskForm';import useHttp from '../../hooks/use-http';const NewTask = (props) => {const { isLoading, error, sendRequest: sendTaskRequest } = useHttp();const enterTaskHandler = async (taskText) => {// taskText is defined in the scope of enterTaskHandler// I can safely use it.π const createTask = (taskData) => {const generatedId = taskData.name; // firebase-specific => "name" contains generated idconst createdTask = { id: generatedId, text: taskText };props.onAddTask(createdTask);};sendTaskRequest({url: 'https://react-http-6b4a6.firebaseio.com/tasks.json',method: 'POST',headers: {'Content-Type': 'application/json',},body: { text: taskText },},π createTask // this a function pointer);};return //someJSX here
If you've read the article to this point, congratulations! You now have a solid πͺ foundational knowledge of how Hooks πͺ work, so let's move on the next section, where we'll explore what happens with state inside of Hooks.
There is an important question here:
π Is the state inside Hooks shared between the components that use it βββπ€
Let's find out! π€
Scope of state in Hooks
To see how Hooks manage their state, let's build a simple app (stored at this repo's master branch) that displays two counters and 2 buttons to increase it.
// /src/hooks/useCounter.jsimport { useState } from 'react';const useCounter = (componentName) => {// let's log the component that is calling this hookconsole.log('useCounter hook function running because' +componentName +' is calling it');// all components using the Hooks will have counter with value 0 as initial stateconst [counter, setCounter] = useState(0);return [counter, setCounter];};export default useCounter;
// /src/App.jsimport Counter1 from './components/Counter1';import Counter2 from './components/Counter2';function App() {return (<div className="App"><header><h1>Counter app</h1></header><body><p>Counter1 component</p><Counter1 /><p>Counter2 component</p><Counter2 /></body></div>);}export default App;
// /src/Counter1.jsimport useCounter from '../hooks/useCounter';function Counter1() {const [counter, setCounter] = useCounter('Counter1');return (<><div>{counter}</div><button onClick={() => setCounter(counter + 1)}>Increment counter</button></>);}export default Counter1;
// /src/Counter2.jsimport useCounter from '../hooks/useCounter';function Counter2() {const [counter, setCounter] = useCounter('Counter2');return (<><div>{counter}</div><button onClick={() => setCounter(counter + 1)}>Increment counter</button></>);}export default Counter2;
If both components are using the same function useCounter
, we would expect the counter
state to be the same for both components, and when clicking any of the 2 buttons, we should see both counters increment their values by one at the same time, but...
No!π― The state is not shared, because each counter has different values π€
When calling the built in useState
or useReducer
inside custom Hooks, the state that is stored "inside the Hook" is different for every component calling that hook, in other words, the state is scoped to the component using it.
That isn't very helpful if we want to have a global state, is it? π€
Global state management requirements
We have now finished understanding how Hooks work, including how state is scoped to each component using a custom Hook π
Let's first think about what things the global state management solution should do for us:
Provide the same shared state to components and pass them a function to update it.
When one components updates the state, the rest of the interested components should get the updated value.
To overcome this issue of Hooks having different states for each component using the Hook, we need something that remains the same every time the Hook is being called by components. A variable declared outisde the Hook with the let
keyword looks like a really good candidate! Like this:
let globalCounter = {};
Global counter example
Let's try to create a global counter app, that any component on the app can update and read the most updated value π€―
The code can be found at this repo's use-counter-store branch.
The structure of the Hook should be something like this:
// object pointer usage// globalStore variable should be a pointer of an emtpy objectlet globalCounter = {};// let's name the custom Hook useStoreconst useCounterStore = () => {// we can use the globalStore variable + useState Hooks to share// the same state across components!return globalCounter;};
Having a variable defined outside of the Hook, that can be updated, is the first part of the puzzle solved.
The next step is to find a way to notify of the variables value changes to all the components that are using the Hook. This part is becoming tricky...π€
In other words, if we have component A and B using the useStore
custom Hook, when component A changes, the state (mutating the variable), component B should be notified that the state has changed, and the new value should be passed.
Let's jump directly to the solution and then discuss how it works:
// /src/useCounterStore.jsimport { useState, useEffect } from 'react';// hardcoded value for the initial statelet globalCounter = 0;// let's store the functions to update the state for each interested component// this is an array of setCounter's function pointers.let listeners = [];const useCounterStore = () => {// we're not interested in using the counter state, just setCounterconst setCounter = useState(globalCounter)[1];// let's create a function that updates the global counterconst incrementCounter = () => {// let's change the variable globalCounter defined outside the HookglobalCounter = globalCounter + 1;// let's call the function setCounter corresponding to each interested component// so they re-render, and show the latest globalCounter valuefor (const listener of listeners) {listener(globalCounter);}};// let's register the components when they call this hook for the first time by// pushing their corresponding setCounter function into the listeners arrayuseEffect(() => {//when the component did mount, its corresponding setCounter function is added to the list, as a pointerlisteners.push(setCounter);//There's an unmounting clean up function (which is a closure), that de-registers the component from the listenersreturn () => {listeners = listeners.filter((li) => li !== setCounter);};}, [setCounter]);// let's return the global state and a way to update itreturn [globalCounter, incrementCounter];};export default useCounterStore;
// /src/Counter1.jsimport useCounterStore from '../hooks/useCounterStore';function Counter1() {const [globalCounter, incrementCounter] = useCounterStore();return (<><div>{globalCounter}</div><button onClick={incrementCounter}>Increment counter</button></>);}export default Counter1;
Some notes about the code:
Components that use
counterGlobal
won't be re-rendered just for the fact that variableglobalCounter
changes, but because we'll trigger asetCounter
of each interested component.Registration of the interested components:
The first time an interested component calls the Hook (when being mounted to the Virtual DOM), we'll need to add the
setCounter
function pointer to an array, calledlisteners
. EachsetCounter
pointer is linked to the component that called the Hook.That way, every time we call the
useState
functions referenced in the array, every component will re-render, this way they will be able to display the updated value of the global variableglobalCounter
.
An important note about the
listeners
array is that the firstsetCounter
function there, corresponds the most parent component in the app that uses that hook, then we have the children's functions, grandchildren, and so on. That way, we ensure that when there is a change of state, the first component to know about it, is the most parent component, and not the children. If we didn;t have this specific order inlisteners
we could have this error in the console:Warning: Cannot update a component (`ProductItem`) while rendering a different component (`ProductItem`). To locate the bad setState() call inside `ProductItem`, follow the stack trace as described in https://fb.me/setstate-in-renderin ProductItem (at Products.js:15)in ul (at Products.js:13)in Products (created by Context.Consumer)in Route (at App.js:13)in main (at App.js:12)in App (at src/index.js:14)in Router (created by BrowserRouter)in BrowserRouter (at src/index.js:13)the difference with using state is that dispatch function wasn't triggering a re-render of this component firstbecause we were calling the setStates as they were stored in the list (from parents to children).Clean up function: that anonymous functions is a closure. What does that mean? the
setCounter
value is being trapped at the moment of the cleanup function definition, as that function has been defined inside the scope of a parent function. Without this closure behaviour, it would be hard to know whichsetCounter
to remove from the list, because thatuseEffect
function is being called by multiple components.setCounter
is passed to theuseEffect
dependency array because it's an external dependency, and in this case, it's not going to change every time the Hooks runs, because it is a built in React function that is guaranteed by React to stay the same (be the same pointer).π‘Remember, changes in props and state will trigger re-render in components. In this example, only calls to
setCounter
function will trigger re-renders in components.
The state is global now!
Final global state management solution
Check out the code in this repo's master branch
The solution to manage a global count
state is really good, but what if we could have a custom Hook that can be used to manage any kind of state, not only counter
state, that uses a Redux-like approach?
The Redux approach is explained in detail on this documentation.
There are two main concepts in Redux land: actions
and reducers
.
Actions are objects with a type
and a payload
, that are dispatched (or fired) when certain things happen in the app (e.g add button clicked to add Buy Milk
to a list of items) :
// Action example:const addTodoAction = {type: 'todos/todoAdded',payload: 'Buy milk'}
A reducer is a function that receives the current state
and an action
object, decides how to update the state if necessary, and returns the new state: (state, action) => newState
. You can think of a reducer as an event listener which handles events based on the received action (event) type.
// Reducer exampleconst initialState = { todoList: [] };function counterReducer(state = initialState, action) {// Check to see if the reducer cares about this action// switch statements are commonly used instead of `if` checksif (action.type === 'todos/todoAdded') {// If so, make a copy of `state`return {// clone the state...state,// clone the array + push the new itemtodoList: [...todoList, action.payload],};}// some more `if` checks here...// otherwise return the existing state unchangedreturn state;}
Without further ado, let's jump into the custom Hook solution to manage global state like Redux π
// /hooks-store/store.jsimport { useState, useEffect } from 'react';let globalState = {};let listeners = [];let actions = {};πexport const useStore = () => {const setState = useState(globalState)[1];const dispatch = (actionIdentifier, payload) => {const newState = actions[actionIdentifier](globalState, payload);globalState = { ...globalState, ...newState };for (const listener of listeners) {listener(globalState);}};useEffect(() => {listeners.push(setState);return () => {listeners = listeners.filter(li => li !== setState);};}, [setState]);return [globalState, dispatch];};πexport const initStore = (userActions, initialState) => {if (initialState) {globalState = { ...globalState, ...initialState };}actions = { ...actions, ...userActions };};
Two functions are exported above:
useStore
: theuseStore
hook will be called in the the files of the interested componentsinitStore
: it will be called in special JS files (e.g products-store.js), from where the initial state for each state slice and actions are set up.
// /src/hooks-store/products-store.jsπimport { initStore } from './store';const configureStore = () => {const actions = {TOGGLE_FAV: (curState, productId) => {const prodIndex = curState.products.findIndex(p => p.id === productId);const newFavStatus = !curState.products[prodIndex].isFavorite;const updatedProducts = [...curState.products];updatedProducts[prodIndex] = {...curState.products[prodIndex],isFavorite: newFavStatus};return { products: updatedProducts };}};πinitStore(actions, {products: [{id: 'p1',title: 'Red Scarf',description: 'A pretty red scarf.',isFavorite: false},{id: 'p2',title: 'Blue T-Shirt',description: 'A pretty blue t-shirt.',isFavorite: false},{id: 'p3',title: 'Green Trousers',description: 'A pair of lightly green trousers.',isFavorite: false},{id: 'p4',title: 'Orange Hat',description: 'Street style! An orange hat.',isFavorite: false}]});};πexport default configureStore;
Some notes for the above code:
The
actions
object is just an object with keys that are the identifiers, and then, the keys value is a function that takes state and a payload, and returns the new state. It's a hybrid of a Redux action and reducerThe
initStore
function is called when theconfigureStore
function is called in another JS file (index.js
), and that will set the slice's initial state and its respective actions.Multiple state slices and actions can be added by calling
initStore
multiple times in different files (e.gorders-store.js
).
Let's see where configureStore
is called:
// /src/index.jsimport React from 'react';import ReactDOM from 'react-dom';import { BrowserRouter } from 'react-router-dom';import './index.css';import App from './App';πimport configureProductsStore from './hooks-store/products-store';πconfigureProductsStore();ReactDOM.render(<BrowserRouter><App /></BrowserRouter>,document.getElementById('root'));
Some notes:
In
Index.js
we can configure other slices of the store, likeorders
,payments
etc, by first setting up config files like/hooks-store/products-store.js
but named differently, like/hooks-store/orders-store.js
, and then calling them here, e.gconfigureOrdersStore
, etc.
The useStore
hook can be used inside components to read and update the data slices; in this case we have only one slice called products
:
// /src/containers/products.jsimport ProductItem from '../components/Products/ProductItem';import { useStore } from '../hooks-store/store';import './Products.css';const Products = props => {// we're just interested in reading the state, not dispatching an actionπconst state = useStore()[0];return (<ul className="products-list">π{state.products.map(prod => (<ProductItemkey={prod.id}id={prod.id}title={prod.title}description={prod.description}isFav={prod.isFavorite}/>))}</ul>);};export default Products;
// /src/components/Products/ProductItem.jsimport React from 'react';import Card from '../UI/Card';import { useStore } from '../../hooks-store/store';import './ProductItem.css';const ProductItem = props => {// we're just interested in dispatching an action, not in reading the stateπconst dispatch = useStore()[1];const toggleFavHandler = () => {// toggleFav(props.id);πdispatch('TOGGLE_FAV', props.id);};return (<Card style={{ marginBottom: '1rem' }}><div className="product-item"><h2 className={props.isFav ? 'is-fav' : ''}>{props.title}</h2><p>{props.description}</p><buttonclassName={!props.isFav ? 'button-outline' : ''}onClick={toggleFavHandler}>{props.isFav ? 'Un-Favorite' : 'Favorite'}</button></div></Card>);};export default ProductItem;
Final state management solution with side effects
Check out the code in this repo's async branch.
So far, so good. Our custom Hook solution works like a charm, but what if we want to perform async tasks before updating the state?
That async task could be, for example, a GET
or POST
request, and depending on the result, dispatch or not action, or just dispatching an action with fresh data from that API.
In this particular example, a new product's property called timesClicked
will only be updated if a POST
request to an analytics service has been successful.
For this solution, let's make use of the async await
features, that make the JavaScript code nicer to read.
Heads up! If you're going to use the following snippets in production code, make sure you test it thoroughly, because it's an experimental hook π§ͺ.
If you find any bugs in the followuing code, please submit a PR so we can improve it π
// /src/hooks-store/store.jsimport { useState, useEffect } from 'react';let globalState = {};let listeners = [];let actions = {};πlet sideEffects = {};export const useStore = () => {const setState = useState(globalState)[1];const dispatch = async (actionIdentifier, payload) => {console.log(`${actionIdentifier} action has been dispatched for ${payload.productId} product`);if (π sideEffects[actionIdentifier]) {await sideEffects[actionIdentifier](globalState, dispatch, payload);}const newState = actions[actionIdentifier] ?actions[actionIdentifier](globalState, dispatch, payload) :{ ...globalState };globalState = { ...globalState, ...newState };console.log(`${actionIdentifier} action for ${payload.productId} product finished running`, 'the updated globalState is: ', globalState );for (const listener of listeners) {listener(globalState)}};useEffect(() => {listeners.push(setState);return () => {listeners = listeners.filter(li => li !== setState);};}, [setState]);return [globalState, dispatch];};export const initStore = (userActions, π userSideEffects, initialState) => {if (initialState) {globalState = { ...globalState, ...initialState };}actions = { ...actions, ...userActions };πsideEffects = { ...sideEffects, ...userSideEffects}};
// /src/hooks-store/products-store.jsimport { initStore } from './store';π// fake async task that takes 4 seconds to resolveconst fakePostRequest = (payload) => {console.log(`Posting : ${payload.productId} to analytics as favourite ${payload.newFavStatus}`);// this fakes sending the value of productId & newFavStatus to an analytics service everytime the button is clickedreturn new Promise((resolve, reject) => {setTimeout(() => {console.log(`${payload.productId} successfully posted to analytics!`)resolve();}, 4000);});}const configureStore = () => {const actions = {TOGGLE_FAV: (curState, dispatch, payload) => {const prodIndex = curState.products.findIndex(p => p.id === payload.productId);const newFavStatus = !curState.products[prodIndex].isFavorite;const updatedProducts = [...curState.products];updatedProducts[prodIndex] = {...curState.products[prodIndex],isFavorite: newFavStatus};πdispatch('POST_TO_ANALYTICS', { productId: payload.productId, newFavStatus });return { products: updatedProducts };},πSET_TIMES_CLICKED: (curState, dispatch, payload) => {const prodIndex = curState.products.findIndex(p => p.id === payload.productId);const updatedProducts = [...curState.products];updatedProducts[prodIndex] = {...curState.products[prodIndex],timesClicked: curState.products[prodIndex].timesClicked + 1};return { products: updatedProducts };}};πconst sideEffects = {POST_TO_ANALYTICS: async (curState, dispatch, payload) => {console.log(`POST_TO_ANALYTICS sideEffect is running for product ${payload.productId} `, 'globalState is: ', curState,'payload is :', payload);try {// fake call to post data to an Data analytics API// this is just an example, you might not do this in real life analyticsawait fakePostRequest(payload);dispatch('SET_TIMES_CLICKED', {productId: payload.productId});} catch(error) {// analytics post failed, let's not dispatch the action to mark it as clickedreturn;}}}initStore(actions, π sideEffects, {products: [{id: 'p1',title: 'Red Scarf',description: 'A pretty red scarf.',πisFavorite: false,timesClicked: 0},{id: 'p2',title: 'Blue T-Shirt',description: 'A pretty blue t-shirt.',isFavorite: false,timesClicked: 0},{id: 'p3',title: 'Green Trousers',description: 'A pair of lightly green trousers.',isFavorite: false,timesClicked: 0},{id: 'p4',title: 'Orange Hat',description: 'Street style! An orange hat.',isFavorite: false,timesClicked: 0}],});};export default configureStore;
Some notes about this solution:
In
/src/hooks-store/store.js
:
An
if
check and aternary expression
have been used inside thedispatch
function body, to handle the case of not having anactions
orsideEffect
functions defined for a specificactionIdentifier
.
In
/src/hooks-store/products-store.js
:
sideEffects
functions don't change state, they just run before actions and they do async tasks (like posting data, geting data from endpoints, etc) and they then dispatch another action with some fresh data (if needed), or they could even not dispatch an action at all.actions
functions can also dispatch other actions. This is different to how Redux works. When dispatching an action from inside an action, make sure you pass the updated state as a payload to it.
The expected behaviour when a user toggles the Favourite
button on an unfaved product is the following:
TOGGLE_FAV
action is dispatchedPOST_TO_ANALYTICS
sideEffect is dispatched, holding the newisFavourite
boolean value.POST_TO_ANALYTICS
sideEffect starts runningThe fake POST request to analytics starts, with a request body holding the
productId
and the newisFavourite
values.The
isFavorite
boolean property of the product is toggledtrue
TOGGLE_FAV action finishes running
The UI reflects the change of the faved product.
The button text changes to
Un-favorite
.The fake POST request returns a successful response, after 4 seconds
SET_TIMES_CLICKED
action is dispatchedThe property
timesClicked
of the product is incremented by 1.SET_TIMES_CLICKED
finished running.POST_TO_ANALYTICS
sideEffect finished running
So, to recap, the timesClicked
propery is updated only if data has been successfuly posted to an analytics server.
Feel free to start smashing the Favourite
buttons and check the log messages in the console log, it's really fun π€
Pros and cons of the custom Hook solution with side effects
Advantages:
By Reading the Redux documentation, the proposed solution with custom Hooks shares these advantages with Redux:
1. Single Source of Truth:
"The global state of your application is stored as an object inside a single store. Any given piece of data should only exist in one location, rather than being duplicated in many places.
This makes it easier to debug and inspect your app's state as things change, as well as centralizing logic that needs to interact with the entire application."
That way, when any component dispatches an action, the state is changed, and then all the interested components are notified of the new state value, in a one way system."
- #
State is Read-Only
"The only way to change the state is to dispatch an action ..."
"This way, the UI won't accidentally overwrite data, and it's easier to trace why a state update happened.."
And this is an advantage over Redux:
- #
Lightweight
There's no dependency on a library, we're using built in React features to manage state.
- #
Out of the box Async taks handling
Make API calls and other async tasks out of the box, without the need of another library, plus having the ability to call dispatch more actions inside actions (do this with care though)
Disadvantages:
One disadvantage over Redux:
- #
Unnecessary re-renders
When any slice of the state changes, all components using the store re-render, no matter if they're interested in that slice or not. This could be solved by having as many Hooks as slices we want in the state (1 hook per slice).
- #
Debugging experience
No Redux debugging tools available through the Redux Devtools extension to debug state.
Bonus
Do you remember the multiple http
calls to get the list of posts we had when using the usePosts
hook? (shown in this section of the article).
We can now solve the problem by using the useStore hook with side effects with the store configured like this:
// /src/hooks/posts-store.jsimport { initStore } from './store';const configureStore = () => {const actions = {πSET_POSTS: (curState, dispatch, data) => {return { posts: [...data] };},}const sideEffects = {πFETCH_POSTS: async (curState, dispatch, payload) => {try {const rawData = await fetch("https://jsonplaceholder.typicode.com/posts");const data = await rawData.json();dispatch('SET_POSTS', data);} catch (error) {return;}}}initStore(actions, sideEffects, {posts: [],});};export default configureStore;
Then, we dispatch the FETCH_POSTS
sideEffect at a high level of the app, so all the components (even the most nested ones) using the posts
array have it available when they render (or they can get the posts as soon as the single http
call response arrives).
// /src/App.jsimport { useEffect, useCallback } from 'react';import './App.css';import Posts from './components/Posts';import Widget from './components/Widget';import { useStore } from './hooks/store';function App() {πconst dispatch = useCallback(useStore()[1], []);πuseEffect(() => {dispatch('FETCH_POSTS');},[dispatch]);return (<div className="App"><Posts /><Widget /></div>);}export default App;
And this is how the posts are consumed:
// /src/components/Posts.jsimport "./Posts.css"import { useStore } from '../hooks/store';const Posts = () => {πconst globalState = useStore()[0];const postsJSX = globalState.posts.slice(0, 9).map(item => {return (<li><h3>{item.title}</h3><p>{item.body}</p></li>);});return (<div className="Posts"><h2>Posts component</h2><ul>{postsJSX}</ul></div>);}export default Posts;
The useStore
hook is used in the same way in Posts.js
and Widget.js
.
After building and serving the app, we can see that there's only one http
request π, no matter how many components are rendered on the screen using the custom Hook.
The code of the above snippets can be found in this repo.
Conclusion
If you have reached this point, massive congrats!π
It's not enough to read blog posts to get good at React Hooks and state management, you need to spend time on your keyboard building apps, so I encourage you to do that π€
Here's one amazing YouTube video by Jack Herrington about a new React API called useSyncExternalStore, that makes the global store more performant by using selectors, it's worth checking out!
π»Happy coding! π»