State with useState
Keep interaction state local and update it with event-driven state setters.
- The setter queues an update — reading the state variable on the next line still gives the previous value.
- Pass a function to the setter when the next value depends on the last one, or two updates in the same tick collapse into one.
- Passing a function to
useState itself makes the initial value lazy, so an expensive default is computed once and not on every render.
Example: components/comment-box.tsx
"use client";
import { useState } from "react";
export function CommentBox() {
const [tab, setTab] = useState<"write" | "preview">("write");
const [notify, setNotify] = useState(true);
const [draft, setDraft] = useState("");
return (
<div>
<button onClick={() => setTab("write")} aria-pressed={tab === "write"}>Write</button>
<button onClick={() => setTab("preview")} aria-pressed={tab === "preview"}>Preview</button>
{tab === "write"
? <textarea value={draft} onChange={e => setDraft(e.target.value)} />
: <p>{draft || "Nothing to preview"}</p>}
<label><input type="checkbox" checked={notify} onChange={() => setNotify(n => !n)} /> Email me replies</label>
</div>
);
}
A tab, a toggle, a draft: State that belongs to one screen and nothing else — putting it in a global store instead is what makes two screens fight over one value.
Reducers & context
Model complex transitions with reducers and share stable values through context.
- A reducer moves the transition rules out of the handlers, so 'what can happen next' is readable in one function.
- A reducer must be pure: the same state and action produce the same next state, which is what makes it testable without a component.
- Context re-renders every consumer when its value changes, so a new object literal as the value re-renders them all on every parent render.
Example: components/wizard/wizard-context.tsx
"use client";
import { createContext, useContext, useReducer, type ReactNode, type Dispatch } from "react";
type State = { step: 1 | 2 | 3; data: { name?: string; plan?: string } };
type Action = { type: "next"; data: Partial<State["data"]> } | { type: "back" };
function reducer(state: State, action: Action): State {
if (action.type === "back") return { ...state, step: Math.max(1, state.step - 1) as State["step"] };
return { step: Math.min(3, state.step + 1) as State["step"], data: { ...state.data, ...action.data } };
}
const WizardContext = createContext<{ state: State; dispatch: Dispatch<Action> } | null>(null);
export function WizardProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(reducer, { step: 1, data: {} });
return <WizardContext value={{ state, dispatch }}>{children}</WizardContext>;
}
export function useWizard() {
const ctx = useContext(WizardContext);
if (!ctx) throw new Error("useWizard must be inside <WizardProvider>");
return ctx;
}
A multi-step wizard: Steps, validation and a back button turn into a tangle of useState calls that can reach impossible combinations; a reducer names the legal transitions.
Effects and synchronization
Synchronize with external systems only when rendering alone cannot express the work.
- An Effect runs after paint, so anything that must happen before the browser draws belongs in
useLayoutEffect instead. - The cleanup function is not optional for subscriptions and timers: without it a component that mounts twice leaks the first one.
- Work that only derives a value from props or state does not need an Effect — computing it during render is simpler and cannot go stale.
Example: components/live-price.tsx
"use client";
import { useEffect, useState } from "react";
export function LivePrice({ symbol }: { symbol: string }) {
const [price, setPrice] = useState<number | null>(null);
useEffect(() => {
const source = new EventSource(`/api/prices/${symbol}`);
source.onmessage = e => setPrice(Number(e.data));
return () => source.close();
}, [symbol]);
return <p>{symbol}: {price ?? "…"}</p>;
}
A live price that must stop when the panel closes: A socket opened on mount keeps pushing updates after navigation unless the teardown runs — the classic memory leak in a dashboard.
useEffectEvent
Read the latest props and state inside an Effect without making the Effect resubscribe.
- It separates the values an Effect reacts to from the values it merely reads, which is what a stale-closure bug is really about.
- The function it returns is not reactive, so it must not appear in the dependency array.
- It can only be called from inside an Effect in the same component — passing it elsewhere is a lint error, not a style preference.
Example: components/chat-room.tsx
"use client";
import { useEffect, useEffectEvent } from "react";
import { createConnection } from "@/lib/chat";
import { track } from "@/lib/analytics";
export function ChatRoom({ roomId, plan }: { roomId: string; plan: string }) {
const onConnected = useEffectEvent(() => {
track("room_joined", { roomId, plan }); // always the current plan
});
useEffect(() => {
const connection = createConnection(roomId);
connection.on("connected", onConnected);
connection.connect();
return () => connection.disconnect();
}, [roomId]); // plan is not a dependency
return <h2>Room {roomId}</h2>;
}
An analytics call inside a subscription: Logging the current filter when a message arrives should not make the socket reconnect every time the filter changes.