Using the Context API in a React/Typescript project

The Context API is quite trendy these days and people say that it might even be good enough to replace redux. In the following lines, we will give it a try.

In this article we will setup a minimal frontend project, using the Context API to make some requests and update the content of some components, of course using Typescript and hooks. Related to the hooks, we will make use of useContext and useReducer, so that we will dispatch actions which will modify the globally stored state.

Important note: the code inside this article should be treated as sample and easy access / general overview. The complete code is available on Github:

Initial setup

You need some version of node installed on your computer. My current version is v14.15.3. We will use npx and yarn with it. Just to keep things simple, we will use create-react-app as a starting point:

  • npx create-react-app pokemons-with-context-api --template typescript

We will try to keep the project as small and simple as possible. Thus, we will make some decisions upfront and set some general rules to apply within the codebase:

  • Components, folders, variables will be named using camelCase and PascaleCase;
  • For keeping the project small, we will use fetch for making the api requests;
  • We will have a global state, defined using the Context API.

Setting up the global context

We will store the contexts inside a new folder which we will create in the root of the project, namely ./contexts. Therefore, our global context will have the following structure:

  • ./contexts/globalContext/index.ts – exports the components and hooks created;
  • ./contexts/globalContext/hooks/useGlobalContext.ts – contains a hook which will basically provide easy access to the context data;
  • ./contexts/globalContext/GlobalContext.tsx – the component for the context itself;
  • ./contexts/globalContext/GlobalProvider.tsx – the component for the provider;
  • ./contexts/globalContext/types.ts – defining the types outside the two components can make the code easier to understand.

Once the context is properly defined, we need to wrap the application component inside the GlobalProvider, and we will do that in the root file of the whole application, namely the index.tsx file.

Sources:

GlobalContext.tsx

This component will contain the type and the initial value of the context. We will also import from the provider the type of the context, mainly because the type of the dispatch function is defined in there.

import { createContext } from 'react'
import { GlobalContextType } from './GlobalProvider'

export type GlobalStateType = {
  pokemonName: string
  pokemonAvatar: string

  error: string
  isLoading: boolean
}

/**
 * The initial state
 */
export const initialState: GlobalStateType = {
  pokemonName: '',
  pokemonAvatar: '',

  error: '',
  isLoading: false,
}

/**
 * The context
 */
export const GlobalContext = createContext<GlobalContextType>({
  state: initialState,
  dispatch: () => undefined,
})

export default GlobalContext

GlobalProvider.tsx

import { useReducer, Dispatch, ReactNode } from 'react'

import GlobalContext, { initialState, GlobalStateType } from './GlobalContext'

export type GlobalActionType =
  | {
      type: 'setPokemonName' | 'setPokemonAvatar' | 'setError'
      payload: string
    }
  | {
      type: 'setIsLoading'
      payload: boolean
    }

export type GlobalContextType = {
  state: GlobalStateType
  dispatch: Dispatch<GlobalActionType>
}

const globalContextReducer = (state: GlobalStateType, action: GlobalActionType): GlobalStateType => {
  switch (action.type) {
    case 'setPokemonName': {
      console.log(`Setting the name of the pokemon to: ${action.payload}`)

      return {
        ...state,
        pokemonName: action.payload,
        pokemonAvatar: '',
        error: '',
      }
    }
    case 'setPokemonAvatar': {
      console.log(`Updating the avatar for: ${state.pokemonName}`)

      return {
        ...state,
        pokemonAvatar: action.payload,
        error: '',
      }
    }
    case 'setError': {
      console.log(`Error: ${action.payload}`)

      return {
        ...state,
        error: action.payload,
      }
    }
    case 'setIsLoading': {
      console.log(`Set isLoading: ${action.payload}`)

      return {
        ...state,
        isLoading: action.payload,
      }
    }
    default:
      return state
  }
}

export const GlobalProvider = (props: { children: ReactNode }): JSX.Element => {
  const [state, dispatch] = useReducer(globalContextReducer, initialState)

  return (
    <GlobalContext.Provider
      value={{
        state,
        dispatch,
      }}
    >
      {props.children}
    </GlobalContext.Provider>
  )
}

export default GlobalProvider

useGlobalContext.ts

This hook is mainly created to provide easy access to the context inside the components which need it.

import { useContext } from 'react'

import GlobalContext from '../GlobalContext'
import { GlobalContextType } from '../GlobalProvider'

const useGlobalContext = (): GlobalContextType => useContext(GlobalContext)

export default useGlobalContext

Create the pokemons request

We will store the request inside a custom hook. It will contain minimal protection to ensure that only one request happens at a time. For the sake of simplicity, we will use fetch.

import { useEffect } from 'react'
import { useGlobalContext } from '../contexts'

const useFetchPokemon = () => {
  const {
    state: { pokemonName },
    dispatch,
  } = useGlobalContext()

  useEffect(() => {
    let isCurrent = true

    if (pokemonName.length < 3) {
      return
    }

    fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}/`)
      .then((res) => res.json())
      .then((res) => {
        if (isCurrent && !!res.sprites && !!res.sprites.front_default) {
          dispatch({
            type: 'setPokemonAvatar',
            payload: res.sprites.front_default,
          })
        }
      })
      .catch((e) => {
        dispatch({ type: 'setError', payload: e.message })
      })

    return () => {
      isCurrent = false
    }
  }, [pokemonName, dispatch])
}

export default useFetchPokemon

Sources:

Create components which use the context API

The next step is to create an input which will set the name of the pokemon we are looking for. Just for the fun of it, as it is not necessary for this project, we will add some debouncing.

At this point, it is important to remember that we are building a small demo which uses the Context API – this means that we are separating components more than it might seem necessary. We will mainly implement two components which access the global context:

  • ./components/Search.tsx – shows the input for updating the pokemon’s name;
  • ./components/Avatar.tsx – displays the avatar of the pokemon, if available.

Sources:

Search.tsx

import { useState, useRef, useEffect } from 'react'
import { useGlobalContext } from '../contexts'
import debounce from '../utils/debounce'

import { useFetchPokemon } from '../hooks'

const Search = (): JSX.Element => {
  const {
    state: { pokemonName, isLoading, error },
    dispatch,
  } = useGlobalContext()
  const [searchInput, setSearchInput] = useState(pokemonName)

  useFetchPokemon()

  console.log({ pokemonName, searchInput })

  const debounced = useRef(
    debounce((value: string) => {
      dispatch({
        type: 'setPokemonName',
        payload: value,
      })
    }, 500)
  )

  useEffect(() => debounced.current(searchInput), [searchInput])

  return (
    <>
      <input
        className="Search"
        type="text"
        value={searchInput}
        onChange={(e) => {
          setSearchInput(e.target.value)
          dispatch({
            type: 'setPokemonName',
            payload: '',
          })
        }}
      />
      {isLoading && <div className="Loading">Loading...</div>}
      {!!error && <div className="Error">An error occurred while retrieving the data.</div>}
    </>
  )
}
export default Search

Avatar.tsx

import { useGlobalContext } from '../contexts'

const Avatar = (): JSX.Element => {
  const { state } = useGlobalContext()

  return <>{state.pokemonAvatar && <img src={state.pokemonAvatar} alt={state.pokemonName} />}</>
}

export default Avatar

Improve the test coverage

We have very little tests at this point. We will use jest and its __tests__ / *.spec.ts folder structure. We will not aim to having proper coverage. So the purpose just show some examples of how to test components. Using blackbox testing is preferred.

Sources:

Conclusion

Having the possibility to create small projects, without setting up the whole redux boilerplate is quite useful.

However, the Context API is not meant to replace redux. Or if it is, it’s just not there yet. The main drawback is that everything which consumes a context, will be re-rendered when the values inside the context are updated.