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

4

5

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.

Cicily answered 2/10, 2023 at 17:7 Comment(7)
FWIW, the documentation also shows repeating the default value. Given that, I think sticking it in a constant and reusing it (as you are) makes good sense.Orchid
This is the standard way to do it. But there's nothing stopping you from creating an abstraction that pulls out this logicAric
@Aric maybe I'll create a custom hook. But I'm not so sure how other components could access the context...Moriyama
@LaczkóÖrs - Perhaps something like const [ SomeContext, someValue, setSomeValue ] = useCustomContext(SomeContextWrapper); where the wrapper has the context and the default, etc.Orchid
@LaczkóÖrs I do this all the time. I have a factory fn that I use. I can show you if you wantAric
@Aric I would like you to share it!Moriyama
@T.J.Crowder Thank you! Not so sure how to implement something like that correctly.Moriyama
I
2

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.

Insurer answered 10/10, 2023 at 15:3 Comment(0)
A
4

You can write in typeScript like this:

SomeContext.tsx:

Define your context and no need to define default value here.

import React, { createContext, useContext, useState } from 'react';

export interface SomeContextType {
    someValue: string;
    setSomeValue: React.Dispatch<React.SetStateAction<string>>;
}

const SomeContext = createContext<SomeContextType | undefined>(undefined);

export const useSomeContext = () => {
    const context = useContext(SomeContext);
    if (context === undefined) {
        throw new Error('context not found');
    }
    return context;
};

SomeComponent.tsx:

Define your component that uses the context.

import { useSomeContext } from 'SomeContext';

const SomeComponent = () => {
    const { someValue, setSomeValue } = useSomeContext();

    // Rest of your component code
}

App.tsx

Wrap your application with the SomeContext.Provider.

import { SomeContext, SomeContextType } from 'SomeContext';

const App = () => {
    const defaultValue: SomeContext = {
        someValue: '',
        setSomeValue: () => {}
    };

    const [someValue, setSomeValue] = useState<string>(defaultValue.someValue);

    return (
        <SomeContext.Provider value={{ someValue, setSomeValue }}>
            // Rest of your code
        </SomeContext.Provider>
    );
}

I think this approach is commonly seen as the best way when working with React Context in TypeScript.

Amphibole answered 12/10, 2023 at 17:6 Comment(0)
A
2

Here is an abstraction that I use to create standardized contexts

import React, { createContext, useCallback, useContext, useMemo, useReducer } from 'react'

type initialCtx<T> = {
    state: T,
    updateState: (payload: Partial<T>) => void
}

function makeUseProvider<T extends Record<string, any>>(initialState: T) {
    const Context = createContext<initialCtx<T>>({
        state: initialState,
        updateState: () => null,
    })

    const Provider = (Component: React.FC<any>) => {
        const useContextProvider = () => {
            function reducer<T>(state: T, payload: Partial<T>) {
                return {
                    ...state,
                    ...payload,
                }
            }

            const [state, dispatch] = useReducer(reducer, initialState) as [T, initialCtx<T>["updateState"]]

            const updateState = useCallback((partialState: Partial<T>) => {
                dispatch(partialState)
            }, [])

            const resetState = useCallback(() => {
                dispatch(initialState)
            }, [dispatch])

            return useMemo(() => ({
                state,
                updateState,
                resetState,
            }), [state, updateState, resetState])
        }

        function ContextHOC<T>(props: T) {
            const { updateState, state, resetState } = useContextProvider()
            const ctx = {
                state,
                updateState,
                resetState,
            }

            return (
                <Context.Provider value={ctx}>
                    <Component {...props} />
                </Context.Provider>
            )
        }
        return ContextHOC
    }

    return {
        Provider,
        useProvider: () => useContext(Context),
    }
}

export default makeUseProvider

Then it is used like this. You can import useProvider to access the data and setter in the locations you need it

const { Provider, useProvider } = makeUseProvider({
    someValue: "",
    someFunction: () => { },
})

const Component = () => {
    const { state, updateState } = useProvider()
    return <div />
}

export default Provider(Component)

This function is a factory that abstracts out state management (via useReducer + Context). Invoke makeUseProvider with your initial state. That will be the initial value and does not need to be redefined anywhere else.

const { Provider, useProvider } = makeUseProvider({
    someValue: "",
    someFunction: () => { },
})

Provider is a higher order component. It is the same thing as wrapping a set of components in context. You wrap the topmost component with it and state management will be available in all children in the component hierarchy

const Table = Provider(() => {
  return <Row />
})

const Row = () => {
  const {state, updateState} = useProvider()
  return <div />
}

updateState accepts a subset of the defined data structure (the initial state of makeUseProvider) and merges the previous state with the new data.

Aric answered 2/10, 2023 at 17:34 Comment(7)
First of all, thank you for your answer! As someone new to Typescript this looks a bit horrifying. I need a little time to truly process what's going on. If it's not too big of an ask, you could please explain how to use makeUseProvider in a bit more detail? Also, a big concern for me, is that I'm building a library with components that need to use different instances of the same context (e.g. there is more than one Table component with its own context). Would your solution work for my use case?Moriyama
@LaczkóÖrs Yes it will. This is a genericized abstraction. Any problem that can be solved using context can be solved using makeUseProvider. I can go into more detail laterAric
thank you! I'm already grateful for your time and expertise!Moriyama
@LaczkóÖrs DoneAric
thanks! How do you export / inport userProvider()?Moriyama
@LaczkóÖrs It's just a function. You export it normally export const { Provider, useProvider } = makeUseProvider({})Aric
Thank you! I've been thinking a lot about your solution. I'm going to start a bounty, to see what other solutions might be there. I upvoted your answer, and if in the end, your solution fits best for my case you are going to get the bounty. And again, I'm immensely thankful for your answer and help!Moriyama
I
2

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.

Insurer answered 10/10, 2023 at 15:3 Comment(0)
T
1

SomeContext.ts No need to use defaultValue

import React from 'react';

export interface SomeContext {
  someValue: string;
  setSomeValue: React.Dispatch<React.SetStateAction<string>>;
}

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

In your SomeComponent.tsx

import React, { useState, useContext } from 'react';
import { SomeContext } from './SomeContext';

function SomeComponent() {
  const [someValue, setSomeValue] = useState<string>("");     
  return (
    <SomeContext.Provider value={{ someValue, setSomeValue }}>
      {/* ... */}
    </SomeContext.Provider>
  );
}

export default SomeComponent;

This approach can make your code more readable and maintainable.

Trucking answered 12/10, 2023 at 13:53 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.