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.tsxtypescript
3type Theme = {
4  darkMode: boolean
5  toggleTheme: () => void
6}
7
8const 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.tsxtypescript
11const [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.tsxtypescript
22<ThemeContext.Provider
23  value={{
24	 darkMode,
25	 toggleTheme: () => setDarkMode(!darkMode),
26  }}
27>
28  <div className="container mx-auto">
29    <h1>React + Vite + TypeScript + Tailwind + Prettier</h1>
30    <button className="btn bg-yellow-400" onClick={() => setDarkMode(!darkMode)}>
31      {darkMode ? 'Dark Mode' : 'Light Mode'}
32    </button>
33  </div>
34</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.tsxtypescript
1import { createContext, useEffect, useState } from 'react'
2
3type Theme = {
4  darkMode: boolean
5  toggleTheme: () => void
6}
7
8const ThemeContext = createContext<Theme | null>(null)
9
10function App() {
11  const [darkMode, setDarkMode] = useState(false)
12
13  useEffect(() => {
14    if (darkMode) {
15      document.body.classList.add('dark')
16    } else {
17      document.body.classList.remove('dark')
18    }
19  }, [darkMode])
20
21  return (
22    <ThemeContext.Provider
23      value={{
24        darkMode,
25        toggleTheme: () => setDarkMode(!darkMode),
26      }}
27    >
28      <div className="container mx-auto">
29        <h1>React + Vite + TypeScript + Tailwind + Prettier</h1>
30        <button className="btn bg-yellow-400" onClick={() => setDarkMode(!darkMode)}>
31          {darkMode ? 'Dark Mode' : 'Light Mode'}
32        </button>
33      </div>
34    </ThemeContext.Provider>
35  )
36}
37
38export default App

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

./index.csscss
1.dark {
2  @apply bg-gray-800 text-white;
3}
4
5.btn {
6  @apply px-4 py-2 my-1 rounded-md;
7}

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.tsxtypescript
1import { useContext, createContext, useState } from 'react'
2
3type Theme = {
4  darkMode: boolean
5  toggleTheme: () => void
6}
7
8const ThemeContext = createContext<Theme | null>(null)
9
10export function ThemeProvider({ children }: { children: React.ReactNode }) {
11  const [darkMode, setDarkMode] = useState(false)
12
13  return (
14    <ThemeContext.Provider
15      value={{
16        darkMode,
17        toggleTheme: () => setDarkMode(!darkMode),
18      }}
19    >
20      {children}
21    </ThemeContext.Provider>
22  )
23}
24
25export function useTheme() {
26  const context = useContext(ThemeContext)
27  if (context === null) {
28    throw new Error('useTheme must be used within a ThemeProvider')
29  }
30  return context
31}

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.tsxtypescript
1import { useEffect } from 'react'
2import { useTheme } from './contexts/ThemeContext'
3
4function App() {
5  const { darkMode, toggleTheme } = useTheme()
6
7  useEffect(() => {
8    if (darkMode) {
9      document.body.classList.add('dark')
10    } else {
11      document.body.classList.remove('dark')
12    }
13  }, [darkMode])
14
15  return (
16    <div className="container mx-auto">
17      <h1>React + Vite + TypeScript + Tailwind + Prettier</h1>
18      <button className="btn bg-yellow-400" onClick={toggleTheme}>
19        {darkMode ? 'Dark Mode' : 'Light Mode'}
20      </button>
21    </div>
22  )
23}
24
25export 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.tsxtypescript
1import React from 'react'
2import ReactDOM from 'react-dom/client'
3import App from './App'
4import { ThemeProvider } from './contexts/ThemeContext'
5import './index.css'
6
7ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
8  <React.StrictMode>
9    <ThemeProvider> // wrapping the App component
10      <App />
11    </ThemeProvider>
12  </React.StrictMode>
13)

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.tsxtypescript
1import AddJob from './components/AddJob'
2import JobDetail from './components/JobDetail'
3import JobList from './components/JobList'
4
5export default function JobBoard() {
6  return (
7    <div className="wrapper">
8      <div className="flex justify-between">
9        <h1 className="text-2xl">Job Board</h1>
10      </div>
11      <div className="flex">
12        <JobList />
13        <JobDetail />
14      </div>
15      <AddJob />
16    </div>
17  )
18}

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.tsxtypescript
1import { useTheme } from '../contexts/ThemeContext'
2
3export default function JobList() {
4  const darkMode = useTheme().darkMode
5
6  return (
7    <div className={`wrapper w-1/4 ${darkMode ? 'bg-gray-700' : ''}`}>
8      <h3 className="text-xl">Job List</h3>
9      <ul>
10        <li>React Developer</li>
11      </ul>
12    </div>
13  )
14}

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

./components/JobDetail.tsxtypescript
1export default function JobDetail() {
2  return (
3    <div className="wrapper w-3/4">
4      <h3 className="text-xl">Job Details</h3>
5      <p>Title: React Developer</p>
6      <p>Is Active: 'Yes'</p>
7      <button>Delete Job</button>
8    </div>
9  )
10}

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

./components/AddJob.tsxtypescript
1export default function AddJob() {
2  return (
3    <div className="wrapper">
4      <h1>Add Job</h1>
5      <div>
6        <label>Title</label>
7        <input className="border dark:text-black" type="text" value={''} />
8      </div>
9      <button className="btn bg-blue-400">Add Job</button>
10    </div>
11  )
12}

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

./index.csscss
1@tailwind base;
2@tailwind components;
3@tailwind utilities;
4
5.dark {
6  @apply bg-gray-800 text-white;
7}
8
9.btn {
10  @apply px-4 py-2 my-1 rounded-md;
11}
12
13.wrapper {
14  @apply border p-2 m-1;
15}

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

./App.tsxtypescript
15...
16    <div className="container mx-auto">
17      <h1>React + Vite + TypeScript + Tailwind + Prettier</h1>
18      <button className="btn bg-yellow-400" onClick={toggleTheme}>
19        {darkMode ? 'Dark Mode' : 'Light Mode'}
20      </button>
21      <JobBoard /> // < = = = JobBoard
22    </div>
23...

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.jsonjson
1{
2  "jobs": [
3    {
4      "id": 1,
5      "title": "Full Stack Engineer",
6      "isActive": true
7    },
8    {
9      "id": 2,
10      "title": "React Developer",
11      "isActive": true
12    }
13  ]
14}

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.tsxtypescript
3export type Job = {
4  title: string
5  isActive: boolean
6}
7
8type JobAction =
9  | {
10      type: 'SET_JOBS'
11      payload: Job[]
12    }
13  | {
14      type: 'ADD_JOB' | 'REMOVE_JOB'
15      payload: Job
16    }

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.tsxtypescript
18const JobsContext = createContext<Job[] | null>(null)
19const JobsDispatchContext = createContext<React.Dispatch<JobAction>>(
20  {} as React.Dispatch<JobAction>
21)

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.tsxtypescript
23function jobsReducer(jobs: Job[], action: JobAction): Job[] {
24  switch (action.type) {
25    case 'SET_JOBS':
26      return [...(action.payload as Job[])]
27    case 'ADD_JOB':
28      return [...jobs, action.payload as Job]
29    case 'REMOVE_JOB':
30      return [...jobs.filter((job) => job.title !== action.payload.title)]
31    default:
32      return jobs
33  }
34}

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.tsxtypescript
36export function JobsProvider({ children }: { children: React.ReactNode }) {
37  const [jobs, dispatch] = useReducer(jobsReducer, [])
38
39  return (
40    <JobsContext.Provider value={jobs}>
41      <JobsDispatchContext.Provider value={dispatch}>
42        {children}
43      </JobsDispatchContext.Provider>
44    </JobsContext.Provider>
45  )
46}

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.tsxtypescript
48export function useJobs() {
49  const context = useContext(JobsContext)
50  if (context === null) {
51    throw new Error('useJobs must be used within a JobsContext')
52  }
53  return context
54}
55
56export function useJobsDispatch() {
57  const context = useContext(JobsDispatchContext)
58  if (context === null) {
59    throw new Error('useJobsDispatch must be used within a JobsDispatchContext')
60  }
61  return context
62}

And finally, the whole JobsContext:

./context/JobsContext.tsxtypescript
1import { createContext, useContext, useReducer } from 'react'
2
3export type Job = {
4  title: string
5  isActive: boolean
6}
7
8type JobAction =
9  | {
10      type: 'SET_JOBS'
11      payload: Job[]
12    }
13  | {
14      type: 'ADD_JOB' | 'REMOVE_JOB'
15      payload: Job
16    }
17
18const JobsContext = createContext<Job[] | null>(null)
19const JobsDispatchContext = createContext<React.Dispatch<JobAction>>(
20  {} as React.Dispatch<JobAction>
21)
22
23function jobsReducer(jobs: Job[], action: JobAction): Job[] {
24  switch (action.type) {
25    case 'SET_JOBS':
26      return [...(action.payload as Job[])]
27    case 'ADD_JOB':
28      return [...jobs, action.payload as Job]
29    case 'REMOVE_JOB':
30      return [...jobs.filter((job) => job.title !== action.payload.title)]
31    default:
32      return jobs
33  }
34}
35
36export function JobsProvider({ children }: { children: React.ReactNode }) {
37  const [jobs, dispatch] = useReducer(jobsReducer, [])
38
39  return (
40    <JobsContext.Provider value={jobs}>
41      <JobsDispatchContext.Provider value={dispatch}>
42        {children}
43      </JobsDispatchContext.Provider>
44    </JobsContext.Provider>
45  )
46}
47
48export function useJobs() {
49  const context = useContext(JobsContext)
50  if (context === null) {
51    throw new Error('useJobs must be used within a JobsContext')
52  }
53}
54
55export function useJobsDispatch() {
56  const context = useContext(JobsDispatchContext)
57  if (context === null) {
58    throw new Error('useJobsDispatch must be used within a JobsDispatchContext')
59  }
60}

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.tsxtypescript
1import { useEffect } from 'react'
2import { useTheme } from '../contexts/ThemeContext'
3import data from '../../data.json'
4import { Job, useJobs, useJobsDispatch } from '../contexts/JobsContext'
5
6export default function JobList() {
7  const darkMode = useTheme().darkMode
8  const dispatch = useJobsDispatch()
9  const jobs = useJobs()
10
11  useEffect(() => {
12    if (jobs.length <= 0) {
13      dispatch({ type: 'SET_JOBS', payload: data.jobs })
14    }
15  }, [])
16
17  return (
18    <div className={`wrapper w-1/4 ${darkMode ? 'bg-gray-700' : ''}`}>
19      <h3 className="text-xl">Job List</h3>
20      {jobs.length > 0 ? (
21        <ul>
22          {jobs.map((job: Job) => (
23            <li key={job.title}>{job.title}</li>
24          ))}
25        </ul>
26      ) : (
27        <p>No jobs found.</p>
28      )}
29    </div>
30  )
31}

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.tsxtypescript
10<ThemeProvider>
11  <JobsProvider>
12    <App />
13  </JobsProvider>
14</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.tsxtypescript
5const [job, setJob] = useState<Job>({
6  title: '',
7  isActive: true,
8})
9const 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.tsxtypescript
23    <div className="wrapper">
24      <h1>Add Job</h1>
25      <div>
26        <label>Title</label>
27        <input
28          className="border dark:text-black"
29          type="text"
30          value={job.title}
31          onChange={handleTitleChange}
32        />
33      </div>
34      <button className="btn bg-blue-400" onClick={handleAddJob}>
35        Add Job
36      </button>
37    </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.tsxtypescript
11  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
12    setJob({ ...job, title: e.target.value })
13  }
14  
15  const handleAddJob = () => {
16    if (job.title !== '') {
17      dispatch({ type: 'ADD_JOB', payload: job })
18      setJob({ ...job, title: '' })
19    }
20  }

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.tsxtypescript
1import { useState } from 'react'
2import { Job, useJobsDispatch } from '../contexts/JobsContext'
3
4export default function AddJob() {
5  const [job, setJob] = useState<Job>({
6    title: '',
7    isActive: true,
8  })
9  const dispatch = useJobsDispatch()
10
11  const handleTitleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
12    setJob({ ...job, title: e.target.value })
13  }
14
15  const handleAddJob = () => {
16    if (job.title !== '') {
17      dispatch({ type: 'ADD_JOB', payload: job })
18      setJob({ ...job, title: '' })
19    }
20  }
21
22  return (
23    <div className="wrapper">
24      <h1>Add Job</h1>
25      <div>
26        <label>Title</label>
27        <input
28          className="border dark:text-black"
29          type="text"
30          value={job.title}
31          onChange={handleTitleChange}
32        />
33      </div>
34      <button className="btn bg-blue-400" onClick={handleAddJob}>
35        Add Job
36      </button>
37    </div>
38  )
39}

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.tsxtypescript
3type Props = {
4  job: Job | null
5  setSelectedJob: (job: Job | null) => void
6}

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.tsxtypescript
9  const dispatch = useJobsDispatch()
10
11  const handleRemoveJob = () => {
12    if (job) {
13      dispatch({ type: 'REMOVE_JOB', payload: job })
14      setSelectedJob(null)
15    }
16  }

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

./components/JobDetail.tsxtypescript
19    <div className="wrapper w-3/4">
20      {job ? (
21        <>
22          <h3 className="text-xl">Job Details</h3>
23          <p>Title: {job.title}</p>
24          <p>Is Active: {job.isActive ? 'Yes' : 'No'}</p>
25          <button className="btn bg-red-500" onClick={handleRemoveJob}>
26            Delete Job
27          </button>
28        </>
29      ) : (
30        <>
31          <p>Select a job.</p>
32        </>
33      )}
34    </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.tsxtypescript
1import { Job, useJobsDispatch } from '../contexts/JobsContext'
2
3type Props = {
4  job: Job | null
5  setSelectedJob: (job: Job | null) => void
6}
7
8export default function JobDetail({ job, setSelectedJob }: Props) {
9  const dispatch = useJobsDispatch()
10
11  const handleRemoveJob = () => {
12    if (job) {
13      dispatch({ type: 'REMOVE_JOB', payload: job })
14      setSelectedJob(null)
15    }
16  }
17
18  return (
19    <div className="wrapper w-3/4">
20      {job ? (
21        <>
22          <h3 className="text-xl">Job Details</h3>
23          <p>Title: {job.title}</p>
24          <p>Is Active: {job.isActive ? 'Yes' : 'No'}</p>
25          <button className="btn bg-red-500" onClick={handleRemoveJob}>
26            Delete Job
27          </button>
28        </>
29      ) : (
30        <>
31          <p>Select a job.</p>
32        </>
33      )}
34    </div>
35  )
36}

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.tsxtypescript
1import { useState } from 'react' 
2import { Job } from './contexts/JobsContext'
3import AddJob from './components/AddJob'
4import JobDetail from './components/JobDetail'
5import JobList from './components/JobList'
6
7export default function JobBoard() {
8  const [selectedJob, setSelectedJob] = useState<Job | null>(null)
9
10  return (
11    <div className="wrapper">
12      <div className="flex justify-between">
13        <h1 className="text-2xl">Job Board</h1>
14      </div>
15      <div className="flex">
16        <JobList setSelectedJob={setSelectedJob} />
17        <JobDetail job={selectedJob} setSelectedJob={setSelectedJob} />
18      </div>
19      <AddJob />
20    </div>
21  )
22}

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.tsxtypescript
6type Props = {
7  setSelectedJob: (job: Job) => void
8}
9...
10export default function JobList({ setSelectedJob }: Props) {
11...
12	 <li onClick={() => setSelectedJob(job)} key={job.title}>
13	 	{job.title}
14	 </li>
15...

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!

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.