Typing State
State is the data a component owns and re-renders on, and useState is the hook that holds it. TypeScript types useState so the value, the setter, and every read of that value stay in sync. Most of the time inference does the work for you from the initial value, but null defaults, empty arrays, and “loading / loaded / error” machines need an explicit type to stay sound. This page covers letting inference do its job, supplying an explicit generic when it cannot, modeling absent values, and using discriminated unions to make impossible states unrepresentable.
Inference from the initial value
When you pass a non-null initial value, TypeScript infers the state type from it. The returned tuple is [T, Dispatch<SetStateAction<T>>], so the setter accepts either a new value or an updater function.
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0); // count: number
const [name, setName] = useState("Ada"); // name: string
return (
<button onClick={() => setCount((c) => c + 1)}>
{name}: {count}
</button>
);
}
Because count is inferred as number, calling setCount("3") is a compile error. Let inference handle primitives and simple objects — adding an annotation there is redundant noise.
When inference is too narrow or too wide
Inference fails you in two common cases: a null initial value (inferred as null alone) and an empty array (inferred as never[]). Supply the type with the useState<T>() generic.
interface User {
id: number;
name: string;
}
// ❌ Without a generic, type is User | null is not inferred:
const [user, setUser] = useState(null); // user: null — setUser(realUser) errors
// ✅ Tell TypeScript the full set of possible values:
const [typedUser, setTypedUser] = useState<User | null>(null);
const [items, setItems] = useState<string[]>([]);
The generic widens the type to everything the state can ever hold. Without <User | null>, the state is stuck as null and assigning a real user is rejected; without <string[]>, items.push("a") fails because never[] accepts nothing.
| Initial value | Inferred type | Fix |
|---|---|---|
0, "hi", true | number, string, boolean | none needed |
null | null | useState<T | null>(null) |
[] | never[] | useState<T[]>([]) |
{} | {} | useState<Shape>({...}) |
Lazy initial state
When the initial value is expensive to compute, pass a function. TypeScript infers the state type from the function’s return type, so it stays just as precise.
function TodoList() {
const [todos, setTodos] = useState<string[]>(() => {
const saved = localStorage.getItem("todos");
return saved ? (JSON.parse(saved) as string[]) : [];
});
return <p>{todos.length} todos</p>;
}
The initializer runs only on the first render. The explicit <string[]> is still useful here because the parsed JSON would otherwise be any.
Union states for async data
A boolean isLoading plus a nullable data lets you express contradictory combinations like “loading and has an error”. A discriminated union keyed by a status field makes each state carry exactly the data it needs and nothing else.
type RequestState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: string };
function Profile() {
const [state, setState] = useState<RequestState<User>>({ status: "idle" });
if (state.status === "loading") return <p>Loading…</p>;
if (state.status === "error") return <p>{state.error}</p>;
if (state.status === "success") return <p>{state.data.name}</p>;
return <button onClick={() => setState({ status: "loading" })}>Load</button>;
}
After each status check TypeScript narrows state to the matching variant, so state.data exists only in the success branch and state.error only in the error branch. Reading state.data while loading is a compile error rather than a runtime undefined.
Updating object and array state
Setters replace state, they do not merge it, so build the next value yourself with spreads. Using the updater form (prev) => … keeps the type intact and avoids stale closures.
const [form, setForm] = useState({ email: "", agree: false });
setForm((prev) => ({ ...prev, email: "[email protected]" })); // ✅ stays { email; agree }
// ❌ Error: missing 'agree' — partial objects are rejected
setForm({ email: "[email protected]" });
Because the state type is fixed at { email: string; agree: boolean }, the compiler forces every update to produce a complete object, catching forgotten fields before they ship.
Best Practices
- Let inference type state from a real initial value; annotate only when it cannot.
- Use the
useState<T>()generic fornulldefaults and empty arrays. - Model “absent” with
T | null(orT | undefined) rather than a sentinel value. - Replace
isLoading+data+errorflags with one discriminatedstatusunion. - Always use the updater form
setX((prev) => …)when the next value depends on the current one. - Spread existing state when updating objects and arrays — setters replace, never merge.
- Avoid typing state as
any; a wrong type now becomes a runtime crash later.