Adios Redux: using React hooks and Context effectively

ankitjey

Ankit Jena

Posted on April 26, 2020

Adios Redux: using React hooks and Context effectively

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("")
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

Then in the Login component

import React from 'react'
import useLoginReducer from '../hooks/useLoginReducer'

export default function Login() {
  const [store, dispatch] = useLoginReducer()
    ...
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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>
    )
}
Enter fullscreen mode Exit fullscreen mode

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]
}
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

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>
  )
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

πŸ’– πŸ’ͺ πŸ™… 🚩
ankitjey
Ankit Jena

Posted on April 26, 2020

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related