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,switchor after an earlyreturn. - 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 acurrentproperty. - 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
nevercan 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.
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.
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.
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 here04Type 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.
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); // OK05Describe actions as a discriminated union
Each action is an object with a type and the data that action needs. The union lists them all.
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.
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'.Try it yourself
- 1
Break the empty array
In the first example change
useState<string[]>([])touseState([]). Predict: does the preview still work? (Yes, types are stripped.) What wouldtscsay about[...t, "tag" + ...]? (Type 'string' is not assignable to type 'never'.) - 2
Add a removed action
In the reducer example add
| { type: "removed"; id: number }, acase "removed"that filters the todo out, and a button that removes id 2. Predict the extra log line. Then delete thecaseand say which linetscwould complain about. - 3
Cancel twice
In the ref example, call
click("cancel")right after the firstclick("cancel"). Predict the console: doescancelledprint twice? (No: after the first cancel,timerRef.currentisnull.)
Code & diagrams
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
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' ]`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
Console
Nothing logged yet.Expected console output
focused: true
cancelled
saved
Status: savedEach 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
Console
Nothing logged yet.Expected console output
(empty)
[ ] milk
[ ] milk, [ ] bread
[x] milk, [ ] bread
[ ] breadimport { 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.
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).
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
useRefoverloads:useRef<T>(initialValue: T): RefObject<T>,useRef<T>(initialValue: T | null): RefObject<T | null>anduseRef<T>(initialValue: T | undefined): RefObject<T | undefined>.MutableRefObjectis deprecated;RefObject.currentis no longer read-only. The required argument was added so the "undefineduntil set" case is explicit. - ▸
React 19's types changed
useReducerto infer from the reducer:useReducer<S, A extends AnyActionArg>(reducer: (prevState: S, ...args: A) => S, initialState: S). Old code that passeduseReducer<Reducer<State, Action>>(...)now fails withExpected 2 type arguments, but got 1.Remove the type argument and annotate the reducer instead.dispatchis typedActionDispatch<[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
letvariables or object properties that could change, so TypeScript sometimes forgets a check inside a closure. Copy the narrowed value into aconstfirst (const u = user;) when a callback needs it.
Remember this
- 1
Inference first.
useState(0)givescount: numberand a setter that only accepts numbers (or a function from number to number).useState("")givesstring,useState(false)givesboolean, anduseState({ name: "Asha", age: 9 })gives that object shape. Don't writeuseState<number>(0): it says the same thing twice. The setter's type isDispatch<SetStateAction<number>>:SetStateAction<S>means "a newS, or a function(prev: S) => S", andDispatch<A>means "a function that takes anAand returns nothing". You'll use those names when you pass a setter to a child as a prop. - 2
Give a type argument when the start value says too little.
useState(null)infers the typenull, so the state can never hold anything else, andsetUser(user)fails with a confusing message.useState([])infersnever[](an array of nothing), so adding any item fails withType 'string' is not assignable to type 'never'. The fix is a generic type argument (a type passed in angle brackets):useState<User | null>(null)anduseState<string[]>([]). A generic is a function or type with a type-shaped blank to fill in;useState<S>is one. - 3
Null state forces you to check. With
User | null, TypeScript won't let you readuser.nameuntil you've ruled outnull:'user' is possibly 'null'.Anif (user === null) return <p>Loading…</p>;above the JSX narrows the type: below that line TypeScript knowsuseris aUser. Narrowing means the checker uses yourif,===,typeofand earlyreturnto work out a more precise type in each branch. This is exactly the loading state every data-fetching component needs (Phase 14). - 4
Refs to DOM elements:
useRef<HTMLInputElement>(null)gives aRefObject<HTMLInputElement | null>.HTMLInputElementis the browser's own type for<input>(others:HTMLDivElement,HTMLButtonElement,HTMLCanvasElement). React puts the node in.currentafter the commit, but TypeScript knows it can benull(before mount, after unmount, or if the element is conditionally rendered), so you writeinputRef.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:useRefneeds an argument (useRef<number>()is now an error), and every ref object is mutable, soref.current = 5is always allowed. - 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 }. Insideswitch (action.type), eachcasenarrowsactionto one member, soaction.textis only allowed in the"added"case. Dispatching{ type: "add" }(a typo) or forgetting a field is an error at thedispatchcall. Annotate the reducer's parameters and return type;useReducerinfers the rest. In React 19's types, don't pass type arguments touseReducerthe old way (useReducer<Reducer<State, Action>>): it is now an error. - 6
**Exhaustive switch with
never.**neveris the type with no values. If every member of the union has acase, then in thedefaultbranchactionhas typenever. Writingconst unhandled: never = action;there costs nothing at run time, but when someone adds a new action type and forgets itscase, that line becomes an error:Type '{ type: "renamed"; ... }' is not assignable to type 'never'.The compiler now lists every reducer you must update. (Turning onnoFallthroughCasesInSwitchalso catches a missingreturnorbreakbetween cases.)
Explain it without notes
When does useState need a type argument, and what goes wrong without one?
How do you type a ref to an input, and why is current possibly null?
What is a discriminated union, and why is it a good fit for reducer actions?
How does the never trick make a switch exhaustive?
Practice
Write a Counter reducer with actions { type: "increment" }, { type: "decrement" } and { type: "set"; value: number }, with an exhaustive never check, and buttons for each.
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
neverchecks 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, plainuseState<boolean>is simpler. - ↔
The
!non-null assertion andascasts 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
useStateinfer simple types and pass a type argument fornull,[]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
useRefneeds an argument and refs are mutable.I can model reducer actions as a discriminated union.
I can make a
switchexhaustive with anevercheck.