Command Palette

Search for a command to run...

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

Topic 14.2

Typing State, Refs and Reducers

In one line

useState works out its type from the first value, which is right for numbers and wrong for "not loaded yet" or "one of a few words"; there you write the type yourself. Refs come in two kinds, and reducers get their safety from a union of actions.

Think of it like this

A whiteboard in a kitchen with a label on top: "Order status: waiting / cooking / ready". If someone writes "burnt" the cooks are confused. State with a type is the same whiteboard with a rule on it. The type says which words may ever be written there, and the compiler stops everyone else from writing anything different.

Words you'll meet

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

State
A value React remembers between renders; changing it redraws the component.
Setter
The function useState gives you to change the state, like setCount.
Type argument
The type you write in angle brackets, as in useState<string[]>([]), to tell TypeScript what the state holds.
Widening
TypeScript turning the exact value "idle" into the general type string because a variable might change.
Ref
A box React keeps between renders. Changing it does not redraw the page.
DOM ref
A ref that holds a real page element such as an input, null until React fills it.
Reducer
A function (state, action) => newState that holds all the rules for changing a piece of state.
Action
An object describing what happened, such as { type: "increment" }, sent to the reducer with dispatch.
Discriminated union
A union of object types that each have the same field with a different literal value (type), so one check tells them apart.

Step by step

01Let inference work: numbers and simple values

useState(0) is enough. The value 0 is a number, the state is a number, and the setter takes a number or a function from number to number.

Hover the names in the first example: count is number.

02Write the type when the first value is not enough

Three cases need a type argument: a few allowed words, "nothing yet", and an empty list.

state.tsxwhole filetsx
const [status, setStatus] = useState<"idle" | "saving" | "done">("idle");
const [user, setUser] = useState<User | null>(null);
const [tags, setTags] = useState<string[]>([]);

03See what happens without the type argument

useState(null) is stuck on null. The setter's parameter becomes null or a function that returns null, so the setter accepts null or a function (prevState: null) => null. An object matches neither, and the error for setUser({ name: "Asha" }) is reported against the function half, which is why it mentions (prevState: null) => null.

useState("idle") is just string, so a typo like "savng" is allowed. The second example shows both.

04Refs: two jobs, two shapes

A DOM ref starts as null. A value ref starts as a real value and may be changed at any time.

refs.tsxwhole filetsx
const inputRef = useRef<HTMLInputElement>(null); // RefObject<HTMLInputElement | null>
const clicks = useRef(0);                         // RefObject<number>

inputRef.current?.focus(); // must handle null
clicks.current += 1;       // fine: a number

05Reducer: describe every action

Write the state type, then the action union, then the reducer. Each case sees only its own action.

The default branch receives never: if every case is handled, nothing is left to arrive there.

Reducer: describe every actiondiagram
Rendering diagram…

Try it yourself

  1. 1

    Fix the two bad states

    In the second example, change the first line to useState<{ name: string } | null>(null) and the second to useState<"idle" | "saving">("idle"). Predict which errors appear now: the object is accepted, and "savng" is rejected.

  2. 2

    Dispatch something that doesn't exist

    In the reducer example, add dispatch({ type: "double" }); inside a button handler. Read the error, then add | { type: "double" } to Action and read the new error inside the reducer, where assertNever complains.

  3. 3

    Ask for the ref's type

    In the refs example, change useRef<HTMLInputElement>(null) to useRef(null) and hover inputRef with // ^?. The type becomes RefObject<null>: the input still accepts it, but inputRef.current?.focus() now fails with Property 'focus' does not exist on type 'never'. A ref without its element type can only ever hold null.

Code & diagrams

useState: inferred numbers and written-down unions

count was inferred; status and user needed a type argument. The checker lists the union's members in its own order ("done" first), which is not the order you wrote. The script clicks Save so the Console shows both renders.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

idle / 0 / nobody
saving / 1 / Asha
When inference is too narrow or too wide

Two opposite problems. user is stuck on null, so storing an object is an error. status is just string, so the typo "savng" is accepted and reaches the page. Fix both with type arguments.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

savng
Refs: a DOM ref and a value ref

inputRef.current is HTMLInputElement | null, so the if is required. clicks.current is a plain number you can add to. After the click the page's active element is the input.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

focused, clicks: 1
INPUT
useReducer with a discriminated union of actions

The reducer's two parameter types are the only types you wrote; useReducer infers the rest, including dispatch. The script clicks +, then sets the step to 10 and clicks + again: 5, then 15.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

5
15

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

Use a DOM ref without checking for null

In the refs example, replace the line if (inputRef.current) { with a plain { (keep the closing brace), so inputRef.current.focus() runs unchecked.

terminal
$ Types panel
── what you'll see ──
index.tsx(13,7): error TS18047: 'inputRef.current' is possibly 'null'.

Break #2

Add an action and forget to handle it

In the reducer example, add one more member to the union: move the ; and add | { type: "double" }; on a new line after | { type: "reset" }.

terminal
$ Types panel
── what you'll see ──
index.tsx(28,26): error TS2345: Argument of type '{ type: "double"; }' is not assignable to parameter of type 'never'.

Myth vs fact

Myth

useState always works out its own type, so I never write it.

Fact

It only reads the first value. null, [], "idle" and undefined are the cases where the first value says too little or too much; write the type argument there.

Myth

useRef is only for DOM elements.

Fact

It is any mutable box that survives renders: a timer id, a counter, the last value. The DOM ref is one use, with a null start.

Myth

Use a type assertion (as) to silence the ref's null.

Fact

The null is real for the first render. An assertion hides the bug instead of handling it. Check, or use ?..

Pro corner

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

  • ▸

    useState is declared with overloads: useState<S>(initial: S | (() => S)), and a no-argument form useState<S = undefined>() whose state is S | undefined. useState<string>() therefore holds string | undefined, which is a neat way to say "not chosen yet".

  • ▸

    In @types/react 19 there is one RefObject<T> with a mutable current: T. The | null in RefObject<HTMLInputElement | null> is added by a convenience overload of useRef<T>(initialValue: T | null), which is the one useRef<HTMLInputElement>(null) matches. In @types/react 18 RefObject.current was readonly for DOM refs; code that assigned to it needed a different overload.

  • ▸

    useReducer infers its action type from the reducer's rest parameters (A extends AnyActionArg, a tuple), so a reducer taking (state, action) gives ActionDispatch<[action: Action]>. Reducers with several arguments are possible but rare.

  • ▸

    Don't mirror props into state (useState(props.value)): the type is right and the design is wrong, because the state won't follow later prop changes. Types can't catch design bugs like that; derive the value during render instead.

Remember this

  1. 1

    The problem this solves: a wrong value in state, such as a typo in a status word or an object stored where null was expected, shows up as a broken page. Typing the state makes the compiler list every allowed value. useState(initialValue) is a generic function (Topic 7.2): TypeScript infers the state's type from the initial value you pass. useState(0) gives [number, Dispatch<SetStateAction<number>>]. The second item, the setter, accepts either a new value or a function that turns the old value into the new one (setCount((c) => c + 1)), and TypeScript types both.

  2. 2

    Inference reads the initial value only, and that is sometimes too little. useState("idle") is a string, not "idle", because state will change, so TypeScript widens the literal (Topic 1.3): a typo like setStatus("savng") is accepted. useState(null) is the type null forever, so no object can ever be stored. And useState([]) is never[]: an array that can hold nothing. Whenever the initial value is not a good example of every value the state will have, write the type argument: useState<"idle" | "saving" | "done">("idle"), useState<User | null>(null), useState<string[]>([]).

  3. 3

    The most common shape is "nothing yet": useState<User | null>(null). Every read must handle null first, and the compiler enforces it with narrowing (user ? user.name : "nobody", Topic 4.3). That is not a nuisance, it is the bug report you would otherwise get from a customer whose page said Cannot read properties of null. For several fields that change together, or loading/error/success, a union of objects (Topic 14.6) is better than many separate useState calls.

  4. 4

    A ref is a box React keeps for you between renders, and changing it does not redraw the page. Refs come in two jobs. A DOM ref points at a real element: useRef<HTMLInputElement>(null) has type RefObject<HTMLInputElement | null>. React fills .current after the first render, so before that it is null, and TypeScript makes you check (if (inputRef.current) ... or inputRef.current?.focus()), because .current.focus() on null crashes. You choose the element type from the tag: HTMLInputElement for <input>, HTMLDivElement for <div> (Topic 13.1).

  5. 5

    A value ref is a plain mutable box: useRef(0) has type RefObject<number>, and you write clicks.current += 1. Use it for things the screen doesn't show, like a timer id (useRef<number | null>(null)), the previous value, or a flag. In React 19's types useRef always needs an argument; there is no more useRef() with nothing.

  6. 6

    A reducer (useReducer) keeps state in one function: (state, action) => newState. The type of that function is the whole contract. Describe the actions as a discriminated union (Topic 4.5): each action is an object with a literal type field, and extra data only on the actions that need it ({ type: "setStep"; step: number }). Inside a switch (action.type), TypeScript narrows action, so in case "setStep" you can read action.step and nowhere else.

  7. 7

    useReducer(reducer, initialState) infers everything from the reducer's parameter types; you don't pass generics. dispatch then has type ActionDispatch<[action: Action]>, so dispatch({ type: "double" }) is an error, and dispatch({ type: "setStep" }) (no step) is an error. Finish the switch with an **assertNever** default (Topic 4.6): when someone adds a new action and forgets to handle it, the reducer stops compiling instead of silently ignoring it.

Explain it without notes

01

When does useState need a type argument, and when does inference do the job?

02

How do you type a ref to an input element, and why do you have to check for null?

03

How do you type a reducer so that adding an action is safe?

04

What is the difference between useRef<HTMLInputElement>(null) and useRef(0) in the types?

Practice

01

Write a Todos component with a list of strings in state and an add(text) function. Show why useState([]) doesn't work and what to write instead.

02

Write a reducer for a toggle with actions { type: "on" }, { type: "off" } and { type: "flip" }. Show what happens to the reducer when you add { type: "reset" } to the union.

Trade-offs

  • ↔

    A reducer adds a few lines (action union, switch) compared with several useState calls. It pays off when state changes in more than three or four ways, or when changes depend on each other; for a single toggle, useState<boolean> is clearer.

  • ↔

    Writing useState<T> by hand is explicit and safe, but a wrong T is a lie that nobody checks. Keep the type close to what the state really holds, and use unions instead of string.

Done when you can

  • I can say when useState infers correctly and when I must write the type.

  • I can type "nothing yet" state with T | null and handle the null.

  • I can type a DOM ref and explain why .current can be null.

  • I can tell a DOM ref from a value ref by how it is created.

  • I can write a reducer with a discriminated-union action and an assertNever default.