Ankit Jena
Posted on April 26, 2020
It's 2020 and React is still the most popular frontend framework in the world. It's not just because it's relatively simpler. The fact that it keeps getting better is what has keeping me hooked (unintentional pun). The introduction of hooks changed the ecosystem from class based components to functions and made writing React way more fun. But there hasn't been a particular state management tool that is the go to option in React.
Redux is really popular. But a major source of complaint with Redux is how difficult it is learn at the beginning due to a lot of boilerplate. Recently I got to see some tweets
The impact React Query is having on some Redux users is mind boggling to me.
I'm seeing it more every day that someone starts integrating the two and ultimately ends up with such a small amount of redux state that they drop Redux altogether and put it in React context.
π€―22:34 PM - 20 Apr 2020
This led me to go an learning spree and I got to know some exciting patterns and packages which might completely change how you view hooks and global state in general(it did for me).
When I first thought I would write this article series I had way too many options for a title. There was State Management 2020, Custom Hooks in React, and a few others. But finally I decided to go with Ciao Redux(Goodbye Redux), since that seemed like the end goal for this article series.
This article is inspired by this great talk from Tanner Linsley at JSConf Hawaii 2020. I recommend you to watch it if you haven't already.
So let's get started.
How do you see State in React?
One would simply say, State is all the data present in frontend or it's what you fetch from the server. But when you have used React for building applications for a few time now, you would understand the point I'm going to make.
@jhooks All apps have two types of state: UI State and Server Cache. Put all your server cache in react-query and the rest of your state is pretty simply managed within React state/context.04:57 AM - 22 Apr 2020
State can be majorly divided into 2 types:
- UI State
- Server Cache
You maybe wondering WTH I'm talking about. Let me explain.
UI State is the state or information for managing your UI. For example, Dark/Light theme, toggle a dropdown, manage some error state in forms. Server Cache is the data you receive from the server like a user details, list of products etc.
Managing State
Lets start with basics. And build something for example's sake while we are at it. No, not a todo list. We have enough tutorials for that already. We are gonna build a simple application with a login screen and a home screen.
useState
The useState
hook allows us to use state inside a functional component. So bye bye all the hassles of declaring state in constructor, accessing it through this
. One can simply do
import { useState } from 'react'
const [name, setName] = useState("")
and we get name
variable and a function to update the variable as setName
.
Now let's use this knowledge to make a login form for our page.
import React, { useState } from 'react'
export default function Login() {
const [email, setEmail] = useState("")
const [password, setPassword] = useState("")
const [emailError, setEmailError] = useState(false)
const [passwordError, setPasswordError] = useState(false)
const [isLoading, setIsLoading] = useState(false)
async function handleSubmit() {
setIsLoading(true)
const res = await axios.post(url, {email, password})
if(res.data.status === "EMAIL_ERROR") {
setEmailError(true)
}
if(res.data.status === "PASSWORD_ERROR") {
setPasswordError(true)
}
// Otherwise do stuff
}
return (
<div>
<input
type="text"
value={email}
onChange={
e => setEmail(e.target.value)
}
/>
{emailError && <ErrorComponent type="EMAIL_ERROR" />}
<input
type="password"
value={password}
onChange={
e => setPassword(e.target.value)
}
/>
{passwordError && <ErrorComponent type="PASSWORD_ERROR" />}
{ isLoading
? <button onClick={() => handleSubmit()}>Sign in</button>
: <LoadingButton /> }
</div>
)
}
This works. But this must not be the best way is it. And this can pretty easily go out of hand with addition of few other factors or validation checks for example.
useReducer
People familiar with Redux must know useReducer
works just like Redux does. For those who don't here's how it works.
Action -------> Dispatch -------> Reducer --------> Store
You create an action and dispatch it which goes through the reducer and updates the store. Let's implement it in the previous example and see how it works.
import React, { useReducer } from 'react'
const initialState = {
user: {
email: "",
password: ""
},
errors: {
email: false,
password: false
},
isLoading: false
}
const reducer = (state, action) => {
switch (action.type) {
case 'CHANGE_VALUE':
return {
...state,
user: {
...state.user,
[action.field]: action.data
}
}
case 'ERROR':
return {
...state,
errors: {
...state.errors,
[action.type]: true
}
}
case 'LOADING':
return {
...state,
isLoading: true
}
default:
return state
}
}
export default function Login() {
const [state, dispatch] = useReducer(reducer, initialState)
async function handleSubmit() {
dispatch({type: 'LOADING'})
const res = await axios.post(url, store.user)
if(res.data.status === "EMAIL_ERROR") {
dispatch({type: 'ERROR', field: "email"})
}
if(res.data.status === "PASSWORD_ERROR") {
dispatch({type: 'ERROR', field: "password"})
}
// Otherwise do stuff
}
return (
<div>
<input
type="text"
value={state.user.email}
onChange={
e => dispatch({type: "CHANGE_VALUE", data: e.target.value, field: "email"})
}
/>
{state.errors.email && <ErrorComponent type="EMAIL_ERROR" />}
<input
type="password"
onChange={
value={state.user.password}
e => dispatch({type: "CHANGE_VALUE", data: e.target.value, field: "password"})
}
/>
{state.errors.password && <ErrorComponent type="PASSWORD_ERROR" />}
<button onClick={() => handleSubmit()}>Sign in</button>
</div>
)
}
This looks good, we don't deal with separate functions, we declare one reducer and define some actions and corresponding store changes. This is quite helpful because while using useState
, we can easily lose track of the number of variables as our requirement grows. You must have a noticed this is much longer than the previous code, which takes us to the next section.
Abstracting logic from UI
While developing an application in react you should always try to keep your business logic away from your UI code. The UI component, which interacts with the user should only know what interactions the user can do(actions). Plus this provides proper structure as well good maintainability to your codebase. This was well supported by redux in which we can define our actions elsewhere which would take care of all the logic, keeping our UI code clean. But how do we achieve that with hooks. Custom hooks to the rescue!
Custom Hooks
React allows you to create your own custom hooks for better separation and sharing of logic across components. For the above example, we can create a file called hooks/useLoginReducer.js
import { useReducer } from 'react'
const initialState = {
user: {
email: "",
password: ""
},
errors: {
email: false,
password: false
},
isLoading: false
}
const reducer = (state, action) => {
switch (action.type) {
case 'CHANGE_VALUE':
return {
...state,
user: {
...state.user,
[action.field]: action.data
}
}
case 'ERROR':
return {
...state,
errors: {
...state.errors,
[action.type]: true
}
}
case 'LOADING':
return {
...state,
isLoading: true
}
default:
return state
}
}
export default function useLoginReducer() {
const [store, dispatch] = useReducer(reducer, initialState)
return [store, dispatch]
}
Then in the Login component
import React from 'react'
import useLoginReducer from '../hooks/useLoginReducer'
export default function Login() {
const [store, dispatch] = useLoginReducer()
...
}
Voila! We separated the logic from the component and it looks so much cleaner now. Custom hooks can be used as such to a great effect for separation of concerns.
Let's go ahead to the best part.
Global State
Managing global state is what 3rd party libraries like Redux aim to provide, because prop drilling is hell. React has Context API, which allows to pass data between components. Context allows you declare a Provider
which stores or initialises the data and Consumer
which can read or update the data. It is used by Redux in the background, but
- it was unstable for a lot of time
- needed render props which led to less readability
With the introduction of React hooks however, using context became a lot more easier. One can easily declare a global state and use them by combining hooks
and context
. Let's take a look at an example we used above. Suppose after login you want update the global store with user's details which can be used in a Navbar component to display the user's name.
We declare a context first and use hooks to store and update data.
const globalContext = React.createContext()
const intialState = {
user: {
...
}
}
const reducer = {
...
}
export const StoreProvider = ({children}) => {
const [store, dispatch] = React.useReducer(reducer, initialState)
//memoizes the contextValue so only rerenders if store or dispatch change
const contextValue = React.useMemo(
() => [store, dispatch],
[store, dispatch]
)
return (
<globalContext.Provider value={contextValue}>
{children}
</globalContext.Provider>
)
}
export function useStore() {
return React.useContext(globalContext)
}
So let me explain through the code here. We first create a context. Then we are using useReducer inside a component to create the store and dispatch method. We are using useMemo
to create a context variable to update only when one of it's dependencies change. Then we are returning the context.Provider
component with value as the context variable. In the last part we are using the useContext
hook which simply allows us to use the context inside a functional component provided it lies inside the Provider
.
// App.js
import React from 'react';
import { StoreProvider, useStore } from './context';
function App() {
return (
<StoreProvider>
<Navbar />
...
</StoreProvider>
);
}
// Login.js
import React from 'react';
import { useStore } from './context'
function Login() {
const [, dispatch] = useStore()
...
function handleSubmit() {
...
dispatch(...)
}
}
// Navbar.js
import React from 'react';
import { useStore } from './context';
function Navbar() {
const [{user}, dispatch] = useStore()
return (
...
<li>{user.name}</li>
)
}
So we wrap the app component in the StoreProvider
and use the useStore
function we returned to access the store value and dispatch function at a nested component. Sounds awesome right. Umm not so much. There are a lot of issues in this. Let's take a look.
- Firstly, since we are exporting both
store
anddispatch
. Any component which updates the component (uses dispatch only) and doesn't use the store will also rerender everytime the state changes. This is because a new data object is formed everytime context value changes. This is undesirable. - Secondly, we are using a single store for all our components. When we would add any other state to the reducer initialState, things will grow a lot. Plus every component that consumes the context will rerender everytime the state changes. This is undesirable and can break your application.
So what can we do to solve these. A few days I came across this tweet thread
@tannerlinsley @kentcdodds Most projects donβt need Redux if they use React Context correctly
So many problems b/c people try to put all their state in one context
If you break them up by concern instead, use useReducer, separate get/set contexts as needed, useMemo as needed -> React Context is golden14:47 PM - 21 Apr 2020
@dibfirman 1. Context + useState
2. Context + useReducer
3. DispatchContext + StateContext + useReducer
4. Multiple Providers of #3 for state "slices"
At any stage: Profile for slow renders, then useMemo
With those 4 stages and useMemo, I believe you can solve 99% of perf challenges.22:58 PM - 20 Apr 2020
Problem solved. This is what we needed. Now's let's implement that and I'll explain it along with.
For the first problem, we can simply separate the store and dispatch into to different contexts DispatchContext
for updating the store and StoreContext
for using the store.
const storeContext = React.createContext()
const dispatchContext = React.createContext()
const intialState = {
user: {
...
}
}
const reducer = {
...
}
export const StoreProvider = ({children}) => {
const [store, dispatch] = React.useReducer(reducer, initialState)
return (
<dispatchContext.Provider value={dispatch}>
<storeContext.Provider value={store}>
{children}
</storeContext.Provider>
</dispatchContext.Provider>
)
}
export function useStore() {
return React.useContext(storeContext)
}
export function useDispatch() {
return React.useContext(dispatchContext)
}
Then simply we can only import useDispatch
or useStore
according to our case.
// App.js
import React from 'react';
import { StoreProvider } from './context';
function App() {
return (
<StoreProvider>
<Navbar />
...
</StoreProvider>
);
}
//Login.js
import React from 'react';
import { useDispatch } from './context'
function Login() {
const dispatch = useDispatch()
...
function handleSubmit() {
...
dispatch(...)
}
}
// Navbar.js
import React from 'react';
import { useStore } from './context'
function Navbar() {
const {user} = useStore()
return (
...
<li>{user.name}</li>
)
}
Now moving on to the second problem. It's really simple, we don't need to create a single store. I had difficulty using context previously primarily due to this reason only. Even in Redux, we separate reducers and combine them.
We can simply define a function which takes in initialState
and reducer
and returns a store. Let's see how it's done.
import React from 'react'
export default function makeStore(reducer, initialState) {
const storeContext = React.createContext()
const dispatchContext = React.createContext()
const StoreProvider = ({children}) => {
const [store, dispatch] = React.useReducer(reducer, initialState)
return (
<dispatchContext.Provider value={dispatch}>
<storeContext.Provider value={store}>
{children}
</storeContext.Provider>
</dispatchContext.Provider>
)
}
function useStore() {
return React.useContext(storeContext)
}
function useDispatch() {
return React.useContext(dispatchContext)
}
return [StoreProvider, useStore, useDispatch]
}
Then we can declare our userContext
as follows.
import makeStore from '../store'
const initalState = {
user: {
...
}
}
const reducer = (state, action) => {
switch (action.type) {
...
...
}
}
const [
UserProvider,
useUserStore,
useUserDispatch
] = makeStore(reducer, initalState)
export { UserProvider, useUserStore, useUserDispatch }
And finally use it when we need
// App.js
import React from 'react';
import { UserProvider } from './userStoreContext';
function App() {
return (
<UserProvider>
<Navbar />
...
</UserProvider>
);
}
// Login.js
import React from 'react';
import { useUserDispatch } from './userStoreContext'
function Login() {
const dispatch = useUserDispatch()
...
function handleSubmit() {
...
dispatch(...)
}
}
// Navbar.js
import React from 'react';
import { useUserStore } from './userStoreContext'
function Navbar() {
const {user} = useUserStore()
return (
...
<li>{user.name}</li>
)
}
Done. If we want another store we can simply make another store and wrap it around our app or the components where you want to use it. For example
function App() {
return (
<UserProvider>
<Navbar />
<ProductProvider>
<Products />
</ProductProvider>
</UserProvider>
);
}
Whooh. This was it for the first part of the series. Hope you have learned how to use hooks and context effectively. In the next articles I'm going to talk about react-query
and how to deal with server cache. Stay tuned.
Further Reading
Posted on April 26, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.