skip to Main Content

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.

2

Answers


  1. 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.

    Login or Signup to reply.
  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.

    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search