Command Palette

Search for a command to run...

Hectal
PHASE 12Intermediate ~29 min· topic 3 of 6

Topic 12.3

Typing State, Refs and Reducers

In one line

Let useState infer simple types, but give it a type argument when the start value is null or an empty array; type DOM refs as useRef<HTMLInputElement>(null); and type reducer actions as a discriminated union so a switch with a never check proves every action is handled.

Think of it like this

Labelled jars in a kitchen. A jar that already holds sugar is obviously the sugar jar. But an empty jar tells you nothing: is it for rice or for coffee? So you stick a label on it before it's filled: "rice". useState(0) is a jar that already holds a number. useState(null) and useState([]) are empty jars, and you must write the label yourself: useState<User | null>(null), useState<string[]>([]).

Words you'll meet

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

Generic
A function or type with a blank for a type, filled in with angle brackets: useState<string[]>([]).
Type argument
The type you put in the angle brackets of a generic.
Narrowing
TypeScript working out a more precise type inside an if, switch or after an early return.
SetStateAction
The type of what a state setter accepts: a new value, or a function from old value to new value.
Dispatch
React's type for a function that takes one argument and returns nothing, like a setter or dispatch.
HTMLInputElement
The browser's type for an <input> element. Every tag has one (HTMLDivElement, HTMLButtonElement...).
RefObject
The type of a ref made by useRef: an object with a current property.
Discriminated union
A union of object types that share a property (like type) with a different literal value in each.
never
The type with no possible values. A variable of type never can only exist in code that can't run.
Exhaustive check
Code that makes the compiler prove every case of a union is handled.

Step by step

01Let simple state infer itself

A number, string, boolean or filled object as the start value tells TypeScript everything. Don't add a type argument.

Counter.tsxwhole filetsx
const [count, setCount] = useState(0);       // number
const [name, setName] = useState("");        // string
const [open, setOpen] = useState(false);     // boolean

setCount((c) => c + 1); // c is a number
setName(42);
// error TS2345: Argument of type 'number' is not assignable to parameter of type 'SetStateAction<string>'.

02Label the empty jars

null and [] say nothing about what will go in later. Here are the real errors when you forget, and the fix.

Profile.tsxwhole filetsx
const [user, setUser] = useState(null);
setUser({ id: 1, name: "Asha" });
// error TS2353: Object literal may only specify known properties, and 'id' does not exist in type '(prevState: null) => null'.

const [tags, setTags] = useState([]);
setTags(["new"]);
// error TS2322: Type 'string' is not assignable to type 'never'.

// Fix: pass the type argument.
const [user2, setUser2] = useState<User | null>(null);
const [tags2, setTags2] = useState<string[]>([]);

03Narrow before you use it

Reading a property of possibly-null state is an error until you check. An early return is the cleanest check.

Profile.tsxwhole filetsx
const [current, setCurrent] = useState<User | null>(null);
console.log(current.name);
// error TS18047: 'current' is possibly 'null'.

if (current === null) return <p>Loading…</p>;
return <h3>{current.name}</h3>; // OK: current is a User here
Narrow before you use itdiagram
Rendering diagram…

04Type DOM refs and value refs

A DOM ref starts as null and gets the element after the commit. A value ref holds whatever you store. React 19 requires the starting value.

Draft.tsxwhole filetsx
const inputRef = useRef<HTMLInputElement>(null);  // RefObject<HTMLInputElement | null>
inputRef.current.focus();
// error TS18047: 'inputRef.current' is possibly 'null'.
inputRef.current?.focus(); // OK

const timer = useRef<number>();
// error TS2554: Expected 1 arguments, but got 0.
const timer2 = useRef<number | null>(null); // OK

05Describe actions as a discriminated union

Each action is an object with a type and the data that action needs. The union lists them all.

todos.tswhole filetsx
type Action =
  | { type: "added"; text: string }
  | { type: "toggled"; id: number }
  | { type: "cleared" };

dispatch({ type: "added" });
// error TS2345: Argument of type '{ type: "added"; }' is not assignable to parameter of type 'Action'.
//   Property 'text' is missing in type '{ type: "added"; }' but required in type '{ type: "added"; text: string; }'.

06Make the switch exhaustive

Assign action to a never variable in default. Add a new action without a case, and the compiler points straight at the reducer.

todos.tswhole filetsx
default: {
  const unhandled: never = action;
  throw new Error("Unknown action: " + JSON.stringify(unhandled));
}

// After adding  | { type: "renamed"; id: number; text: string }  to Action:
// error TS2322: Type '{ type: "renamed"; id: number; text: string; }' is not assignable to type 'never'.
Make the switch exhaustivediagram
Rendering diagram…

Try it yourself

  1. 1

    Break the empty array

    In the first example change useState<string[]>([]) to useState([]). Predict: does the preview still work? (Yes, types are stripped.) What would tsc say about [...t, "tag" + ...]? (Type 'string' is not assignable to type 'never'.)

  2. 2

    Add a removed action

    In the reducer example add | { type: "removed"; id: number }, a case "removed" that filters the todo out, and a button that removes id 2. Predict the extra log line. Then delete the case and say which line tsc would complain about.

  3. 3

    Cancel twice

    In the ref example, call click("cancel") right after the first click("cancel"). Predict the console: does cancelled print twice? (No: after the first cancel, timerRef.current is null.)

Code & diagrams

useState: inferred, null until loaded, and a typed empty array New tab

The first render shows the loading state, because `user` is `null`. After the fake request, `user` is a `User` and the early `return` no longer runs. `setTags((t) => [...t, ...])` type-checks because `t` is `string[]`; with `useState([])` it would be `never[]` and the same line would be an error.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

render: loading
render: Asha likes 0 tags []
render: Asha likes 1 tags []
render: Asha likes 1 tags [ 'tag1' ]
A DOM ref and a value ref, both typed New tab

`window.setTimeout` returns a `number` in the browser types. Plain `setTimeout` can be typed as Node's `Timeout` object when `@types/node` is installed, so portable code writes `useRef<ReturnType<typeof setTimeout> | null>(null)`. The second Cancel does nothing, because `timerRef.current` is `null` again.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

focused: true
cancelled
saved
Status: saved
useReducer with a discriminated union and an exhaustive switch New tab

Each button dispatches one action, and the log shows the list after each render. Add `| { type: "renamed"; id: number; text: string }` to `Action` without a `case`: the preview still runs (types are stripped), but `tsc` reports `Type '{ type: "renamed"; id: number; text: string; }' is not assignable to type 'never'.` at the `unhandled` line.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

(empty)
[ ] milk
[ ] milk, [ ] bread
[x] milk, [ ] bread
[ ] bread
Passing a setter to a child: Dispatch and SetStateActiontsx
import { useState, type Dispatch, type SetStateAction } from "react";

type FilterProps = {
  value: string;
  onChange: Dispatch<SetStateAction<string>>; // exactly the type of setQuery
};

function Filter({ value, onChange }: FilterProps) {
  return <input value={value} onChange={(e) => onChange(e.target.value)} />;
}

function Page() {
  const [query, setQuery] = useState("");
  return <Filter value={query} onChange={setQuery} />;
}

// Often clearer: a plain callback type that hides that it's a setter.
// type FilterProps = { value: string; onChange: (value: string) => void };

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 the non-null assertion to silence the checker

Write inputRef.current!.focus() inside the component body (during render) instead of in a handler.

terminal
$ Browser console
── what you'll see ──
Uncaught TypeError: Cannot read properties of null (reading 'focus')

Break #2

Type the reducer's action as string

Write function reducer(state: State, action: { type: string; text?: string; id?: number }) and dispatch { type: "add", text: "milk" } (note the typo).

terminal
$ Preview and tsc
── what you'll see ──
tsc: no errors. The page: clicking Add does nothing, and no message appears anywhere (or your default branch throws `Unknown action`).

Myth vs fact

Myth

You should always write the type argument for useState.

Fact

Only when the start value doesn't describe the state: null, undefined, [], or a union like "idle" | "loading" that starts as one member. Otherwise inference is shorter and just as safe.

Myth

The never check is just for show.

Fact

It is the only thing that makes the compiler fail when a new action is added but a reducer isn't updated. It costs nothing at run time and turns a silent bug into a list of errors.

Myth

useRef<HTMLInputElement>(null) means current is always an input.

Fact

Its type is HTMLInputElement | null. Before mount, after unmount, or when the element is conditionally hidden, current is null, and the type makes you handle that.

Pro corner

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

  • ▸

    React 19's useRef overloads: useRef<T>(initialValue: T): RefObject<T>, useRef<T>(initialValue: T | null): RefObject<T | null> and useRef<T>(initialValue: T | undefined): RefObject<T | undefined>. MutableRefObject is deprecated; RefObject.current is no longer read-only. The required argument was added so the "undefined until set" case is explicit.

  • ▸

    React 19's types changed useReducer to infer from the reducer: useReducer<S, A extends AnyActionArg>(reducer: (prevState: S, ...args: A) => S, initialState: S). Old code that passed useReducer<Reducer<State, Action>>(...) now fails with Expected 2 type arguments, but got 1. Remove the type argument and annotate the reducer instead. dispatch is typed ActionDispatch<[Action]>.

  • ▸

    For status values, a union like useState<"idle" | "loading" | "error">("idle") beats several booleans (isLoading, isError), because impossible combinations can't be expressed. Combined with a discriminated union of state objects ({ status: "success"; data: User } | { status: "error"; error: string }), the data only exists in the branch where it is valid (Topic 14.2).

  • ▸

    Narrowing doesn't survive into callbacks for let variables or object properties that could change, so TypeScript sometimes forgets a check inside a closure. Copy the narrowed value into a const first (const u = user;) when a callback needs it.

Remember this

  1. 1

    Inference first. useState(0) gives count: number and a setter that only accepts numbers (or a function from number to number). useState("") gives string, useState(false) gives boolean, and useState({ name: "Asha", age: 9 }) gives that object shape. Don't write useState<number>(0): it says the same thing twice. The setter's type is Dispatch<SetStateAction<number>>: SetStateAction<S> means "a new S, or a function (prev: S) => S", and Dispatch<A> means "a function that takes an A and returns nothing". You'll use those names when you pass a setter to a child as a prop.

  2. 2

    Give a type argument when the start value says too little. useState(null) infers the type null, so the state can never hold anything else, and setUser(user) fails with a confusing message. useState([]) infers never[] (an array of nothing), so adding any item fails with Type 'string' is not assignable to type 'never'. The fix is a generic type argument (a type passed in angle brackets): useState<User | null>(null) and useState<string[]>([]). A generic is a function or type with a type-shaped blank to fill in; useState<S> is one.

  3. 3

    Null state forces you to check. With User | null, TypeScript won't let you read user.name until you've ruled out null: 'user' is possibly 'null'. An if (user === null) return <p>Loading…</p>; above the JSX narrows the type: below that line TypeScript knows user is a User. Narrowing means the checker uses your if, ===, typeof and early return to work out a more precise type in each branch. This is exactly the loading state every data-fetching component needs (Phase 14).

  4. 4

    Refs to DOM elements: useRef<HTMLInputElement>(null) gives a RefObject<HTMLInputElement | null>. HTMLInputElement is the browser's own type for <input> (others: HTMLDivElement, HTMLButtonElement, HTMLCanvasElement). React puts the node in .current after the commit, but TypeScript knows it can be null (before mount, after unmount, or if the element is conditionally rendered), so you write inputRef.current?.focus() or check first. For refs that hold values (a timer id, a previous value) write the full type: useRef<number | null>(null). React 19's types changed two things: useRef needs an argument (useRef<number>() is now an error), and every ref object is mutable, so ref.current = 5 is always allowed.

  5. 5

    Reducers with a discriminated union. A discriminated union is a union of object types that share one property (the discriminant, usually type) whose value is a different string literal in each member: { type: "added"; text: string } | { type: "toggled"; id: number }. Inside switch (action.type), each case narrows action to one member, so action.text is only allowed in the "added" case. Dispatching { type: "add" } (a typo) or forgetting a field is an error at the dispatch call. Annotate the reducer's parameters and return type; useReducer infers the rest. In React 19's types, don't pass type arguments to useReducer the old way (useReducer<Reducer<State, Action>>): it is now an error.

  6. 6

    **Exhaustive switch with never.** never is the type with no values. If every member of the union has a case, then in the default branch action has type never. Writing const unhandled: never = action; there costs nothing at run time, but when someone adds a new action type and forgets its case, that line becomes an error: Type '{ type: "renamed"; ... }' is not assignable to type 'never'. The compiler now lists every reducer you must update. (Turning on noFallthroughCasesInSwitch also catches a missing return or break between cases.)

Explain it without notes

01

When does useState need a type argument, and what goes wrong without one?

02

How do you type a ref to an input, and why is current possibly null?

03

What is a discriminated union, and why is it a good fit for reducer actions?

04

How does the never trick make a switch exhaustive?

Practice

01

Write a Counter reducer with actions { type: "increment" }, { type: "decrement" } and { type: "set"; value: number }, with an exhaustive never check, and buttons for each.

02

Write a component with useState<"idle" | "loading" | "done">("idle") and a button that goes to loading, then to done after a 300 ms fake request. Show the status and log each change.

Trade-offs

  • ↔

    Discriminated unions and never checks add a little typing up front and make every new action touch the type, the reducer and the dispatchers. That is the point: the compiler finds the places for you. For a two-value toggle, plain useState<boolean> is simpler.

  • ↔

    The ! non-null assertion and as casts make errors go away without making the code safer. Each one is a place a bug can hide; prefer narrowing with checks.

Done when you can

  • I let useState infer simple types and pass a type argument for null, [] and literal unions.

  • I can narrow nullable state with an early return.

  • I can type DOM refs (useRef<HTMLInputElement>(null)) and value refs (useRef<number | null>(null)).

  • I know React 19's useRef needs an argument and refs are mutable.

  • I can model reducer actions as a discriminated union.

  • I can make a switch exhaustive with a never check.