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 AppcreateContext with TypeScript
First we need to create our ThemeContext
and give it a type of Theme
.
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.
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.
<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:
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:
.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
.
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
.
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
.
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.
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
:
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.
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.
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:
@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:
...
<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:
{
"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
.
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.
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
.
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.
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.
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
:
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.
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.
<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.
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.
<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:
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:
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.
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.
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.
<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:
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.
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.
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!