Command Palette

Search for a command to run...

Hectal
PHASE 14Intermediate ~23 min· topic 4 of 6

Topic 14.4

Typing Context and Custom Hooks

In one line

Context hands a value to every component below a provider, and the honest type of its default is T | null. A small guard hook removes the null for everyone else. Custom hooks need as const to return tuples and a type parameter to work for any value.

Think of it like this

A school notice board in the corridor. Every classroom can read it, but only if the board is actually hung up. If a teacher walks to the spot and finds an empty wall, that is a mistake to report loudly, not a reason to guess what the notice said. Context is the board, the provider hangs it up, and the guard hook is the teacher who says "there is no board here" instead of reading nonsense.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Context
A way to give a value to many components below a point in the tree without passing it through props.
Provider
The component that supplies a context's value to everything inside it.
Default value
What useContext returns when no provider is above. Often null in typed code.
Guard hook
A small hook that reads a context, throws a clear error if it is missing, and returns the non-null value.
Custom hook
Your own function whose name starts with use and which calls other hooks.
Tuple
An array with a fixed length where each position has its own type, like [boolean, () => void].
as const
Tells TypeScript to treat a value as exact and read-only, which turns [a, b] into a tuple.
Type parameter
A placeholder type, written <T>, that is filled in each time the hook or component is used.
Dispatch<SetStateAction<T>>
The type of a state setter: it takes a new value or a function from the old value to the new one.

Step by step

01Create the context with an honest type

Describe what consumers will get, then make the context hold that or null.

theme.tsxwhole filetsx
type Theme = "light" | "dark";
type ThemeContextValue = { theme: Theme; toggle: () => void };

const ThemeContext = createContext<ThemeContextValue | null>(null);

02The provider supplies a complete value

The provider builds a ThemeContextValue from state. Annotating the variable (const value: ThemeContextValue = ...) checks that nothing is missing. In React 19 you can render the context object itself as the provider: <ThemeContext value={value}>.

03The guard hook removes the null

The hook returns the narrowed value, and throws if there is none. Its declared return type is the non-null one.

After if (value === null) throw ..., TypeScript has narrowed value from ThemeContextValue | null to ThemeContextValue (Topic 4.3), so return value is fine.

theme.tsxwhole filetsx
function useTheme(): ThemeContextValue {
  const value = useContext(ThemeContext);
  if (value === null) {
    throw new Error("useTheme must be used inside <ThemeProvider>");
  }
  return value;
}

04Consumers get the full type

Any component can call const { theme, toggle } = useTheme(); and both names are typed, with no null check and no cast. Forget the provider and the first render stops with your message.

Consumers get the full typediagram
Rendering diagram…

05Hooks that return pairs need as const

Without as const, [on, toggle] is an array of boolean | (() => void): calling toggle() fails. With it, the result is a tuple and each name has its own type. The second example puts both side by side.

06Generic hooks

useStored<T> works for numbers, strings and arrays alike. The return type spells out the pair so callers can pass updater functions to the setter.

useStored.tswhole filets
function useStored<T>(key: string, initial: T): [T, Dispatch<SetStateAction<T>>] {
  // ...reads and writes localStorage, returns [value, setValue]
}

const [visits] = useStored("visits", 0);               // number
const [tags] = useStored<string[]>("tags", []);        // string[]

Try it yourself

  1. 1

    Predict the type of useTheme

    In the first example add const t = useTheme(); with // ^? in ThemeButton. Then change useTheme's declared return type to ThemeContextValue | null and watch theme and toggle turn red: that is the work the guard hook saves everyone.

  2. 2

    Fix the loose hook

    In the second example, add as const to the return of useToggleLoose. The two errors vanish and looseOpen becomes boolean. Then return [on, toggle, "extra"] as const and destructure only two names: there is no error, because a tuple may be destructured partly (see Topic 5.2).

  3. 3

    Make inference work

    In the third example, change useStored("guess", []) to useStored<number[]>("guess", []) and hover guess. Then add setGuess([1, 2]) to the click handler and setGuess(["a"]) to see an error.

Code & diagrams

A typed context: null default and a guard hook

The context's type includes null, but useTheme returns the non-null value, so ThemeButton has no null check. The script clicks the button once.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

Theme: light
Theme: dark
A hook returning a pair: loose array versus as const

useToggle's result is a tuple, so open is boolean and toggle is a function. The loose version is an array of boolean | (() => void), and with noUncheckedIndexedAccess (on in this course) each item may also be undefined, hence two errors on the same call. The page still runs; the first button toggles to open.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

open
A generic hook: useStored<T>

T is inferred as number from 0, written as string[] for tags, and wrongly inferred as never[] for guess: an empty array is no clue. The preview has an in-memory localStorage, so the stored value is printed at the end. Note JSON.parse(raw) as T is a cast.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

1 / react / 0
["react"]

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

Pretend the context always has a value

In the first example, change createContext<ThemeContextValue | null>(null) to createContext<ThemeContextValue>(null).

terminal
$ Types panel
── what you'll see ──
index.tsx(8,55): error TS2345: Argument of type 'null' is not assignable to parameter of type 'ThemeContextValue'.

Break #2

Forget the provider

In the first example, delete the <ThemeProvider> and </ThemeProvider>, lines around <ThemeButton />.

terminal
$ Console
── what you'll see ──
Uncaught Error: useTheme must be used inside <ThemeProvider>
An error occurred in the <ThemeButton> 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.
 
Uncaught TypeError: Cannot read properties of null (reading 'textContent')

Myth vs fact

Myth

Use createContext({} as Value) to avoid null checks.

Fact

That is a cast that lies. A missing provider then fails somewhere else with a confusing error. Use | null plus a guard hook.

Myth

Custom hooks must return an array or an object.

Fact

They can return anything. Use an object when there are more than two values (names beat positions); use a tuple with as const for a state-like pair.

Myth

JSON.parse(raw) as T makes the stored value a T.

Fact

It only tells the compiler to believe you. The runtime value is whatever was stored.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    A context default of undefined works as well as null; pick one and be consistent. Some teams use a factory, function createRequiredContext<T>(name: string), that returns [Provider, useContextHook] already guarded, so each context is two lines.

  • ▸

    In React 19, use(ThemeContext) reads context too, and can be called after an early return or inside a condition. It has the same type as useContext, so the guard-hook pattern is unchanged.

  • ▸

    A context value that is a new object on every render (as in the example) makes all consumers re-render whenever the provider does. Types don't help with that; useMemo does. In big apps split state and dispatch into two contexts, so components that only dispatch don't redraw on every change.

  • ▸

    as const on a returned array makes it readonly. That is what you want for a hook's pair (callers shouldn't push to it), but it means the result can't be passed to a function that expects a mutable T[].

Remember this

  1. 1

    The problem this solves: passing a value through many layers of props is tedious, and a shared value that might not exist yet forces a null check in every component. Typing context honestly, plus one guard hook, solves both. Context lets you pass a value to many components without writing it through every level of props. You create it with createContext, a provider component supplies the value to everything inside it, and any component below reads it with useContext. The value's type is the type argument of createContext.

  2. 2

    createContext requires a default value, which is used only when a component reads the context with no provider above it. The trouble: usually there is no meaningful default (what is the "current user" before one exists?). The tempting fix, createContext({} as ThemeContextValue), is a lie to the compiler: it claims the value is complete, and a forgotten provider then fails far from the cause with undefined is not a function. The honest type is createContext<ThemeContextValue | null>(null).

  3. 3

    With | null every consumer would have to check for null, which is noise. The standard fix is a guard hook that does the check once: useTheme() reads the context, throws a clear error if it is null ("useTheme must be used inside ThemeProvider"), and returns the non-null value. Its return type is written as ThemeContextValue, so every component that calls useTheme() gets the full object, with nothing to check. Never export the raw context if you can export the hook: the error message is a gift to your teammates.

  4. 4

    A custom hook is a function whose name starts with use and which calls other hooks. Its type is just a function type: parameters and a return type, mostly inferred. The one trap is returning a pair: return [on, toggle] is inferred as an array, (boolean | (() => void))[], so TypeScript forgets which position holds what. Add as const (Topic 4.2, Topic 5.1): return [on, toggle] as const is the tuple readonly [boolean, () => void], and destructuring gives each name its own type, exactly like useState.

  5. 5

    A hook that should work for any kind of value takes a type parameter (Topic 7.2): function useStored<T>(key: string, initial: T): [T, Dispatch<SetStateAction<T>>]. Callers get T inferred from initial (useStored("visits", 0) is a number), or write it when inference is too weak (useStored<string[]>("tags", []), because [] alone is never[]). Dispatch<SetStateAction<T>> is the type React uses for state setters; spelling it out in your return type makes your hook's setter accept both values and updater functions.

  6. 6

    A word of honesty about generic hooks that read outside data: JSON.parse(raw) as T in useStored is a cast, not a check. TypeScript trusts you that whatever was in storage has the shape T. If a different version of your app stored something else, nothing catches it. For anything that matters, validate at runtime (Topic 13.4, Topic 13.6).

Explain it without notes

01

How do you type a React context whose real value isn't available when it's created?

02

What does a guard hook do, and why is it better than exporting the context?

03

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

04

How would you type a custom hook that works for any kind of value?

Practice

01

Write a UserContext holding { name: string; logout: () => void } with a provider component and a guard hook useUser(). Show the error you get if the context is created as createContext<User>(null).

02

Write useCounter(initial = 0) returning the count and three functions, as an object. Then write useBoolean(initial) returning a typed pair.

Trade-offs

  • ↔

    A guard hook throws at runtime when the provider is missing; that is a loud failure during development, which is what you want, but remember it can also fire in tests that forget to wrap a component.

  • ↔

    Returning a tuple makes call sites short and lets callers rename values; returning an object is self-documenting and survives adding a third value without breaking anyone. Choose by how many values there are.

Done when you can

  • I can create a context whose type includes null and write a guard hook for it.

  • I can explain why {} as T for a context default is dangerous.

  • I can make a custom hook return a tuple with as const.

  • I can write a generic custom hook and say when to write the type argument.

  • I can name the cast inside a hook that parses stored JSON and why it isn't a check.