loading...
Cover image for Working with React context providers in Typescript

Working with React context providers in Typescript

mitchkarajohn profile image Dimitris Karagiannis ・4 min read

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! 🎉

Posted on by:

mitchkarajohn profile

Dimitris Karagiannis

@mitchkarajohn

Frontend engineer, working with the Javascripts and the HTMLs and the CSSs. Co-founder and former front end engineer at Sourcelair.

Discussion

markdown guide