Search code examples
reactjstypescriptreact-context

Best way to use React Context (with useState) in Typescript


I have code that looks something like this:

SomeContext.ts:

export interface SomeContext {
  someValue: string;
  someFunction: () => void;
}

export const defaultValue: SomeContext = {
  someValue: "",
  someFunction: () => {},
};

export const SomeContext = React.createContext<SomeContext>(defaultValue);

SomeComponent.tsx:

function SomeComponent() {
  const [someValue, setSomeValue] = useState(defaultValue.someValue);

  return (
    <SomeContext.Provider value={{ someValue, setSomeValue }}>
      {/*...*/}
    </SomeContext.Provider>
  );
}

The part that bugs me is that I have to use defaultValue to initialize both the context and the state that will control that context.

Isn't there a more straightforward way to create a context that is controlled by a state? What I mean by that is, isn't there a way to single-handedly initialize both the state and the context? What is considered best practice here?

I tried not to give someContext a default value, but then Typescript (maybe rightfully so) gives a warning.


Solution

  • I agree, having to define (and maintain) default state is annoying (especially when there are several state values). I usually take the following approach:

    import React, { PropsWithChildren, useCallback, useEffect, useState } from 'react';
    
    export interface SomeContextValue {
       someValue: string;
       someFunction: () => void;
    }
    
    // I generally don't export the Context itself.
    // I export 'SomeProvider' for creating the context and 'useSomeContext' hook to consume it.
    // That way, we can skip the type checking here and verify the actual values later (if necessary).
    const SomeContext = React.createContext<SomeContextValue>({} as SomeContextValue);
    
    // The provider is responsible for managing its own state.
    // If you want to reuse it in slightly different ways, pass some extra props to configure it.
    export const SomeProvider: React.FC<PropsWithChildren> = (props) => {
    
       // The state could be initialised via some default value from props...
       // const [someValue, setSomeValue] = useState(props.defaultValue);
    
       // ...or by some async logic inside a useEffect.
       const [someValue, setSomeValue] = useState<string>();
       useEffect(() => {
          loadSomeValue().then(setSomeValue);
       }, []);
    
       // wrapping the state-mutation function in useCallback is optional,
       // but it can stop unnecessary re-renders, depending on where the provider sits in the tree
       const someFunction = useCallback(() => {
          const nextValue = ''; // Some logic
          setSomeValue(nextValue);
       }, []);
    
       // We ensure the state value actually exists before letting the children render
       // If waiting for some data to load, we may render a spinner, text, or something useful instead of null
       if (!someValue) return null;
    
       return (
          <SomeContext.Provider value={{ someValue, someFunction }}>
             {props.children}
          </SomeContext.Provider>
       );
    };
    
    // This hook is used for consuming the context.
    // I usually add a check to make sure it can only be used within the provider.
    export const useSomeContext = () => {
       const ctx = React.useContext(SomeContext);
       if (!ctx) throw new Error('useSomeContext must be used within SomeProvider');
       return ctx;
    };
    

    Note: much of this boilerplate can be abstracted into a helper/factory function (much like @Andrew's makeUseProvider) but we found that made it more difficult for developers to debug. Indeed, when you yourself revisit the code in 6-months time, it can be hard to figure out what's going on. So I like this explicit approach better.