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