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
useContextreturns when no provider is above. Oftennullin 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
useand 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.
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.
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.
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.
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
Predict the type of useTheme
In the first example add
const t = useTheme();with// ^?inThemeButton. Then changeuseTheme's declared return type toThemeContextValue | nulland watchthemeandtoggleturn red: that is the work the guard hook saves everyone. - 2
Fix the loose hook
In the second example, add
as constto the return ofuseToggleLoose. The two errors vanish andlooseOpenbecomesboolean. Then return[on, toggle, "extra"] as constand destructure only two names: there is no error, because a tuple may be destructured partly (see Topic 5.2). - 3
Make inference work
In the third example, change
useStored("guess", [])touseStored<number[]>("guess", [])and hoverguess. Then addsetGuess([1, 2])to the click handler andsetGuess(["a"])to see an error.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
Theme: light
Theme: darkuseToggle'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
Console
Nothing logged yet.Expected console output
openT 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
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).
Break #2
Forget the provider
In the first example, delete the <ThemeProvider> and </ThemeProvider>, lines around <ThemeButton />.
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
undefinedworks as well asnull; 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 asuseContext, 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;
useMemodoes. In big apps split state and dispatch into two contexts, so components that only dispatch don't redraw on every change. - ▸
as conston a returned array makes itreadonly. 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 mutableT[].
Remember this
- 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 withuseContext. The value's type is the type argument ofcreateContext. - 2
createContextrequires 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 withundefined is not a function. The honest type iscreateContext<ThemeContextValue | null>(null). - 3
With
| nullevery consumer would have to check fornull, 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 isnull("useTheme must be used inside ThemeProvider"), and returns the non-null value. Its return type is written asThemeContextValue, so every component that callsuseTheme()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
A custom hook is a function whose name starts with
useand 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. Addas const(Topic 4.2, Topic 5.1):return [on, toggle] as constis the tuplereadonly [boolean, () => void], and destructuring gives each name its own type, exactly likeuseState. - 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 getTinferred frominitial(useStored("visits", 0)is anumber), or write it when inference is too weak (useStored<string[]>("tags", []), because[]alone isnever[]).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
A word of honesty about generic hooks that read outside data:
JSON.parse(raw) as TinuseStoredis a cast, not a check. TypeScript trusts you that whatever was in storage has the shapeT. 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
How do you type a React context whose real value isn't available when it's created?
What does a guard hook do, and why is it better than exporting the context?
Why does a custom hook returning [value, setter] need as const?
How would you type a custom hook that works for any kind of value?
Practice
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).
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
nulland write a guard hook for it.I can explain why
{} as Tfor 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.