DEV Community

Cover image for Working with React context providers in Typescript
Dimitris Karagiannis
Dimitris Karagiannis

Posted on

Working with React context providers in Typescript

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 };
Enter fullscreen mode Exit fullscreen mode

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 };
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

we implicitly set the type of the provider value to undefined and thus doing

return (
    <AxiosContext.Provider value={axiosInstance}>
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

and then declare the type during the context initialisation like this:

const AxiosContext = React.createContext<ContextValue>(undefined);
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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')
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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())
);
Enter fullscreen mode Exit fullscreen mode

Our types definition can now also change to this:

type ProviderValue = AxiosInstance; 

type DefaultValue = AxiosInstance;

type ContextValue = DefaultValue | ProviderValue;
Enter fullscreen mode Exit fullscreen mode

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');
    },
  })
);
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
arnaldobadin profile image
bardonolado • Edited

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?

export type ProviderType = Authentication;
export type ContextType = ProviderType | undefined;

...

/* assert type */
function isProviderType(value: ContextType): value is ProviderType {
    return value !== undefined;
}

export function userProvider() {
    const value = React.useContext<ContextType>(ProviderContext);
    if (!isProviderType(value)) throw new Error("The component using the ...");
    return value;
}
Enter fullscreen mode Exit fullscreen mode

Would be this a good approach?

Collapse
 
mitchkarajohn profile image
Dimitris Karagiannis

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

Collapse
 
segebee profile image
Segun Abisagbo

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

Collapse
 
mitchkarajohn profile image
Dimitris Karagiannis

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)