Topic 12.5
Typing Context and Custom Hooks
In one line
Create context as createContext<Value | null>(null) and read it through a guard hook that throws outside the provider, so callers never see null; return tuples from custom hooks as const, and make hooks generic when the caller decides the type.
Think of it like this
A school's lost-and-found desk. If you go to the desk in the school, you get help. If you look for the desk at home, there is no desk. You don't want to pretend there is one and get handed an empty box; you want a clear message: "the desk is at school". Context works like that desk: inside its provider you get a value; outside, a guard hook gives a clear error instead of a quietly empty value.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Context
- React's way to share a value with many components below a provider without passing props (Phase 10).
- Provider
- The component that supplies a context value:
<AuthContext value={...}>in React 19. - Default value
- The value
useContextreturns when there's no provider above. Set when you callcreateContext. - Guard hook
- A custom hook that reads a context and throws a clear error if the provider is missing, so callers never get
null. - Tuple
- A fixed-length array where each position has its own type:
[boolean, () => void]. - as const
- Tells TypeScript to infer the narrowest, read-only type: a tuple instead of an array, literals instead of
string. - Generic hook
- A custom hook with a type parameter, like
useStoredState<T>, so the caller decides the type. - Type parameter
- The blank in a generic, usually called
T, filled by inference or by<...>at the call.
Step by step
01Create the context with an honest type
There is no user before the app loads and no sensible fake, so the default is null and the type includes it.
type User = { name: string; role: "admin" | "member" };
export type AuthContextValue = {
user: User | null;
logIn: (name: string) => void;
logOut: () => void;
};
const AuthContext = createContext<AuthContextValue | null>(null);02See why raw useContext is awkward
Every reader would have to check for null, and calling a hook that returns an array without as const has its own trap. These are the real messages:
const theme = useContext(ThemeContext); // Theme | null
console.log(theme.mode);
// error TS18047: 'theme' is possibly 'null'.
function useToggle(initial: boolean) {
const [on, setOn] = useState(initial);
const toggle = () => setOn((v) => !v);
return [on, toggle]; // inferred: (boolean | (() => void))[]
}
const [open, toggleOpen] = useToggle(false);
toggleOpen();
// error TS2349: This expression is not callable.
// Not all constituents of type 'boolean | (() => void)' are callable.
// Type 'false' has no call signatures.03Write the guard hook
Check once, throw a helpful message, return the narrowed value. The hook's return type has no null in it.
export function useAuth(): AuthContextValue {
const ctx = useContext(AuthContext);
if (ctx === null) throw new Error("useAuth must be used inside <AuthProvider>");
return ctx; // narrowed: AuthContextValue
}04Provide a typed value
The provider owns the state and builds the value. Annotating value makes TypeScript check it right there.
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const value: AuthContextValue = {
user,
logIn: (name) => setUser({ name, role: "member" }),
logOut: () => setUser(null),
};
return <AuthContext value={value}>{children}</AuthContext>;
}05Return tuples with as const
as const turns the returned array into a tuple, so each position keeps its own type, just like useState.
export function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle] as const; // readonly [boolean, () => void]
}06Make a hook generic
Add a type parameter and use it for the argument and the result. Callers usually don't write it; TypeScript infers it from initial.
function useStoredState<T>(key: string, initial: T) {
const [value, setValue] = useState<T>(initial);
const save = (next: T) => setValue(next);
return [value, save] as const;
}
const [visits, setVisits] = useStoredState("visits", 0); // T = number
setVisits("ten");
// error TS2345: Argument of type 'string' is not assignable to parameter of type 'number'.
const [tags, setTags] = useStoredState("tags", []); // T = never[]
setTags(["a"]);
// error TS2322: Type 'string' is not assignable to type 'never'.
// Fix: useStoredState<string[]>("tags", [])Try it yourself
- 1
Add a role check
In the auth example, add
isAdmin: booleantoAuthContextValue. Predict: where doestsccomplain first? (Atconst value: AuthContextValue = {...}in the provider:Property 'isAdmin' is missing....) AddisAdmin: user?.role === "admin"and show an Admin badge inHeader. - 2
Return an object instead
Change
useToggletoreturn { on, toggle };and updateApptoconst { on: open, toggle: toggleOpen } = useToggle();. Predict the logs: unchanged. Which style do you prefer when a hook returns four values? - 3
Fix the missing provider
In the third example wrap
<Badge />in<ThemeContext value={{ mode: "dark" }}>. Predict the page (Mode: dark) and the console (empty).
Code & diagrams
`Header` destructures `useAuth()` directly: the guard hook's return type has no `null`. `user` itself can still be `null` (nobody logged in), and that is a real state the UI handles. `<AuthContext value={value}>` is the React 19 provider syntax; `<AuthContext.Provider>` is the older spelling.
Preview
Console
Nothing logged yet.Expected console output
Header sees: nobody
Header sees: Asha (admin)
Header sees: nobody`localStorage` in the preview is in memory and resets on each Run, so `visits` always starts at 0 here; in a real browser it would survive a reload. `JSON.parse(saved) as T` is a promise, not a check: if someone stored a string under `visits`, the type would lie (Topic 12.1). A production hook would validate it or catch parse errors.
Preview
Console
Nothing logged yet.Expected console output
open: false visits: 0 tags: []
open: true visits: 0 tags: []
open: true visits: 1 tags: []
open: true visits: 1 tags: [ 't0' ]
stored: 1 ["t0"]TypeScript can't know at compile time whether a provider will be above `Badge`, so the guard turns the mistake into a run-time error with a message that says exactly what to do. Wrap the app in `<ThemeContext value={{ mode: "dark" }}>` and it renders. Without the guard (and with a `{} as Theme` default), `mode` would silently be `undefined`.
Preview
Console
Nothing logged yet.Expected console output
Uncaught Error: useTheme must be used inside <ThemeContext value={...}>
An error occurred in the <Badge> component.
Consider adding an error boundary to your tree to customize error handling behavior.
Visit https://react.dev/link/error-boundaries to learn more about error boundaries.Break it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Fake the default with as
Write const AuthContext = createContext({} as AuthContextValue);, drop the guard, and render Header without AuthProvider. Click the Log in button.
Break #2
Return a tuple without as const
Remove as const from useToggle's return and call toggleOpen() in a handler.
Myth vs fact
Myth
A context default value is the value most components see.
Fact
Components inside a provider never see the default. It's only for components with no provider above them, which is usually a bug; that's why null plus a guard is the honest default.
Myth
Generic hooks need <T> at every call.
Fact
TypeScript infers T from the arguments. Write it only when the argument says too little ([], null) or you want a wider type than the value.
Myth
as const changes the value.
Fact
It only changes the inferred type (a read-only tuple or literals). The JavaScript that runs is identical.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
createContextin @types/react has a required parameter:createContext<T>(defaultValue: T). The comment in the types explains it was kept required on purpose, socreateContext<User>()is an error; passnulland widen the type. - ▸
A hook's inferred return type is part of its public API. For hooks shared across a codebase, an explicit return type (
): readonly [T, (next: T) => void]) keeps accidental changes in the hook's body from rippling out to callers as confusing errors far away. - ▸
The provider value object is a new object every render, so every consumer re-renders when the provider does. Wrap it in
useMemo(withuseCallbackfor the functions) or let the React Compiler memoise it (Topic 16.5); split state and actions into two contexts if consumers only need the actions (Topic 10.3). - ▸
Generic constraints keep generic hooks honest:
function useSortedList<T extends { id: string }>(items: T[])lets the hook readitem.idwhile still returning the caller's exact typeT[]. Topic 12.6 uses the same idea for components.
Remember this
- 1
**Typing
createContext.**createContext<T>(defaultValue)makes a context whose value has typeT. The default is used only when a component reads the context with no provider above it. Sometimes there is a sensible default (a theme can default to"light"): then type it normally,createContext<Theme>("light"). Often there isn't (the logged-in user, a cart with functions that change state): then say so honestly,createContext<AuthValue | null>(null). Avoid the shortcutcreateContext({} as AuthValue): theaslies, and a component outside the provider gets an empty object that crashes later, far from the cause. - 2
The guard hook. With a
nulldefault, everyuseContext(AuthContext)returnsAuthValue | null, so every reader would need a null check ('theme' is possibly 'null'.). Wrap it once in a custom hook:function useAuth() { const ctx = useContext(AuthContext); if (ctx === null) throw new Error("useAuth must be used inside <AuthProvider>"); return ctx; }. After the check, TypeScript narrowsctxtoAuthValue, so the hook returns a non-null value and callers writeconst { user } = useAuth()with no checks. If someone forgets the provider, they get one clear error message at render time. Export the hook and the provider, and keep the raw context private. - 3
Providing the value. In React 19 you render the context itself as the provider:
<AuthContext value={value}>(older code:<AuthContext.Provider value={value}>, which still works). TypeScript checksvalueagainst the context's type, so a missing function is an error at the provider. Build the value object with an annotation (const value: AuthValue = {...}) so mistakes show up where you write it. - 4
Custom hook return types. TypeScript infers a hook's return type, and that is usually what you want. One trap: returning an array
[on, toggle]infers(boolean | (() => void))[], an array whose items could be either, so the caller'stoggle()fails withThis expression is not callable.Add **as const** (return [on, toggle] as const) and TypeScript infers a tuple: a fixed-length array where each position has its own type,readonly [boolean, () => void]. Alternatively return an object{ on, toggle }, which needs noas constand is better when there are more than two values (callers pick by name). - 5
Generic hooks. A hook is generic when the caller decides the type:
function useStoredState<T>(key: string, initial: T). TypeScript infersTfrom the arguments at each call:useStoredState("visits", 0)makesT = number, sosetVisits("ten")is an error. When the argument says too little (an empty array), passTyourself:useStoredState<string[]>("tags", []), the same rule asuseState(Topic 12.3). Data hooks often work like this:useFetch<User>(url); remember that the type parameter there is a promise about the server, not a check (Topic 12.1). - 6
Typing values you pass around. Export the context value type so tests and other hooks can use it. When a hook must accept another hook's setter, use
Dispatch<SetStateAction<T>>. For context that changes often, split it into two contexts (state and actions) to reduce re-renders (Topic 10.3); the types split the same way. And if a provider's value is built inline, wrap it inuseMemo(or rely on the React Compiler, Topic 16.5) so consumers don't re-render on every parent render; types don't change that performance rule.
Explain it without notes
Why use createContext<Value | null>(null) instead of createContext({} as Value)?
What does a guard hook do, and how does it change the type callers see?
Why does a custom hook returning [value, setter] need as const?
How does TypeScript decide T in a generic hook like useStoredState<T>(key, initial)?
Practice
Create a typed CartContext with items: string[], add(item: string) and count: number, a CartProvider, and a guard hook useCart. Show the count in a header and an Add button in another component.
Write a generic hook useList<T>(initial: T[]) that returns { items, add, removeAt }, and use it with numbers and with objects.
Trade-offs
- ↔
A throwing guard hook catches a missing provider immediately, but it makes the component unusable without a provider, even in tests and Storybook; you wrap them in the provider there too. A real default value is better when one exists (a theme, a locale).
- ↔
Tuples (
as const) are short and let callers rename freely (const [open, setOpen]), but get confusing beyond two or three items. Objects are self-describing and easier to extend.
Done when you can
I can type a context with a
nulldefault when no real default exists.I can write a guard hook that throws outside the provider and returns a non-null type.
I can provide a typed context value with React 19's
<Context value>syntax.I return tuples from hooks
as const, or return objects.I can write a generic hook and know when to pass the type argument.