loading...

Adios Redux: using React hooks and Context effectively

ankitjena profile image Ankit Jena Updated on ・9 min read

Ciao%20Redux/undraw_active_options_8je6.png

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

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.

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.

Ciao%20Redux/state.png

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 and dispatch. 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

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-queryand how to deal with server cache. Stay tuned.

Further Reading

Posted on by:

ankitjena profile

Ankit Jena

@ankitjena

Seeking knowledge in this limitless universe.

Discussion

markdown guide
 

So let's say I have 3 reducers (providers) and 2 UI components, I have to wrap every single component for 3 times (3 providers) if I want to use all stores in these components?

<Provider1>
    <Provider2>
        <Provider3>
             <Component 1 />
        </Provider3>
    </Provider2>
</Provider1>
<Provider1>
    <Provider2>
        <Provider3>
             <Component 2 />
        </Provider3>
    </Provider2>
</Provider1>

What happens if I wrap both components together? I mean is that the right way to do it? Are there any performance issues or something?

<Provider1>
    <Provider2>
        <Provider3>
             <Component 1 />
             <Component 2 />
        </Provider3>
    </Provider2>
</Provider1>

ROFL as I was writing this I actually figure out how great this is, I still wanna post this tho πŸ˜…πŸ˜…, let's assume I only want to use providers 1 and 2 in comp1, I just have to move Component1 by 1 line up, there is no need to wrap it in provider3 if it won't use any features from it.

This provider actually acts like that connect function from redux right?

Great article BTW, I didn't really have time to test hooks until now, and I am glad I did now, and run into this post it was really helpful!!

 

Hey Mario,
Sorry for the late reply. You don't have to wrap all the components separately. Also yes there's no need to wrap the 3rd component. The provider is similar to connect function, in Redux we generally combine the reducers.

 

Thanks for the article, it's great. But FYI, the word 'ciao' is in Italian, and it means both 'hello' as well as 'goodbye'. On the other hand, the word 'chau', is in Spanish, and it means only 'goodbye', which would be more appropriate in this context :)

 

Ah, thanks for the info mate. Today years old. Always thought ciao meant "Goodbye". Will update it.

 

You were almost right, in Spanish it does. Chau = Adios = Goodbye. Chau more coloquial, adios slightly more formal...both used extensively.

Thank you for the great article. Cheers.

Glad it helped. PS. For some reason "Chau" did not sound good in my head. So went with Adios :D

 

That was nice. Actually my question is I have tried to learn redux and found it tough as you have mentioned above. I was planning to give it one more shot. In your opinion should I completely ditch it or continue it and learn it on a fundamental level to at least have an idea.

 

The useReducer hook is actually a simpler way to understand Redux. If you understand that then you should understand Redux as well. Learning the fundamentals will help because this pattern is used a lot. Here's a good piece you can read
code-cartoons.com/a-cartoon-intro-...

 
 

This article seems to be underrated, amigo :)
Thank you!

 
 

Awesome write-up! Really well explained. Looking forward to the react-query one as well as it's also something I'm figuring out how to use!

 

I started a global state using hooks and I came to the same solution! haha thanks for sharing, the only missing step was React.useMemo, very interesting! ❀️

 

Ankit, what do you think creating a npm package for this?

 

Thank you Ankit, will be sure to try this workflow!

 

Thanks! This really helped πŸ™‚