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
useStategives you to change the state, likesetCount. - 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 typestringbecause 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,
nulluntil React fills it. - Reducer
- A function
(state, action) => newStatethat holds all the rules for changing a piece of state. - Action
- An object describing what happened, such as
{ type: "increment" }, sent to the reducer withdispatch. - 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.
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.
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 number05Reducer: 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.
Try it yourself
- 1
Fix the two bad states
In the second example, change the first line to
useState<{ name: string } | null>(null)and the second touseState<"idle" | "saving">("idle"). Predict which errors appear now: the object is accepted, and"savng"is rejected. - 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" }toActionand read the new error inside the reducer, whereassertNevercomplains. - 3
Ask for the ref's type
In the refs example, change
useRef<HTMLInputElement>(null)touseRef(null)and hoverinputRefwith// ^?. The type becomesRefObject<null>: the input still accepts it, butinputRef.current?.focus()now fails withProperty 'focus' does not exist on type 'never'. A ref without its element type can only ever holdnull.
Code & diagrams
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
Console
Nothing logged yet.Expected console output
idle / 0 / nobody
saving / 1 / AshaTwo 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
Console
Nothing logged yet.Expected console output
savnginputRef.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
Console
Nothing logged yet.Expected console output
focused, clicks: 1
INPUTThe 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
Console
Nothing logged yet.Expected console output
5
15Break 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.
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" }.
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.
- ▸
useStateis declared with overloads:useState<S>(initial: S | (() => S)), and a no-argument formuseState<S = undefined>()whose state isS | undefined.useState<string>()therefore holdsstring | undefined, which is a neat way to say "not chosen yet". - ▸
In @types/react 19 there is one
RefObject<T>with a mutablecurrent: T. The| nullinRefObject<HTMLInputElement | null>is added by a convenience overload ofuseRef<T>(initialValue: T | null), which is the oneuseRef<HTMLInputElement>(null)matches. In @types/react 18RefObject.currentwasreadonlyfor DOM refs; code that assigned to it needed a different overload. - ▸
useReducerinfers its action type from the reducer's rest parameters (A extends AnyActionArg, a tuple), so a reducer taking(state, action)givesActionDispatch<[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
The problem this solves: a wrong value in state, such as a typo in a status word or an object stored where
nullwas 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
Inference reads the initial value only, and that is sometimes too little.
useState("idle")is astring, not"idle", because state will change, so TypeScript widens the literal (Topic 1.3): a typo likesetStatus("savng")is accepted.useState(null)is the typenullforever, so no object can ever be stored. AnduseState([])isnever[]: 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
The most common shape is "nothing yet":
useState<User | null>(null). Every read must handlenullfirst, 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 saidCannot 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 separateuseStatecalls. - 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 typeRefObject<HTMLInputElement | null>. React fills.currentafter the first render, so before that it isnull, and TypeScript makes you check (if (inputRef.current) ...orinputRef.current?.focus()), because.current.focus()onnullcrashes. You choose the element type from the tag:HTMLInputElementfor<input>,HTMLDivElementfor<div>(Topic 13.1). - 5
A value ref is a plain mutable box:
useRef(0)has typeRefObject<number>, and you writeclicks.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 typesuseRefalways needs an argument; there is no moreuseRef()with nothing. - 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 literaltypefield, and extra data only on the actions that need it ({ type: "setStep"; step: number }). Inside aswitch (action.type), TypeScript narrowsaction, so incase "setStep"you can readaction.stepand nowhere else. - 7
useReducer(reducer, initialState)infers everything from the reducer's parameter types; you don't pass generics.dispatchthen has typeActionDispatch<[action: Action]>, sodispatch({ type: "double" })is an error, anddispatch({ type: "setStep" })(nostep) is an error. Finish theswitchwith 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
When does useState need a type argument, and when does inference do the job?
How do you type a ref to an input element, and why do you have to check for null?
How do you type a reducer so that adding an action is safe?
What is the difference between useRef<HTMLInputElement>(null) and useRef(0) in the types?
Practice
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.
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
useStatecalls. 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 wrongTis a lie that nobody checks. Keep the type close to what the state really holds, and use unions instead ofstring.
Done when you can
I can say when
useStateinfers correctly and when I must write the type.I can type "nothing yet" state with
T | nulland handle the null.I can type a DOM ref and explain why
.currentcan benull.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
assertNeverdefault.