Typing State
Recipe
Type your component state correctly with useState and useReducer. Handle simple values, complex objects, nullable state, and discriminated union reducers.
Working Example
// Simple useState - type is inferred
const [count, setCount] = useState(0); // number
const [name, setName] = useState(""); // string
const [enabled, setEnabled] = useState(false); // boolean// Explicit typing for complex or nullable state
type User = {
id: string;
name: string;
email: string;
};
const [user, setUser] = useState<User | null>(null);
// Later...
if (user) {
console.log(user.name); // TypeScript knows user is not null here
}// useReducer with discriminated union actions
type CounterState = {
count: number;
lastAction: string;
};
type CounterAction =
| { type: "increment"; payload: number }
| { type: "decrement"; payload: number }
| { type: "reset" };
function counterReducer(state: CounterState, action: CounterAction): CounterState {
switch (action.type) {
case "increment":
return { count: state.count + action.payload, lastAction: "increment" };
case "decrement":
return { count: state.count - action.payload, lastAction: "decrement" };
case "reset":
return { count: 0, lastAction: "reset" };
}
}
function Counter() {
const [state, dispatch] = useReducer(counterReducer, { count: 0, lastAction: "none" });
return (
<div>
<p>Count: {state.count}</p>
<button onClick={() => dispatch({ type: "increment", payload: 1 })}>+1</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</div>
);
}Deep Dive
How It Works
useStateinfers the type from the initial value.useState(0)gives you[number, Dispatch<SetStateAction<number>>].- When the initial value does not represent all possible states (e.g., starting as
nullbut eventually holding an object), you must provide an explicit type parameter:useState<User | null>(null). useReducerinfers state and action types from the reducer function signature. Defining the reducer with explicit parameter types gives you full type safety in both the reducer body anddispatchcalls.- Discriminated union actions (actions with a
typeliteral field) let TypeScript narrow the action inside eachcasebranch, giving you access to branch-specificpayloadfields.
Variations
Lazy initializer:
const [data, setData] = useState<Map<string, User>>(() => new Map());State with a tuple:
const [coords, setCoords] = useState<[number, number]>([0, 0]);useReducer with Immer:
import { useImmerReducer } from "use-immer";
function reducer(draft: CounterState, action: CounterAction) {
switch (action.type) {
case "increment":
draft.count += action.payload;
draft.lastAction = "increment";
break;
case "reset":
draft.count = 0;
draft.lastAction = "reset";
break;
}
}
const [state, dispatch] = useImmerReducer(reducer, { count: 0, lastAction: "none" });TypeScript Notes
SetStateAction<T>isT | ((prev: T) => T). This is why bothsetCount(5)andsetCount(prev => prev + 1)work.- For
useReducer, the return type of the reducer must match the state type. TypeScript enforces this automatically when you annotate the reducer parameters. - Avoid
asassertions with state. If TypeScript complains, it usually means your types need adjustment, not a cast.
Gotchas
- Using
useState<User>()without an initial value gives youUser | undefined, notUser. Always provide an initial value or explicitly type asUser | undefined. - Passing an object literal to
setUserwithout spreading the previous state replaces the entire object. TypeScript will catch missing required fields, which is actually helpful. - Typing state as
anydefeats the purpose. Even for dynamic shapes, useRecord<string, unknown>or a proper type. - Forgetting to handle the
nullcase on nullable state leads to runtime errors that TypeScript tries to prevent via strict null checks.
Alternatives
| Approach | Pros | Cons |
|---|---|---|
useState with inference | Zero boilerplate for simple values | Cannot express nullable or union initial states |
useState<T> explicit generic | Full control over state type | Slightly more verbose |
useReducer | Predictable state transitions, great for complex state | More boilerplate than useState |
| Zustand store | Shared state with TypeScript inference | External dependency |
useActionState (React 19) | Built-in form state management | Limited to form/action patterns |
FAQs
When does useState infer the type automatically?
- When you provide an initial value:
useState(0)infersnumber,useState("")infersstring. - If the initial value fully represents all possible states, no explicit generic is needed.
When should I provide an explicit type parameter to useState?
- When the initial value does not cover all possible states, e.g.,
useState<User | null>(null). - Also for complex types like tuples:
useState<[number, number]>([0, 0]).
What is SetStateAction<T> and why can I pass a function to a setter?
SetStateAction<T>is defined asT | ((prev: T) => T).- This is why both
setCount(5)andsetCount(prev => prev + 1)are valid.
How do discriminated union actions work with useReducer?
- Define actions with a
typeliteral field:{ type: "increment"; payload: number } | { type: "reset" }. - TypeScript narrows the action inside each
switchcase, giving access to branch-specific fields likepayload.
What is a lazy initializer for useState?
const [data, setData] = useState<Map<string, User>>(() => new Map());- The function is called only on the first render, avoiding recreating expensive objects on every render.
Gotcha: What happens if I call useState<User>() without an initial value?
- The state type becomes
User | undefined, notUser. - Always provide an initial value or explicitly type as
User | undefined.
Gotcha: Does setUser(newPartialData) merge with the previous state?
- No.
useStatesetters replace the entire value. Unlike class componentsetState, there is no merging. - You must spread the previous state yourself:
setUser(prev => ({ ...prev, ...newPartialData })). - TypeScript will catch missing required fields if you forget to spread.
Why should I avoid typing state as any?
- It defeats TypeScript's purpose and removes all compile-time safety.
- For dynamic shapes, prefer
Record<string, unknown>or define a proper type.
How does useReducer enforce that the reducer return type matches the state type?
- TypeScript checks that the return type of your reducer function matches the state parameter type.
- If a
casebranch returns a shape missing a required field, you get a compile-time error.
Can I use Immer with useReducer and still get type safety?
import { useImmerReducer } from "use-immer";
function reducer(draft: CounterState, action: CounterAction) {
switch (action.type) {
case "increment":
draft.count += action.payload;
break;
}
}- Yes.
useImmerReducerinfers types from the reducer signature just likeuseReducer.
What is the difference between useReducer and Zustand for state management?
useReduceris built-in, scoped to a component, and has zero dependencies.- Zustand provides shared state across components with TypeScript inference but requires an external package.
How do I handle nullable state safely?
- Type it explicitly:
useState<User | null>(null). - Use a null check before accessing properties:
if (user) { user.name }. - Strict null checks in TypeScript will flag unguarded access.