| name | state-discipline |
| description | Apply React 19 / Next.js 16 state discipline — `useState` is the last resort, not the default. Derive state from props/URL/data, use a query library for server state, use event handlers for one-shot side effects, use `key` to reset state, use `useMountEffect` for one-time external sync, never bare `useEffect`. Use when the user pastes `useState + useEffect + fetch`, reaches for `useState` to mirror a prop, hand-rolls a derived value via `useEffect + setState`, syncs state with URL via `useEffect`, or asks "should I add useState here?". Pairs with `data-fetching` (Server Components first) and `forms` (the form toolkit owns field state). Refuses to apply outside React 19 / Next.js 16 (App Router) projects. Not for: server-data reads (use `data-fetching`), form field values (use `forms`), React Native state patterns (RN has no URL state primitive — use plain React patterns), or generic React tutorials (this is a discipline skill for an already-scaffolded Next.js 16 codebase, not a learning resource). |
state-discipline — useState is the last resort
This skill governs where state lives in a Next.js 16 App Router / React 19 codebase. The framework's primitives (Server Components, URL searchParams, props, query libraries, controlled inputs, useOptimistic, useTransition) cover ~90% of "I need some state" cases. useState and useEffect are escape hatches for the last 10%.
The bug is silent: code that uses useState + useEffect to mirror a prop, derive a value, or sync with the URL works — until the source of truth changes and the mirror desyncs. No error, no warning, just wrong UI.
When this skill applies
- The user pastes
useState + useEffect and asks for review.
- The user is about to add
useEffect (at all).
- The user is about to add
useState to mirror a prop.
- The user asks "should I add
useState here?".
- The user is about to add
useState to hold form field values (route them to the forms skill).
- The user is about to add
useState to hold server-fetched data (route them to the data-fetching skill).
- The user asks to audit a codebase against the React state rules.
Contract
Follows the dev-flow contract — see references/contracts.md. Key facts:
- Reads
meta.json#stack.framework and stack.nextjs_version. For monorepo, reads stack.monorepo.web.*.
- Refuses if
stack.framework ∉ {"next", "monorepo"} or stack.nextjs_version != "16". The principles transfer to other React 19 setups (Remix v3, plain React 19 + Vite, etc.) but the URL/Server-Component rungs do not — refuse rather than mis-apply.
- Appends
history per refactor.
- Does not bump
phase.
Companion skills
data-fetching — owns the "read data in Server Components, not in useEffect" rule. Every useEffect that fetches is a data-fetching problem first.
forms — owns field state for forms. Every useState bound to <input>/<Checkbox>/<Switch> whose value persists is a forms problem first.
This skill owns everything left over: derived values, prop mirrors, UI toggles, optimistic UI, reset semantics, one-time external sync.
The Rule
Reach for useState only after exhausting these alternatives, in order:
- Can it be derived? Compute from props, URL, or other state during render. No
useEffect + setState.
- Can it live in the URL?
searchParams for tabs, filters, ranges, pagination, sort, search query, modal-open, selected-item. Page stays a Server Component.
- Can it be a prop? Lift state to the nearest common parent. Stop mirroring.
- Is it server state? Use a query library (or — preferably — push it back to a Server Component per the
data-fetching skill).
- Is it a one-shot side effect (after user click, after fetch resolves)? Event handler. Not
useEffect.
- Do you need to reset state when an identity changes? Pass
key to the component.
- Do you need one-time external sync at mount (DOM API, third-party widget, focus management)?
useMountEffect from @/lib/hooks/use-mount-effect — the project's explicit-intent escape hatch with a single eslint-disable localized to its definition.
- None of the above →
useState is honestly the right answer (transient UI like hover, dropdown-open without URL contract, animation in-progress, etc.). Use it and move on.
Never bare useEffect. Ban it via lint:
{
"selector": "CallExpression[callee.name='useEffect']",
"message": "Bare useEffect is banned. Use the state-discipline skill ladder. For one-time external sync use useMountEffect."
}
The lint rule lives in the project; this skill is its conceptual source of truth.
The eight rungs — with examples
1. Derive, don't store-and-sync
❌ Red — mirror a prop into state, sync via useEffect:
function FullName({ first, last }: { first: string; last: string }) {
const [full, setFull] = useState("");
useEffect(() => {
setFull(`${first} ${last}`);
}, [first, last]);
return <span>{full}</span>;
}
✅ Green — derive during render:
function FullName({ first, last }: { first: string; last: string }) {
const full = `${first} ${last}`;
return <span>{full}</span>;
}
If the derivation is expensive: useMemo. Still no useEffect.
2. URL state for shareable / back-button-correct state
❌ Red — filter state in useState, page becomes "use client":
"use client";
function CasesPage() {
const [status, setStatus] = useState<"open" | "closed">("open");
}
✅ Green — filter state in URL, page stays Server Component:
export default async function CasesPage({
searchParams,
}: {
searchParams: Promise<{ status?: string }>;
}) {
const { status = "open" } = await searchParams;
const cases = await listCases({ status });
return <CasesView cases={cases} status={status} />;
}
The chip-row Client Component calls router.replace(\?${next}`, { scroll: false })`. Free streaming, free cache, shareable URL, back-button works.
3. Lift state, don't mirror
❌ Red — child mirrors parent's state:
function Child({ value }: { value: string }) {
const [local, setLocal] = useState(value);
useEffect(() => setLocal(value), [value]);
}
✅ Green — read the prop:
function Child({ value }: { value: string }) {
}
If the child needs to edit, lift the setter to the parent too.
4. Server state belongs on the server (or in a query library)
See the data-fetching skill. If you must keep it client-side (polling / focus refetch / third-party-mutated data), use SWR or React Query — they own staleness, dedup, retry, error states. Do not re-implement with useState + useEffect.
5. Side effect after user click → event handler, not useEffect
❌ Red — set a "submitted" flag then react in useEffect:
function Form() {
const [submitted, setSubmitted] = useState(false);
useEffect(() => {
if (submitted) {
toast.success("Saved!");
setSubmitted(false);
}
}, [submitted]);
return <button onClick={() => setSubmitted(true)}>Save</button>;
}
✅ Green — do it in the click handler:
function Form() {
return (
<button onClick={() => { void save(); toast.success("Saved!"); }}>
Save
</button>
);
}
6. Reset state on identity change → key, not useEffect
❌ Red — reset internal state via useEffect on prop change:
function ProfileForm({ userId }: { userId: string }) {
const [draft, setDraft] = useState("");
useEffect(() => {
setDraft("");
}, [userId]);
}
✅ Green — let React unmount/remount via key:
<ProfileForm key={userId} userId={userId} />
The child gets fresh state on every userId change. No effect, no race.
7. One-time external sync → useMountEffect
For unavoidable mount-only side effects (DOM API, third-party widget init, focus management), use useMountEffect — a thin wrapper around useEffect(fn, []) with explicit intent:
import { useEffect } from "react";
export function useMountEffect(fn: () => void | (() => void)) {
useEffect(fn, []);
}
"use client";
import { useMountEffect } from "@/lib/hooks/use-mount-effect";
export function FocusOnMount() {
const ref = useRef<HTMLInputElement>(null);
useMountEffect(() => { ref.current?.focus(); });
return <input ref={ref} />;
}
The eslint-disable lives in one place. Every consumer site is grep-able by useMountEffect.
8. Honest useState — the last 10%
Some state really is local, transient, has no URL contract, isn't derivable, isn't server state:
- Hover / focus state for visual feedback (when CSS
:hover doesn't fit).
- Dropdown-open / tooltip-open when there's no URL reason to share it.
- Animation in-progress flags.
- "Show more" toggle inside a card.
For these, useState is honest. Use it. Don't over-engineer.
Optimistic UI — useOptimistic, not hand-rolled
❌ Red — bookkeeping the "real" state by hand:
const [msgs, setMsgs] = useState(initial);
async function onSend(text: string) {
setMsgs((m) => [...m, { text, pending: true }]);
await sendMessage(text);
setMsgs(await listMessages());
}
✅ Green — useOptimistic + revalidatePath inside the action:
"use client";
import { useOptimistic } from "react";
import { sendMessage } from "@/lib/actions/chat.actions";
export function Thread({ initial }: { initial: Message[] }) {
const [optimisticMsgs, addOptimistic] = useOptimistic(
initial,
(state, draft: Message) => [...state, { ...draft, pending: true }],
);
async function onSend(text: string) {
addOptimistic({ id: crypto.randomUUID(), text });
await sendMessage(text);
}
return ;
}
Transitions — useTransition for non-blocking updates
When a state update triggers heavy re-render (filter a large list, switch tabs that re-render charts), wrap in useTransition so the UI stays responsive:
"use client";
import { useTransition, useState } from "react";
export function HeavyTabs() {
const [tab, setTab] = useState("a");
const [pending, startTransition] = useTransition();
return (
<button onClick={() => startTransition(() => setTab("b"))}>
{pending ? "Loading…" : "Tab B"}
</button>
);
}
For URL-state transitions, wrap the router.replace call in startTransition — pending becomes the loading state.
Red flags / rationalizations
| Rationalization | Counter |
|---|
| "I just need to keep the prop in state so I can edit it locally." | Lift the setter, or use key to reset on identity change. Mirroring desyncs. |
"I'll useEffect to compute X from Y." | Derive during render. If expensive: useMemo. |
| "I need to fetch on mount." | Server Component (data-fetching rung 1). Or useMountEffect if genuinely client-side. |
"I need to sync state to URL on every change — useEffect is fine." | Backwards. Make URL the source of truth; read it via searchParams / useSearchParams; write it via router.replace. The "sync" disappears. |
"I'll useEffect to reset state when the user changes." | key={userId} on the component. |
"I'll show a toast in useEffect after submit." | Toast in the click handler. |
| "I'll store the dropdown-open state in URL." | Don't — local UI state with no shareable contract belongs in useState. Rung 8. |
"I need useEffect for a third-party library." | useMountEffect if mount-only. If it has its own subscription model, follow its docs (often a hook the library provides). |
"useOptimistic is overkill, I'll just setState." | useOptimistic is the rung. Manual setState + manual re-read is anti-pattern data-fetching #6. |
Reset semantics — quick table
| What changes | How to reset state |
|---|
Prop identity (userId, recordId) | key={prop} on the component |
| Route segment | Built-in via React — different segment renders a different subtree |
| Manual user action (Clear button) | Event handler that calls the setter(s) |
| Form save success | The forms skill's hook resets baseline automatically — see forms |
| Server data refresh | revalidatePath / revalidateTag — see data-fetching |
Workflow
Step 1 — verify the contract
Read .workflow/meta.json. Confirm stack.framework ∈ {"next", "monorepo"} and stack.nextjs_version = "16". Else refuse.
Step 2 — diagnose the call site
For a useState + useEffect pair, walk the 8 rungs top-down. For a bare useEffect, demand a rung-7 justification (useMountEffect) or refuse.
Step 3 — refactor
Apply the matching rung's green pattern. If routing to a sibling skill (forms for field state, data-fetching for server reads), say so and stop.
Step 4 — append history
{
"skill": "state-discipline",
"ran_at": "<now>",
"outputs": ["<file>"],
"phase_before": "<unchanged>",
"phase_after": "<unchanged>"
}
Audit mode
When the user asks "audit against state-discipline" / "find every useEffect" / "scan for prop mirrors", produce a report. The audit recipe lives in references/audit-recipe.md.
Violation kinds:
| Code | Violation | Severity |
|---|
| A | Bare useEffect (not useMountEffect) | high |
| B | useState mirroring a prop (with sync useEffect) | high |
| C | Derived value computed via useEffect + setState | medium |
| D | URL-shaped state (tabs / filters / pagination) in useState | high |
| E | useEffect calling setState to react to its own state | medium |
| F | Side effect (toast, navigate) inside useEffect triggered by a flag | medium |
| G | Hand-rolled optimistic UI (no useOptimistic) | low |
| H | Reset-on-identity via useEffect instead of key | medium |
Sources
Derived from the nextjs-usestate skill from lusentis/next-skills (MIT-licensed), adapted to the dev-flow contract and renamed to state-discipline since the rules cover all state, not only useState. The eight-rung ladder, the useMountEffect escape-hatch contract, the key-for-reset discipline, the lint rule, and the red-flag catalog are preserved.
When in doubt
Walk the 8 rungs top-down. Stop at the first that fits. Reaching rung 8 too often is the signal you're skipping rungs 1–4.