React SME Cookbook
All FAQs

Search Documentation

Search across all documentation pages

typescriptreactuseStateuseReducerstatehooks

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

  • useState infers 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 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.
  • Discriminated union actions (actions with a type literal field) let TypeScript narrow the action inside each case branch, giving you access to branch-specific payload fields.

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> is T | ((prev: T) => T). This is why both setCount(5) and setCount(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 as assertions with state. If TypeScript complains, it usually means your types need adjustment, not a cast.

Gotchas

  • Using useState<User>() without an initial value gives you User | undefined, not User. Always provide an initial value or explicitly type as User | undefined.
  • Passing an object literal to setUser without spreading the previous state replaces the entire object. TypeScript will catch missing required fields, which is actually helpful.
  • Typing state as any defeats the purpose. Even for dynamic shapes, use Record<string, unknown> or a proper type.
  • Forgetting to handle the null case on nullable state leads to runtime errors that TypeScript tries to prevent via strict null checks.

Alternatives

ApproachProsCons
useState with inferenceZero boilerplate for simple valuesCannot express nullable or union initial states
useState<T> explicit genericFull control over state typeSlightly more verbose
useReducerPredictable state transitions, great for complex stateMore boilerplate than useState
Zustand storeShared state with TypeScript inferenceExternal dependency
useActionState (React 19)Built-in form state managementLimited to form/action patterns

FAQs

When does useState infer the type automatically?
  • When you provide an initial value: useState(0) infers number, useState("") infers string.
  • 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 as T | ((prev: T) => T).
  • This is why both setCount(5) and setCount(prev => prev + 1) are valid.
How do discriminated union actions work with useReducer?
  • Define actions with a type literal field: { type: "increment"; payload: number } | { type: "reset" }.
  • TypeScript narrows the action inside each switch case, giving access to branch-specific fields like payload.
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, not User.
  • Always provide an initial value or explicitly type as User | undefined.
Gotcha: Does setUser(newPartialData) merge with the previous state?
  • No. useState setters replace the entire value. Unlike class component setState, 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 case branch 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. useImmerReducer infers types from the reducer signature just like useReducer.
What is the difference between useReducer and Zustand for state management?
  • useReducer is 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.