Disclaimer π£
This post was originally a part of my other article, but, since it became quite big, I decided to break it into its own mini post.
Say we have a simple provider that takes an axios
instance as a prop and provides it to the rest of the application via context
import React from 'react';
const AxiosContext = React.createContext(undefined);
function AxiosProvider(props) {
const { children, axiosInstance } = props;
return (
<AxiosContext.Provider value={axiosInstance}>
{children}
</AxiosContext.Provider>
);
}
const useAxios = () => React.useContext(AxiosContext);
export { AxiosProvider, useAxios };
So, let's write this in TS:
import { AxiosInstance } from 'axios';
import React, { ReactNode } from 'react';
const AxiosContext = React.createContext(undefined);
export type Props = {
children: ReactNode;
axiosInstance: AxiosInstance;
};
function AxiosProvider(props: Props) {
const { children, axiosInstance } = props;
return (
<AxiosContext.Provider value={axiosInstance}>
{children}
</AxiosContext.Provider>
);
}
const useAxios = () => React.useContext(AxiosContext);
export { AxiosProvider, useAxios };
All is well now, right? We defined the Props
type, so we are good to go. Well, not exactly. This will not work right away, because when we did
const AxiosContext = React.createContext(undefined);
we implicitly set the type of the provider value to undefined
and thus doing
return (
<AxiosContext.Provider value={axiosInstance}>
will throw a TS error, since the value we are passing is of AxiosInstance
type, according to our Props
type declaration, but is also undefined
according to the context initialisation.
To fix this we declare a new type like this
export type ContextValue = undefined | AxiosInstance;
which can be further broken into
export type ProviderValue = AxiosInstance; // since you know this is what the provider will be passing
export type DefaultValue = undefined;
export type ContextValue = DefaultValue | ProviderValue;
and then declare the type during the context initialisation like this:
const AxiosContext = React.createContext<ContextValue>(undefined);
Now we let TS know that the context value can either be undefined
(the default value) or an AxiosInstance
(which is what will actually be returned by your provider). Now everything is ok then? Not yet, hang in there.
Because, now if we use the useAxios
hook inside another component and try to use the value it returns, we will get a TS error telling us that the return value of useAxios
can be undefined
since this is how we defined it when we initialised the AxiosContext
. How do we tackle this problem? We'll take a two-pronged approach.
A development time solution
As the programmer, we know that when we use the useAxios
hook, the value it will return will never be undefined
. It will always be of type ProviderValue
since we know that we are using the hook inside a component that is a child of the AxiosProvider
(because this is how we must use context hooks in order for them to work).
So, the fix here is simple, and it's a type assertion. When we use the useAxios
hook, we should always assert that its type is of ProviderValue
like so
import { useAxios, ProviderValue } from '<Path_to_AxiosProvider>'
function SomeComponent() {
const axiosInstance = useAxios() as ProviderValue;
// Do something with the axiosInstance object
}
and TS now knows that this is in fact an axios
instance object.
A runtime approach
The above solution just solves the issue during development. But what happens if a new developer comes along, who they don't know that in order to use a React context value the component using it must be a child of the Provider component? This is a case where the assertion we made above stops being true during runtime and the whole app crashes because we try to access stuff on an axiosInstance
that is undefined
.
We could add a
if(axiosInstance === undefined) {
throw new Error('The component using the the context must be a descendant of the context provider')
}
right after we do const axiosInstance = useAxios()
but in that case the type assertion we did earlier is useless and we also need to be writing this runtime check every time we make use of useAxios
.
The solution I've come up with for this is the following:
Use a Proxy as the default context value
Proxies are very useful in that they allow you to completely define the behaviour of a proxied object.
To elaborate, remember how we initialise our context, currently:
const AxiosContext = React.createContext<ContextValue>(undefined);
So, what if instead of undefined
we initialised the context with a Proxy
of a random axios
instance object? like so
const AxiosContext = React.createContext<ContextValue>(
new Proxy(axios.create())
);
Our types definition can now also change to this:
type ProviderValue = AxiosInstance;
type DefaultValue = AxiosInstance;
type ContextValue = DefaultValue | ProviderValue;
But this is still not enough. We want the app to throw in case the default context is used, with an appropriate error message (and we do not want to do this check every time we use the useAxios
hook, because we are lazy)
So, we simply define what we want to happen if the application code tries to access any members of this proxied axios
instance that we return as a default context value:
const AxiosContext = React.createContext<ContextValue>(
new Proxy(axios.create(), {
apply: () => {
throw new Error('You must wrap your component in an AxiosProvider');
},
get: () => {
throw new Error('You must wrap your component in an AxiosProvider');
},
})
);
apply
handles the behaviour when we try to call any methods from the proxied axios
instance object and get
handles the behaviour when we try to access any of its properties.
In conclusion
With the above approach we both keep Typescript satisfied and we also need to write the least code possible: Just a type assertion when we use the context hook and define the default context value as a proxy which throws if any code tries to access it.
Thanks for reading! π
Top comments (4)
I was looking exactly for this, really good article! I was thinking, what if you use type guards to assert default values in useProvider() function?
Would be this a good approach?
Yeah, that's definitely another approach to solve this problem! Depends on what you prefer, it's equally as valid, as far as I am concerned
Great article.
Wasn't comfortable with the developer time solution as it goes against typescript principles.
First time I'm hearing about Proxy. Thanks for sharing.
I think defining default values for the context type might work
Thank you, glad you found the article useful!
To be honest, if you do end up defining the Provider's default value with a proxy that follows the schema/type of a valid value, the type assertion when you use the hook is probably not necessary.
The types between default and valid values should match, and if it's a default value it would simply throw on runtime (due to the proxy default value)