How to type React Context with TypeScript – and why is it so painful?
How to type React Context with TypeScript – and why the React devs teach you incorrectly
The other day I was converting a JavaScript component to TypeScript and I got tripped up in one specific area: React Context.
Now don’t get me wrong, I’ve used TypeScript a million times before, but for some reason Context was not clicking with me. So I went back to the drawing board, looked up some tutorials, and familiarized myself with Context again.
That said, during this process I found some clean mental models and shortcuts that I found to make understanding React Context effortless.
That’s what I’m sharing here today.
The 3 Parts of React Context — Visualized
I like to think of Context as needing 3 parts. The official docs call these the createContext, producer, and consumer. But, I think it’s easier to think about these using the metaphor of just a normal variable.
The Context Creator
Think of this as the let myContext;
of your Context. It’s just creating the Context, but not filling it with any functionality yet.
The Context Provider
Think of this as the myContext = [];
of your Context. It fills the already created Context with some sensible default data.
The Context Consumer
Think of this as the myContext = [1,2,3];
or let myData = myContext[0]
. This is where you will manipulate the Context or read its data (hence the name “consumer”).
But… there’s a problem with these typings.
Since we’re using undefined
when we create the Context, we’re not getting good types when we go to consume it.
Every time we want to use the Context we have to check if it’s undefined first. This means if we want to use the addTodo
function, for example, we have to do myContext ? myContext.addTodo() : null
.
However, this check is kind of odd because if we’re using Context properly it should never be undefined. If the component consuming the Context is under the provider, then the Context will always have the defaults passed in from the provider.
The only reason we need to give it a default type of undefined
is if the consumer is not a child component of the Provider.
One solution I see a lot is to give more specific field-level defaults like this:
Notice that instead of setting the entire Context to undefined
by default, we instead plug in sensible defaults for each field. In one sense, this is better because we no longer need to check if it’s undefined when consuming.
That means code like this:
Can become this:
To explain this again using our variable metaphor: Before we were doing let myContext
, then myContext = []
. This is bad because you’re changing the type of the Context from undefined
to an array. Generally you don’t want to change the data type of something after it’s been created.
With this better typing, now we instead do let myContext = []
right off the bat. I’m sure you can see why this is better.
But this brings up another issue…
If we make this change, it’ll be easier to consume the Context, but what if you have a component trying to consume the Context that isn’t a child?
This component will now call the myContext.addTodo()
function, but instead of doing what it expects, it will just silently fail. That’s because the function just looks like this: () => {}
.
Now you might be thinking: “Well, you could change that function to log something to the console”. And you could. But, what if it’s not a function, but a field instead? How would you log to the console in that case?
So, that doesn’t fix the underlying issue.
The underlying issue is that React is allowing components to consume Context even if they’re not children (although it doesn’t do anything). Also, since React doesn’t explicitly throw an error when this happens, it makes typing things and managing their dependencies a lot harder.
Now personally, I think this is poor design on React’s part.
If a component tries to use a Context, but isn’t a child of that Context it should throw an explicit error and crash. That’s because this should only happen because of user error. Either the developer moved the component somewhere it shouldn’t be, or they moved the Context somewhere it shouldn’t be.
With the current way React handles this, you would have no idea that things aren’t working unless you went in functionally tested the component. If it threw an explicit error, you would know instantly when the page is rendered.
This also brings up one last issue:
Why do I have to explicitly define the types for my Context anyways? Shouldn’t the types be generated automatically based on what’s passed into the Provider?
Boom! If you’re thinking this, you’re right. But sadly, since Contexts are accessible in any component (not just children) you have to provide default values in-case it’s not a child. That’s because one of two scenarios is going to play out. Either:
(a) The Context consumer is a child of the Provider → It will use the data passed into the Provider.
(b) The Context consumer is non a child of the Provider → It will use the default data passed into the createContext
function.
So, how do we fix both of these issues?
The nice thing is that we can kill two birds with one stone here. And, the solution is simple:
If a component tries to consume a Context and isn’t a child of the provider, throw an explicit error.
If you do this, then Contexts can be typed automatically based on the props passed into the provider. That’s because no component will be using the Context outside of the scope of the Provider, so we don’t need to add typings for that use-case.
Additionally, we can now have an explicit guarantee that a component hasn’t been accidentally moved outside of the Context it needs.
So, there ya go. Two birds with one stone!
Implementing this is not difficult either. You can implement this addition on top of the current Context API or use a library. I personally use a library called unstated-next to implement these changes for me. The library is 40 lines long, and adds proper typing and explicit errors to Context.
If you want to learn more about this library, I actually wrote an entire article about using unstated-next for state management. Otherwise, feel free to use any methods discussed in this article to type your components. They’re all effective solutions, just with tradeoffs and risks that you’ll want to be aware of.