Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ~26 min· topic 5 of 6

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 useContext returns when there's no provider above. Set when you call createContext.
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.

auth.tsxwhole filetsx
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:

App.tsxwhole filetsx
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.

auth.tsxwhole filetsx
export function useAuth(): AuthContextValue {
  const ctx = useContext(AuthContext);
  if (ctx === null) throw new Error("useAuth must be used inside <AuthProvider>");
  return ctx; // narrowed: AuthContextValue
}
Write the guard hookdiagram
Rendering diagram…

04Provide a typed value

The provider owns the state and builds the value. Annotating value makes TypeScript check it right there.

auth.tsxwhole filetsx
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.

useToggle.tswhole filetsx
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.

useStoredState.tswhole filetsx
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. 1

    Add a role check

    In the auth example, add isAdmin: boolean to AuthContextValue. Predict: where does tsc complain first? (At const value: AuthContextValue = {...} in the provider: Property 'isAdmin' is missing....) Add isAdmin: user?.role === "admin" and show an Admin badge in Header.

  2. 2

    Return an object instead

    Change useToggle to return { on, toggle }; and update App to const { on: open, toggle: toggleOpen } = useToggle();. Predict the logs: unchanged. Which style do you prefer when a hook returns four values?

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

Context with a null default and a guard hook React 19+ New tab

`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

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Header sees: nobody
Header sees: Asha (admin)
Header sees: nobody
A tuple hook with as const and a generic hook New tab

`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

Press Run to see the page.

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"]
Forgetting the provider: the guard hook's clear error New tab

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

Press Run to see the page.

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.

terminal
$ Browser console
── what you'll see ──
Uncaught TypeError: logIn is not a function

Break #2

Return a tuple without as const

Remove as const from useToggle's return and call toggleOpen() in a handler.

terminal
$ npx tsc --noEmit
── what you'll see ──
error TS2349: This expression is not callable.
Not all constituents of type 'boolean | (() => void)' are callable.
Type 'false' has no call signatures.

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.

  • ▸

    createContext in @types/react has a required parameter: createContext<T>(defaultValue: T). The comment in the types explains it was kept required on purpose, so createContext<User>() is an error; pass null and 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 (with useCallback for 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 read item.id while still returning the caller's exact type T[]. Topic 12.6 uses the same idea for components.

Remember this

  1. 1

    **Typing createContext.** createContext<T>(defaultValue) makes a context whose value has type T. 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 shortcut createContext({} as AuthValue): the as lies, and a component outside the provider gets an empty object that crashes later, far from the cause.

  2. 2

    The guard hook. With a null default, every useContext(AuthContext) returns AuthValue | 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 narrows ctx to AuthValue, so the hook returns a non-null value and callers write const { 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. 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 checks value against 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. 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's toggle() fails with This 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 no as const and is better when there are more than two values (callers pick by name).

  5. 5

    Generic hooks. A hook is generic when the caller decides the type: function useStoredState<T>(key: string, initial: T). TypeScript infers T from the arguments at each call: useStoredState("visits", 0) makes T = number, so setVisits("ten") is an error. When the argument says too little (an empty array), pass T yourself: useStoredState<string[]>("tags", []), the same rule as useState (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. 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 in useMemo (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

01

Why use createContext<Value | null>(null) instead of createContext({} as Value)?

02

What does a guard hook do, and how does it change the type callers see?

03

Why does a custom hook returning [value, setter] need as const?

04

How does TypeScript decide T in a generic hook like useStoredState<T>(key, initial)?

Practice

01

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.

02

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