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 and 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
  • npm start

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/index.ts – exports the components;
  • ./contexts/GlobalContext.tsx – the component for the context itself;
  • ./contexts/GlobalProvider.tsx – the component for the provider;
  • ./hooks/useGlobalContext.ts – contains a hook which will basically provide easy access to the context data.

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
Code language: JavaScript (javascript)

GlobalProvider.tsx

It is mandatory that each context come with a provider, which is a wrapper to be set around the components which will use data inside it. Since this is a global context, we will use it inside the ./index.tsx file.

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}: ${action.payload}`)

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

      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
Code language: JavaScript (javascript)

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 '../contexts/GlobalContext'
import type { GlobalContextType } from '../contexts/GlobalProvider'

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

export default useGlobalContext
Code language: JavaScript (javascript)

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 './useGlobalContext'

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

  useEffect(() => {
    const controller = new AbortController()
    const signal = controller.signal

    if (pokemonName.length < 3) {
      return
    }

    dispatch({
      type: 'setIsLoading',
      payload: true,
    })

    fetch(`https://pokeapi.co/api/v2/pokemon/${pokemonName}/`, { signal })
      .then((res) => res.json())
      .then((res) => {
        if (!!res.sprites && !!res.sprites.front_default) {
          dispatch({
            type: 'setPokemonAvatar',
            payload: res.sprites.front_default,
          })
        }
      })
      .catch((e) => {
        console.log({ e })
        if (e.name === 'AbortError') {
          console.log('New search performed')
          return
        }

        dispatch({ type: 'setError', payload: e.message || e })
      })
      .finally(() => {
        dispatch({
          type: 'setIsLoading',
          payload: false,
        })
      })

    return () => {
      controller.abort()
    }
  }, [pokemonName, dispatch])
}

export default useFetchPokemon
Code language: JavaScript (javascript)

A more elegant solution is to use the AbortController:

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. As it is always a safe approach, we will add some debouncing. Using any as a type is not recommended, but since debouncing is not the focus of this article, we will just disable the eslint rule for this line:

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const debounce = <F extends (...args: any[]) => any>(
  func: F,
  waitFor: number
) => {
  let timeout: ReturnType<typeof setTimeout> | null = null;

  const debounced = (...args: Parameters<F>) => {
    if (timeout !== null) {
      clearTimeout(timeout);
      timeout = null;
    }
    timeout = setTimeout(() => func(...args), waitFor);
  };

  return debounced as (...args: Parameters<F>) => ReturnType<F>;
};

export default debounce;
Code language: PHP (php)

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;
  • ./components/Avatar.tsx.

Sources:

Search.tsx

This is a simple input component which we will use to update the pokemon’s name:

import { useState, useRef, useEffect } from 'react'
import { useGlobalContext } from '../hooks'
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()

  const init = () => setSearchInput('pikachu')

  const debounced = useRef(
    debounce((value: string) => {
      dispatch({
        type: 'setPokemonName',
        payload: value,
      })
    }, 500)
  )
  useEffect(() => init(), [])
  useEffect(() => debounced.current(searchInput), [searchInput])

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

Avatar.tsx

This component displays the avatar of the pokemon, if available:

import { useGlobalContext } from '../hooks'

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

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

export default Avatar
Code language: JavaScript (javascript)

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:

Add pico for styling

For minimal styling of our application, we will use Pico.css. This is a minimal CSS framework for semantic CSS, which translates to being the solution to make our site prettier just by using the proper html elements.

To add it we will use:

  • yarn add @picocss/pico

Then, in App.tsx we will add the import for pico and just remove most of our custom styles:

import '@picocss/pico'
import './App.css'

import Search from './components/Search'
import Avatar from './components/Avatar'

function App() {
  return (
    <div className="App">
      <article>
        <header>Pokemon avatars with React</header>
        <Search />
        <Avatar />
      </article>
    </div>
  )
}

export default App
Code language: JavaScript (javascript)

More info:

Deploy using Github actions

To create a preview of the project, we will use github actions to publish the latest version. First of all, we need to make sure that what we build has relative paths, instead of absolute ones. For that, we will add "homepage": "./" to the root of package.json.

Then create an action by creating a new file .github/workflows/publish-master.yml:

name: Build and Deploy
on:
  push:
    branches:
      - master
jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    concurrency: ci-${{ github.ref }}
    steps:
      - name: Checkout 🛎️
        uses: actions/checkout@v2

      - name: Install and Build 🔧 # This example project is built using npm and outputs the result to the 'build' folder. Replace with the commands required to build your project, or remove this step entirely if your site is pre-built.
        run: |
          yarn
          npm run build

      - name: Deploy 🚀
        uses: JamesIves/github-pages-deploy-action@v4
        with:
          branch: gh-pages # The branch the action should deploy to.
          folder: build # The folder the action should deploy.
Code language: PHP (php)

To wrap up, we need to update the settings for the repository:

More info:

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.