//
Search across all documentation pages
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
Type your component state correctly with useState and useReducer. Handle simple values, complex objects, nullable state, and discriminated union reducers.
// 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>
);
}useState infers the type from the initial value. useState(0) gives you [number, Dispatch<SetStateAction<number>>].null but eventually holding an object), you must provide an explicit type parameter: useState<User | null>(null).useReducer infers 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 and dispatch calls.type literal field) let TypeScript narrow the action inside each case branch, giving you access to branch-specific payload fields.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" });SetStateAction<T> is T | ((prev: T) => T). This is why both setCount(5) and setCount(prev => prev + 1) work.useReducer, the return type of the reducer must match the state type. TypeScript enforces this automatically when you annotate the reducer parameters.as assertions with state. If TypeScript complains, it usually means your types need adjustment, not a cast.useState<User>() without an initial value gives you User | undefined, not User. Always provide an initial value or explicitly type as User | undefined.setUser without spreading the previous state replaces the entire object. TypeScript will catch missing required fields, which is actually helpful.any defeats the purpose. Even for dynamic shapes, use Record<string, unknown> or a proper type.null case on nullable state leads to runtime errors that TypeScript tries to prevent via strict null checks.| 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 |
useState(0) infers number, useState("") infers string.useState<User | null>(null).useState<[number, number]>([0, 0]).SetStateAction<T> is defined as T | ((prev: T) => T).setCount(5) and setCount(prev => prev + 1) are valid.type literal field: { type: "increment"; payload: number } | { type: "reset" }.switch case, giving access to branch-specific fields like payload.const [data, setData] = useState<Map<string, User>>(() => new Map());User | undefined, not User.User | undefined.useState setters replace the entire value. Unlike class component setState, there is no merging.setUser(prev => ({ ...prev, ...newPartialData })).Record<string, unknown> or define a proper type.case branch returns a shape missing a required field, you get a compile-time error.import { useImmerReducer } from "use-immer";
function reducer(draft: CounterState, action: CounterAction) {
switch (action.type) {
case "increment":
draft.count += action.payload;
break;
}
}useImmerReducer infers types from the reducer signature just like useReducer.useReducer is built-in, scoped to a component, and has zero dependencies.useState<User | null>(null).if (user) { user.name }.Reviewed by Chris St. John·Last updated Jul 7, 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥