React Context API with TypeScript
BCiriak Avatar
by BCiriakAPR 03, 2023 | 15 min read

React Context API with TypeScript

How can we manage React application state without the use of any external library? We can use the Context API that comes with React. In this article, we will look at how to use it with TypeScript.


Too many state management libraries

Application state management can feel a bit overwhelming for beginner React developers, especially if they have to pick one of the many libraries we have nowadays. Redux? Mobx? Jotai? Zustand?

As you might already know, there is one more choice and it is the Context API. It comes with React, so we don't have to install anything to manage our global application state.

Advantages and disadvantages of Reacts' Context API

Advantages

  • as just mentioned, it comes with React which means we don't need to install anything else = smaller app build
  • Context API is quite powerful and it can handle complex global app state with its partner in crime, useReducer
  • although it can handle complex state, it might be suited for simple state management alongside data fetching library like React Query
  • can help us with anti-patterns like prop drilling

Disadvantages

  • when the state gets too complicated, it can get a bit messy with all the providers and wrapping of our components
  • more boilerplate than some alternatives

useContext example with TypeScript

One very common use case for Context API is to manage our application theme/mode. Let's see how we can manage it with useContext and useState.

Learn more about usage of useState with TypeScript.

Here is a GitHub repo to a starter React app created with Vite, if you want to follow along just clone it:

Vite Starter App

createContext with TypeScript

First we need to create our ThemeContext and give it a type of Theme.

./App.tsx
type Theme = {
  darkMode: boolean
  toggleTheme: () => void
}

const ThemeContext = createContext<Theme | null>(null)

The Theme type has two properties, first one is boolean to track the state of our theme and second, toggleTheme is function that will actually change the state.

Now we can use it in our App component. There is one thing we need to understand about Context, it is basically a container that will hold values specified by us. In case of the ThemeContext, we will assign it a useState piece that will also update the context by setState function.

./App.tsx
const [darkMode, setDarkMode] = useState(false)

Simple useState to hold the boolean value and to update the value.

Now if we want to use the context, we need to wrap a component with it. By doing so, we enable the ThemeContext in that component and all of its' children.

./App.tsx
<ThemeContext.Provider
  value={{
	darkMode,
	toggleTheme: () => setDarkMode(!darkMode),
  }}
>
  <div className="container mx-auto">
    <h1>React + Vite + TypeScript + Tailwind + Prettier</h1>
    <button className="btn bg-yellow-400" onClick={() => setDarkMode(!darkMode)}>
      {darkMode ? 'Dark Mode' : 'Light Mode'}
    </button>
  </div>
</ThemeContext.Provider>

Here we have wrapped the div in ThemeContext.Provider and added button to toggle between the Light and Dark mode.

Here is the whole component:

./App.tsx
import { createContext, useEffect, useState } from 'react'

type Theme = {
  darkMode: boolean
  toggleTheme: () => void
}

const ThemeContext = createContext<Theme | null>(null)

function App() {
  const [darkMode, setDarkMode] = useState(false)

  useEffect(() => {
    if (darkMode) {
      document.body.classList.add('dark')
    } else {
      document.body.classList.remove('dark')
    }
  }, [darkMode])

  return (
    <ThemeContext.Provider
      value={{
        darkMode,
        toggleTheme: () => setDarkMode(!darkMode),
      }}
    >
      <div className="container mx-auto">
        <h1>React + Vite + TypeScript + Tailwind + Prettier</h1>
        <button className="btn bg-yellow-400" onClick={() => setDarkMode(!darkMode)}>
          {darkMode ? 'Dark Mode' : 'Light Mode'}
        </button>
      </div>
    </ThemeContext.Provider>
  )
}

export default App

The useEffect that we are using is only adding and removing class to our body tag, which adds these styles:

index.css
.dark {
  @apply bg-gray-800 text-white;
}

.btn {
  @apply px-4 py-2 my-1 rounded-md;
}

If we now start up the app, we should be able to toggle between the themes. This basic example shows how to use Context with TypeScript and useState for simple use cases.

Complex global state with useContext and TypeScript

Now that we have seen the very basic usage of Context API with TypeScript, let's look at something more powerful. To enable the full power of Context, we will use two React hooks, useContext and useReducer.

useReducer is ideal for handling complex state and useContext is great for exposing it globally throughout our application.

We will add a very simple job board to our application and refactor the ThemeContext to go along with JobsContext. This new context will handle CRUD operations (without update) of our job board.

First, let's refactor ThemeContext by moving it to separate file in contexts folder within the src.

./contexts/ThemeContext.tsx
import { useContext, createContext, useState } from 'react'

type Theme = {
  darkMode: boolean
  toggleTheme: () => void
}

const ThemeContext = createContext<Theme | null>(null)

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [darkMode, setDarkMode] = useState(false)

  return (
    <ThemeContext.Provider
      value={{
        darkMode,
        toggleTheme: () => setDarkMode(!darkMode),
      }}
    >
      {children}
    </ThemeContext.Provider>
  )
}

export function useTheme() {
  const context = useContext(ThemeContext)
  if (context === null) {
    throw new Error('useTheme must be used within a ThemeProvider')
  }
  return context
}

Here we are creating and exporting ThemeProvider that will wrap our App component so that we can use this context. We are also exporting useTheme hook, which exposes the state and toggle function.

Now we can simplify the App component with this new standalone ThemeContext.

./App.tsx
import { useEffect } from 'react'
import { useTheme } from './contexts/ThemeContext'

function App() {
  const { darkMode, toggleTheme } = useTheme()

  useEffect(() => {
    if (darkMode) {
      document.body.classList.add('dark')
    } else {
      document.body.classList.remove('dark')
    }
  }, [darkMode])

  return (
    <div className="container mx-auto">
      <h1>React + Vite + TypeScript + Tailwind + Prettier</h1>
      <button className="btn bg-yellow-400" onClick={toggleTheme}>
        {darkMode ? 'Dark Mode' : 'Light Mode'}
      </button>
    </div>
  )
}

export default App

We just need to import our useTheme hook and we are good to go. Thanks to TypeScript we are getting our two properties that we defined in our Theme type.

If we would try to run the app now, we would get the useTheme must be used within a ThemeProvider error defined in our hook. To fix this, we need to wrap our App component with ThemeProvider.

./main.tsx
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import { ThemeProvider } from './contexts/ThemeContext'
import './index.css'

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <ThemeProvider> // wrapping the App component
      <App />
    </ThemeProvider>
  </React.StrictMode>
)

And the refactor of ThemeContext is done. Onto the job board.

UI Components for Job Board

First we will create couple of basic components to have our UI prepared.

./JobBoard.tsx
import AddJob from './components/AddJob'
import JobDetail from './components/JobDetail'
import JobList from './components/JobList'

export default function JobBoard() {
  return (
    <div className="wrapper">
      <div className="flex justify-between">
        <h1 className="text-2xl">Job Board</h1>
      </div>
      <div className="flex">
        <JobList />
        <JobDetail />
      </div>
      <AddJob />
    </div>
  )
}

JobBoard is sitting in src directory and is a parent component to the three parts of the board, all created in components folder inside the src:

./components/JobList.tsx
import { useTheme } from '../contexts/ThemeContext'

export default function JobList() {
  const darkMode = useTheme().darkMode

  return (
    <div className={\`wrapper w-1/4 $\{darkMode ? 'bg-gray-700' : ''}\`}>
      <h3 className="text-xl">Job List</h3>
      <ul>
        <li>React Developer</li>
      </ul>
    </div>
  )
}

JobList will display the list of jobs in a kind of sidebar.

./components/JobDetail.tsx
export default function JobDetail() {
  return (
    <div className="wrapper w-3/4">
      <h3 className="text-xl">Job Details</h3>
      <p>Title: React Developer</p>
      <p>Is Active: 'Yes'</p>
      <button>Delete Job</button>
    </div>
  )
}

JobDetail will show selected Job and will provide options to read, delete and update single job.

./components/AddJob.tsx
export default function AddJob() {
  return (
    <div className="wrapper">
      <h1>Add Job</h1>
      <div>
        <label>Title</label>
        <input className="border dark:text-black" type="text" value={''} />
      </div>
      <button className="btn bg-blue-400">Add Job</button>
    </div>
  )
}

AddJob is a single input component to add job by writing its' title. There was also small change in the index.css file:

./index.css
@tailwind base;
@tailwind components;
@tailwind utilities;

.dark {
  @apply bg-gray-800 text-white;
}

.btn {
  @apply px-4 py-2 my-1 rounded-md;
}

.wrapper {
  @apply border p-2 m-1;
}

Now we can add the JobBoard component to our App component right under the toggle theme button:

./App.tsx
...
    <div className="container mx-auto">
      <h1>React + Vite + TypeScript + Tailwind + Prettier</h1>
      <button className="btn bg-yellow-400" onClick={toggleTheme}>
        {darkMode ? 'Dark Mode' : 'Light Mode'}
      </button>
      <JobBoard /> // < = = = JobBoard
    </div>
...

Alright, now if we look at the app, we see our little Job Board and we can toggle the light and dark mode. Before we move on, we need some placeholder JSON data for couple of Jobs to simulate API call, when our app loads for the first time.

Create a new file data.json in the root of the project with following data:

../data.json
{
  "jobs": [
    {
      "id": 1,
      "title": "Full Stack Engineer",
      "isActive": true
    },
    {
      "id": 2,
      "title": "React Developer",
      "isActive": true
    }
  ]
}

Perfect, now onto the meat of the article, JobsContext. To handle CRUD operations in our context, we will use aforementioned React hook made just for this, useReducer.

Learn more about how to use the useReducer with TypeScript

First we will create our types. One for the Job, based on the JSON data structure and one for the JobAction.

./contexts/JobsContext.tsx
export type Job = {
  title: string
  isActive: boolean
}

type JobAction =
  | {
      type: 'SET_JOBS'
      payload: Job[]
    }
  | {
      type: 'ADD_JOB' | 'REMOVE_JOB'
      payload: Job
    }

We are exporting Job type because we will need it in our components. For the JobAction type, we are using union to specify the different payload. Now we can create our contexts.

./context/JobsContext.tsx
const JobsContext = createContext<Job[] | null>(null)
const JobsDispatchContext = createContext<React.Dispatch<JobAction>>(
  {} as React.Dispatch<JobAction>
)

The first context is just a container to hold our array of jobs and the second one is for interacting with it through dispatching actions. Now to the brains of this context, jobsReducer.

./context/JobsContext.tsx
function jobsReducer(jobs: Job[], action: JobAction): Job[] {
  switch (action.type) {
    case 'SET_JOBS':
      return [...(action.payload as Job[])]
    case 'ADD_JOB':
      return [...jobs, action.payload as Job]
    case 'REMOVE_JOB':
      return [...jobs.filter((job) => job.title !== action.payload.title)]
    default:
      return jobs
  }
}

We are missing action for updating the job for the sake of making the reducer a bit more simple. Feel free to add the action later as an exercise.

Now we are ready to create the last 3 exports that will actually be used throughout our application. JobsProvider for providing our contexts and 2 hooks to enable interacting with these contexts.

./context/JobsContext.tsx
export function JobsProvider({ children }: { children: React.ReactNode }) {
  const [jobs, dispatch] = useReducer(jobsReducer, [])

  return (
    <JobsContext.Provider value={jobs}>
      <JobsDispatchContext.Provider value={dispatch}>
        {children}
      </JobsDispatchContext.Provider>
    </JobsContext.Provider>
  )
}

Here we are using the useReducer function and supplying it with the jobsReducer and as initial state we give it and empty array. This provider is a simple component that just wraps children with both of our contexts.

Note how we divide variables coming from useReducer hook into each of the Contexts.

Last thing to do is to create our hooks.

./context/JobsContext.tsx
export function useJobs() {
  const context = useContext(JobsContext)
  if (context === null) {
    throw new Error('useJobs must be used within a JobsContext')
  }
  return context
}

export function useJobsDispatch() {
  const context = useContext(JobsDispatchContext)
  if (context === null) {
    throw new Error('useJobsDispatch must be used within a JobsDispatchContext')
  }
  return context
}

And finally, the whole JobsContext:

./context/JobsContext.tsx
import { createContext, useContext, useReducer } from 'react'

export type Job = {
  title: string
  isActive: boolean
}

type JobAction =
  | {
      type: 'SET_JOBS'
      payload: Job[]
    }
  | {
      type: 'ADD_JOB' | 'REMOVE_JOB'
      payload: Job
    }

const JobsContext = createContext<Job[] | null>(null)
const JobsDispatchContext = createContext<React.Dispatch<JobAction>>(
  {} as React.Dispatch<JobAction>
)

function jobsReducer(jobs: Job[], action: JobAction): Job[] {
  switch (action.type) {
    case 'SET_JOBS':
      return [...(action.payload as Job[])]
    case 'ADD_JOB':
      return [...jobs, action.payload as Job]
    case 'REMOVE_JOB':
      return [...jobs.filter((job) => job.title !== action.payload.title)]
    default:
      return jobs
  }
}

export function JobsProvider({ children }: { children: React.ReactNode }) {
  const [jobs, dispatch] = useReducer(jobsReducer, [])

  return (
    <JobsContext.Provider value={jobs}>
      <JobsDispatchContext.Provider value={dispatch}>
        {children}
      </JobsDispatchContext.Provider>
    </JobsContext.Provider>
  )
}

export function useJobs() {
  const context = useContext(JobsContext)
  if (context === null) {
    throw new Error('useJobs must be used within a JobsContext')
  }
}

export function useJobsDispatch() {
  const context = useContext(JobsDispatchContext)
  if (context === null) {
    throw new Error('useJobsDispatch must be used within a JobsDispatchContext')
  }
}

Now we can use our awesome new context to manage application state!

First up, let's fix our JobList, so it actually "fetches" jobs from the JSON file and shows them in the list.

./components/JobList.tsx
import { useEffect } from 'react'
import { useTheme } from '../contexts/ThemeContext'
import data from '../../data.json'
import { Job, useJobs, useJobsDispatch } from '../contexts/JobsContext'

export default function JobList() {
  const darkMode = useTheme().darkMode
  const dispatch = useJobsDispatch()
  const jobs = useJobs()

  useEffect(() => {
    if (jobs.length <= 0) {
      dispatch({ type: 'SET_JOBS', payload: data.jobs })
    }
  }, [])

  return (
    <div className={\`wrapper w-1/4 $\{darkMode ? 'bg-gray-700' : ''}\`}>
      <h3 className="text-xl">Job List</h3>
      {jobs.length > 0 ? (
        <ul>
          {jobs.map((job: Job) => (
            <li key={job.title}>{job.title}</li>
          ))}
        </ul>
      ) : (
        <p>No jobs found.</p>
      )}
    </div>
  )
}

We import our two hooks. Inside the useEffect we look at the jobs coming from JobsContext and if there are no jobs, we dispatch SET_JOBS action with the payload of data.jobs coming from the JSON file. Here we could call an API to fetch real data and initialise our context data.

Once the useEffect runs, we check if we have some jobs in the array and if we do, we map over them and display them in ul>li elements. If there are no jobs, we let the user know that there were no jobs found.

If we would try to run the app, we would get an error. You know why? Well we didn't provide our context anywhere. Let's do that by wrapping the app component with it.

./main.tsx
<ThemeProvider>
  <JobsProvider>
    <App />
  </JobsProvider>
</ThemeProvider>

And don't forget to import the ThemeProvider at the top of the file. When we run the app now, we should see our 2 jobs in the list. Awesome!

Let's handle adding jobs.

./components/AddJob.tsx
const [job, setJob] = useState<Job>({
  title: '',
  isActive: true,
})
const dispatch = useJobsDispatch()

We use useState hook to keep track of the Job variable within AddJob component. This would be even more handy, if Job type would have more properties and the form would be more complex. dispatch function will be used to send action to our context.

./components/AddJob.tsx
    <div className="wrapper">
      <h1>Add Job</h1>
      <div>
        <label>Title</label>
        <input
          className="border dark:text-black"
          type="text"
          value={job.title}
          onChange={handleTitleChange}
        />
      </div>
      <button className="btn bg-blue-400" onClick={handleAddJob}>
        Add Job
      </button>
    </div>

Here we set the input value to the title of the job variable and add onChange handler. We also wire up onClick to handle adding a job. Here are those handle functions:

./components/AddJob.tsx
  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setJob({ ...job, title: e.target.value })
  }
  
  const handleAddJob = () => {
    if (job.title !== '') {
      dispatch({ type: 'ADD_JOB', payload: job })
      setJob({ ...job, title: '' })
    }
  }

The handleTitleChange is just updating the title of job and the handleAddJob is dispatching ADD_JOB action with the job as a payload. After the action is dispatched, we set the title of the job to an empty string.

Here is the complete AddJob component:

./components/AddJob.tsx
import { useState } from 'react'
import { Job, useJobsDispatch } from '../contexts/JobsContext'

export default function AddJob() {
  const [job, setJob] = useState<Job>({
    title: '',
    isActive: true,
  })
  const dispatch = useJobsDispatch()

  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    setJob({ ...job, title: e.target.value })
  }

  const handleAddJob = () => {
    if (job.title !== '') {
      dispatch({ type: 'ADD_JOB', payload: job })
      setJob({ ...job, title: '' })
    }
  }

  return (
    <div className="wrapper">
      <h1>Add Job</h1>
      <div>
        <label>Title</label>
        <input
          className="border dark:text-black"
          type="text"
          value={job.title}
          onChange={handleTitleChange}
        />
      </div>
      <button className="btn bg-blue-400" onClick={handleAddJob}>
        Add Job
      </button>
    </div>
  )
}

With these changes, we are able to add new jobs to our job list. Last thing to do, is to fix our JobDetail component. In this component we want to show details of selected job and also have the delete job button.

./components/JobDetail.tsx
type Props = {
  job: Job | null
  setSelectedJob: (job: Job | null) => void
}

We define Props for our component. First is the job that we want details of, second is function to clear the job after we remove it. Both of these will be sent down from the JobBoard component.

./components/JobDetail.tsx
  const dispatch = useJobsDispatch()

  const handleRemoveJob = () => {
    if (job) {
      dispatch({ type: 'REMOVE_JOB', payload: job })
      setSelectedJob(null)
    }
  }

Again, we use dispatch to remove job from our context and setSelectedJob to unselect job.

./components/JobDetail.tsx
    <div className="wrapper w-3/4">
      {job ? (
        <>
          <h3 className="text-xl">Job Details</h3>
          <p>Title: {job.title}</p>
          <p>Is Active: {job.isActive ? 'Yes' : 'No'}</p>
          <button className="btn bg-red-500" onClick={handleRemoveJob}>
            Delete Job
          </button>
        </>
      ) : (
        <>
          <p>Select a job.</p>
        </>
      )}
    </div>

And this is the JSX, we are checking for a job, if we don't have any job selected, we inform the user to Select a job. If we do have a job, we display the details and also wire up onClick event on the delete button.

Here is the whole JobDetail component:

./components/JobDetail.tsx
import { Job, useJobsDispatch } from '../contexts/JobsContext'

type Props = {
  job: Job | null
  setSelectedJob: (job: Job | null) => void
}

export default function JobDetail({ job, setSelectedJob }: Props) {
  const dispatch = useJobsDispatch()

  const handleRemoveJob = () => {
    if (job) {
      dispatch({ type: 'REMOVE_JOB', payload: job })
      setSelectedJob(null)
    }
  }

  return (
    <div className="wrapper w-3/4">
      {job ? (
        <>
          <h3 className="text-xl">Job Details</h3>
          <p>Title: {job.title}</p>
          <p>Is Active: {job.isActive ? 'Yes' : 'No'}</p>
          <button className="btn bg-red-500" onClick={handleRemoveJob}>
            Delete Job
          </button>
        </>
      ) : (
        <>
          <p>Select a job.</p>
        </>
      )}
    </div>
  )
}

And the last thing to do is to adjust JobBoard so that it supplies the job and setSelectedJob props to JobDetail. Plus we need to adjust JobList to enable selecting a job.

./JobBoard.tsx
import { useState } from 'react' 
import { Job } from './contexts/JobsContext'
import AddJob from './components/AddJob'
import JobDetail from './components/JobDetail'
import JobList from './components/JobList'

export default function JobBoard() {
  const [selectedJob, setSelectedJob] = useState<Job | null>(null)

  return (
    <div className="wrapper">
      <div className="flex justify-between">
        <h1 className="text-2xl">Job Board</h1>
      </div>
      <div className="flex">
        <JobList setSelectedJob={setSelectedJob} />
        <JobDetail job={selectedJob} setSelectedJob={setSelectedJob} />
      </div>
      <AddJob />
    </div>
  )
}

We use useState to handle the selectedJob and than we forward those variables down to the JobDetail and JobList.

Last small addition to JobList and we are done.

./components/JobList.tsx
type Props = {
  setSelectedJob: (job: Job) => void
}
...
export default function JobList({ setSelectedJob }: Props) {
...
	<li onClick={() => setSelectedJob(job)} key={job.title}>
		{job.title}
	</li>
...

Great job, with these changes, we should now have working application with state management handled with Context API, useReducer and TypeScript.

Nice exercise could be to handle the selectedJob in our JobsContext, so that we don't have to send it down via props. Give it a go if you have any capacity left after this long article.

If you like this article, please let me know down below, I would really appreciate it!

Keep learning and see you in the next one!

Connect with me on social media ✨

Join my newsletter, to receive JavaScript, TypeScript, React.js and more news, tips and other goodies right into your mail box 📥. You can unsubscribe at any time.

©2024 JSTopics. All rights reserved.