Working with React Context in TypeScript
See Our Public Workshops:
Like any other technology, TypeScript can be very helpful or a giant pain in the neck. In a word, TypeScript very much embodies the notion of tradeoffs.
Perfectly good working code written in JavaScript will likely spit out loads of errors the moment you change its file extension to .ts
. At first this can be a headache, but you often come to realize that this is because potential edge cases you may not have accounted for are now front and center, so you have to deal with them head-on. In React, one thing I generally see first when refactoring a codebase to TypeScript is weakly-typed context.
NOTE: This blog post assumes that you are already familiar with writing types and interfaces in TypeScript, as well as the basics of React Context.
Let's look at a quick example. A RadioGroup
component is a great candidate for using React context to provide state to its input
elements:
import * as React from 'react'// First we create our contextconst RadioGroupContext = React.createContext()function RadioGroup({ name, legend, children }) {let [checked, setChecked] = React.useState(null)function handleChange(event) {let value = event.target.valuesetChecked(value)}return (<fieldset><legend>{legend}</legend>{/* Next we provide data to nested component with our context's provider */}<RadioGroupContext.Provider value={{ name, checked, handleChange }}>{children}</RadioGroupContext.Provider></fieldset>)}function RadioGroupItem({ value, children }) {// We can consume the data with React's useContext hooklet { name, checked, handleChange } = React.useContext(RadioGroupContext)return (<label><input type="radio" name={name} value={value} checked={checked === value} onChange={handleChange} /><span>{children}</span></label>)}export default function App() {// In our app, RadioGroupItem is used inside of RadioGroup// to ensure it has access to the context datareturn (<div className="App"><RadioGroup name="size" legend="Shirt Size"><RadioGroupItem value="small">Small</RadioGroupItem><RadioGroupItem value="medium">Medium</RadioGroupItem><RadioGroupItem value="large">Large</RadioGroupItem></RadioGroup></div>)}
This works beautifully with no issues as far as we can tell. Hooray! Now let's see what happens if we start refactoring to TypeScript. We'll turn on strict mode in our compiler options to see all of the possible problems we might face.
The first thing I see is a problem with our very first function call:
// Expected 1 arguments, but got 0.// An argument for 'defaultValue' was not provided.const RadioGroupContext = React.createContext()
createContext
expects to be initialized with a default value that is used in case some component tries to useContext
outside of the context provider tree. If RadioGroupItem
is used outside of RadioGroup
, we'll get a type error pointing back to the component:
// TypeError: Cannot read property 'name' of undefined.let { name, checked, handleChange } = React.useContext(RadioGroupContext)
When we don't set an initial value, context will be undefined when we try to use it outside of the provider. So how could we address this?
The obvious answer is to provide a default value. This will be a good time to write an interface for our context value type.
interface RadioGroupContextValue {// The name of the group that will be used for the name attribute for// each of our radio inputsname: string// The value of the radio input that is currently selectedchecked: string | null// The change handler that will be used for each inputhandleChange(event: React.ChangeEvent<HTMLInputElement>): void}const RadioGroupContext = React.createContext<RadioGroupContextValue>({name: '',checked: null,handleChange() {},})
And now we get rid of the TypeScript error! But wait…is that really what we want here? If another developer accidentally uses RadioGroupItem
outside of RadioGroup
, the app won't crash but we'll also end up with a read-only input that doesn't really work for a user.
What if in our case the error was actually more helpful? After all, we don't really want to use a Radio
outside of a RadioGroup
, and it'd be pretty confusing to encounter one that didn't work!
Well, we could go back to the way we had it and just suppress the error:
// @ts-ignoreconst RadioGroupContext = React.createContext<RadioGroupContextValue>()
Now our app will build, but it will crash if RadioGroupItem
is used incorrectly. This can be caught in a test case and handled before a crash ever gets exposed to a user.
We could also suppress the warning by using TypeScript's non-null assertion operator. This is a little less heavy-handed; it suppresses a specific error so the compiler can still catch other issues with this line of code.
const RadioGroupContext = React.createContext<RadioGroupContextValue>(null!)
These methods both work, but I think we can do better. One of TypeScript's strengths is that it puts potential problems in our face and forces us to confront them. The error we get when we try to use RadioGroupItem
outside of its context provider crashes the app, but it isn't very explicit about what exactly we did wrong. We can instead write a custom hook that throws our own error to give other developers a clearer path to quickly fixing the underlying bug.
// We explicitly allow `undefined` as a potential value here// to tell the compiler we plan to deal with it.const RadioGroupContext = React.createContext<RadioGroupContextValue | undefined>(undefined)function useRadioGroupContext() {let context = React.useContext(RadioGroupContext)// If context is undefined, we know we used RadioGroupItem// outside of our provider so we can throw a more helpful// error!if (context === undefined) {throw Error('RadioGroupItem must be used inside of a RadioGroup, ' + 'otherwise it will not function correctly.')}// Because of TypeScript's type narrowing, if we make it past// the error the compiler knows that context is always defined// at this point, so we don't need to do any conditional// checking on its values when we use this hook!return context}
Now in RadioGroupItem
we can swap out React.useContext
with our custom hook:
function RadioGroupItem({ value, children }) {let { name, checked, handleChange } = useRadioGroupContext()return (<label><input type="radio" name={name} value={value} checked={checked === value} onChange={handleChange} /><span>{children}</span></label>)}
Now any time RadioGroupItem
is used outside of its context provider, we'll get a much more helpful warning that makes it much easier to fix in the future. Instead of overruling TypeScript's error, we were forced to deal with it explicitly. We were able to think about why the error exists in the first place and improve upon it — which I think is better for both developers and users!