From the translator:
I present a free translation of an article on how to implement an effective solution for replacing Redux with React context and hooks. Indication of errors in the translation or text is welcome. Enjoy watching.
Since the release of the new Context API in React 16.3.0, many people have asked themselves if the new API is good enough to consider it a replacement for Redux? I thought the same thing, but I did not fully understand even after the release of version 16.8.0 with hooks. I try to use popular technologies, the path is not always understanding the full range of problems that they solve, so I'm too used to Redux.
And so it happened that I signed up for
Kent C. Dodds' newsletter and found a few emails on the subject of context and state management. I started to read .... and read ... and after 5 blog posts, something clicked.
To understand all the basic concepts behind this, we will make a button, by clicking on which we will receive jokes with
icanhazdadjoke and display them. This is a small but sufficient example.
To prepare, let's start with two seemingly random tips.
First, let me introduce my friend
console.count
:
console.count('Button')
We will add a
console.count
call to each component to see how many times it is being rendered. Pretty cool, huh?
Secondly, when a React component is re-rendered, it
does not re-render the content passed as
children
.
function Parent({ children }) { const [count, setCount] = React.useState(0) console.count('Parent') return ( <div> <button type="button" onClick={() => { setCount(count => count + 1) }}> Force re-render </button> {children} </div> ) } function Child() { console.count('Child') return <div /> } function App() { return ( <Parent> <Child /> </Parent> ) }
After a few clicks on the button, you should see the following contents in the console:
Parent: 1 Child: 1 Parent: 2 Parent: 3 Parent: 4
Keep in mind that this is an often overlooked way to improve the performance of your application.
Now that we are ready, let's create the skeleton of our application:
import React from 'react' function Button() { console.count('Button') return ( <button type="button"> Fetch dad joke </button> ) } function DadJoke() { console.count('DadJoke') return ( <p>Fetched dad joke</p> ) } function App() { console.count('App') return ( <div> <Button /> <DadJoke /> </div> ) } export default App
Button
should receive an action generator (approx. Action Creator. The translation is taken from the
Redux documentation in Russian ) which will receive an anecdote.
DadJoke
should get the state, and the
App
display both components using the Provider context.
Now create a custom component and call it
DadJokeProvider
, which inside it will manage the state and wrap the child components in the Context Provider. Remember that updating its state will not re-render the entire application due to the above children optimization in React.
So, create a file and call it
contexts/dad-joke.js
:
import React from 'react' const DadJokeContext = React.createContext() export function DadJokeContextProvider({ children }) { const state = { dadJoke: null } const actions = { fetchDadJoke: () => {}, } return ( <DadJokeContext.Provider value={{ state, actions }}> {children} </DadJokeContext.Provider> ) }
We also export 2 hooks to get the value from the context.
export function useDadJokeState() { return React.useContext(DadJokeContext).state } export function useDadJokeActions() { return React.useContext(DadJokeContext).actions }
Now we can implement this:
import React from 'react' import { DadJokeProvider, useDadJokeState, useDadJokeActions, } from './contexts/dad-joke' function Button() { const { fetchDadJoke } = useDadJokeActions() console.count('Button') return ( <button type="button" onClick={fetchDadJoke}> Fetch dad joke </button> ) } function DadJoke() { const { dadJoke } = useDadJokeState() console.count('DadJoke') return ( <p>{dadJoke}</p> ) } function App() { console.count('App') return ( <DadJokeProvider> <Button /> <DadJoke /> </DadJokeProvider> ) } export default App
Here! Thanks to the API we made using the hooks. We will no longer make any changes to this file throughout the post.
Let's start adding functionality to our context file, starting with the state of
DadJokeProvider
. Yes, we could just use the
useState
hook, but let's instead manage our state through
reducer
by simply adding the well-known and beloved
Redux
functionality to us.
function reducer(state, action) { switch (action.type) { case 'SET_DAD_JOKE': return { ...state, dadJoke: action.payload, } default: return new Error(); } }
Now we can pass this reducer to the
useReducer
hook and get jokes with the API:
export function DadJokeProvider({ children }) { const [state, dispatch] = React.useReducer(reducer, { dadJoke: null }) async function fetchDadJoke() { const response = await fetch('https://icanhazdadjoke.com', { headers: { accept: 'application/json', }, }) const data = await response.json() dispatch({ type: 'SET_DAD_JOKE', payload: data.joke, }) } const actions = { fetchDadJoke, } return ( <DadJokeContext.Provider value={{ state, actions }}> {children} </DadJokeContext.Provider> ) }
Should work! Click on the button should receive and display jokes!
Let's check the console:
App: 1 Button: 1 DadJoke: 1 Button: 2 DadJoke: 2 Button: 3 DadJoke: 3
Both components are re-rendered each time the state is updated, but only one of them actually uses it. Imagine a real application in which hundreds of components use only actions. Would it be nice if we could provide all of these optional re-renders?
And here we enter the territory of relative equality, so a small reminder:
const obj = {}
A component that uses the context will be re-rendered each time the value of this context changes. Let's look at the meaning of our Context Provider:
<DadJokeContext.Provider value={{ state, actions }}>
Here we create a new object during each renderer, but this is inevitable, because a new object will be created every time we perform an action (
dispatch
), so it is simply impossible to cache (
memoize
) this value.
And it all looks like the end of the story, right?
If we look at the
fetchDadJoke
function, the only thing it uses from the external scope is
dispatch
, right? In general, I'm going to tell you a little secret about the functions created in
useReducer
and
useState
. For brevity, I will use
useState
as an example:
let prevSetCount function Counter() { const [count, setCount] = React.useState() if (typeof prevSetCount !== 'undefined') { console.log(setCount === prevSetCount) } prevSetCount = setCount return ( <button type="button" onClick={() => { setCount(count => count + 1) }}> Increment </button> ) }
Click on the button several times and look at the console:
true true true
You will notice that
setCount
the same function for each render. This also applies to our
dispatch
function.
This means that our
fetchDadJoke
function
fetchDadJoke
not depend on anything that changes over time, and does not depend on any other action generators, so the action object needs to be created only once, at the first render:
const actions = React.useMemo(() => ({ fetchDadJoke, }), [])
Now that we have a cached object with actions, can we optimize the context value? Actually, no, because no matter how well we optimize the value object, we still need to create a new one every time because of state changes. However, what if we move an action object from an existing context to a new one? Who said that we can have only one context?
const DadJokeStateContext = React.createContext() const DadJokeActionsContext = React.createContext()
We can combine both contexts in our
DadJokeProvider
:
return ( <DadJokeStateContext.Provider value={state}> <DadJokeActionsContext.Provider value={actions}> {children} </DadJokeActionsContext.Provider> </DadJokeStateContext.Provider> )
And tweak our hooks:
export function useDadJokeState() { return React.useContext(DadJokeStateContext) } export function useDadJokeActions() { return React.useContext(DadJokeActionsContext) }
And we are done! Seriously, download as many jokes as you want and see for yourself.
App: 1 Button: 1 DadJoke: 1 DadJoke: 2 DadJoke: 3 DadJoke: 4 DadJoke: 5
So you have implemented your own optimized state management solution! You can create different providers using this two-context template to create your application, but that's not all, you can also render the same provider component several times! What the fuck ?! Yes, try the
DadJokeProvider
render in several places and see how your state management implementation scales easily!
Unleash your imagination and review why you really need
Redux
.
Thanks to Kent C. Dodds for articles on the two-context template. I have never seen him anywhere and it seems to me that this changes the rules of the game.
Read the following Kent blog posts for more information on the concepts I talked about:
When to use useMemo and useCallback
How to optimize context value
How to use React Context effectively
Managing application state in React.
One simple trick to optimize re-renderings in React