Skip to content
Back to skills

State Discipline

ASecurity

Apply React 19 / Next.js 16 state discipline — `useState` is the last resort, not the default: derive from props/URL/data, `key` to reset, `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?". Also owns the React 19 concurrent-state APIs — `useTransiti...

  • 6 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added October 19, 2026
developmentgoreactnextjsexpressgitapi

Works with

  • cli
  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 19, 2026

npx -y skills add lukedj78/dev-flow --skill state-discipline --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of State Discipline?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for State Discipline
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/lukedj78-state-discipline/badge)](https://www.skillsdirectory.com/skills/lukedj78-state-discipline)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: state-discipline
description: 'Apply React 19 / Next.js 16 state discipline — `useState` is the last resort, not the default: derive from props/URL/data, `key` to reset, `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?". Also owns the React 19 concurrent-state APIs — `useTransition`, `useOptimistic`, `useActionState`, `<Activity>` — including when described rather than named: keeping the UI responsive during a pending update, an optimistic UI, form action state, a hidden subtree that stays mounted. Refuses outside Next.js 16 App Router. Not for: server-data reads (use `data-fetching`), form field values (use `forms`), or React Native state patterns.'
---

# 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:**

1. **Can it be derived?** Compute from props, URL, or other state during render. No `useEffect + setState`.
2. **Can it live in the URL?** `searchParams` for tabs, filters, ranges, pagination, sort, search query, modal-open, selected-item. Page stays a Server Component.
3. **Can it be a prop?** Lift state to the nearest common parent. Stop mirroring.
4. **Is it server state?** Use a query library (or — preferably — push it back to a Server Component per the `data-fetching` skill).
5. **Is it a one-shot side effect (after user click, after fetch resolves)?** Event handler. Not `useEffect`.
6. **Do you need to reset state when an identity changes?** Pass `key` to the component.
7. **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.
8. **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 — **no dev-flow scaffold installs this rule**, so until a project adds it to its ESLint config the ban is a convention, checked by this skill's audit recipe (`references/audit-recipe.md`, step 1 looks for the rule) and by review:

```json
// eslint config — no-restricted-syntax
{
  "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. Adding it to an existing project raises its lint findings, so if the project caps warnings (`--max-warnings`), lower the cap only after the existing `useEffect` calls are converted or moved behind `useMountEffect`.

## The eight rungs — with examples

### 1. Derive, don't store-and-sync

❌ **Red — mirror a prop into state, sync via `useEffect`:**

```tsx
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:**

```tsx
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"`:**

```tsx
"use client";
function CasesPage() {
  const [status, setStatus] = useState<"open" | "closed">("open");
  // …re-fetch via Server Action on status change…
}
```

✅ **Green — filter state in URL, page stays Server Component:**

```tsx
// app/(app)/cases/page.tsx
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 writes the param. Free streaming, free cache, shareable URL, back-button works.

**Write the URL with `nuqs`, not hand-rolled `router.replace`.** Verified against **`nuqs@2.10.1`** (2026-08-26): its `peerDependencies.next` is `>=14.2.0` with **no upper bound**, so Next 16 needs no opt-in and no special handling. `debounce(timeMs)` and `throttle(timeMs)` are both exported and both return a `LimitUrlUpdates`, which is what `limitUrlUpdates` takes. [`nuqs`](https://nuqs.dev) (v2) is the ecosystem-first, type-safe URL-state library — `useQueryState`/`useQueryStates` behave like `useState` but persist to the URL, with typed parsers, built-in **URL-update rate limiting** (`limitUrlUpdates: debounce(…)` — hand-rolled per-keystroke `router.replace` on a search box is the classic jank), and `useTransition` support. The page stays a Server Component reading the `searchParams` prop (above) — nuqs only owns the **client write side**:

```tsx
"use client";
import { useQueryState, parseAsStringEnum } from "nuqs";
// chip row — one typed param, synced to ?status=
const [status, setStatus] = useQueryState(
  "status",
  parseAsStringEnum(["open", "closed"]).withDefault("open"),
);
// setStatus("closed") updates the URL; the Server Component above re-renders with new data
```

One-time setup: wrap the root layout in `<NuqsAdapter>` (from `nuqs/adapters/next/app`). For type-safe server reads in nested components without prop-drilling, `createSearchParamsCache([...])`. The hand-rolled `router.replace(\`?\${next}\`)` is a fine fallback when you don't want the dependency, but for anything beyond a single boolean, prefer nuqs.

### 3. Lift state, don't mirror

❌ **Red — child mirrors parent's state:**

```tsx
function Child({ value }: { value: string }) {
  const [local, setLocal] = useState(value);
  useEffect(() => setLocal(value), [value]);
  // …
}
```

✅ **Green — read the prop:**

```tsx
function Child({ value }: { value: string }) {
  // …read `value` directly
}
```

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`:**

```tsx
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:**

```tsx
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:**

```tsx
function ProfileForm({ userId }: { userId: string }) {
  const [draft, setDraft] = useState("");
  useEffect(() => {
    setDraft(""); // reset when userId changes
  }, [userId]);
  // …
}
```

✅ **Green — let React unmount/remount via `key`:**

```tsx
<ProfileForm key={userId} userId={userId} />
```

The child gets fresh state on every `userId` change. No effect, no race.

**Want to keep the state instead of resetting it?** `key` always throws the subtree away. If the goal is the opposite — hide a tab/panel but keep its scroll position, form draft, or component state alive for when it's shown again — reach for `<Activity>` instead of `key`. See "`<Activity>` — hide without resetting" below.

### 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:

```tsx
// lib/hooks/use-mount-effect.ts
import { useEffect } from "react";
export function useMountEffect(fn: () => void | (() => void)) {
  // eslint-disable-next-line react-hooks/exhaustive-deps
  useEffect(fn, []);
}
```

```tsx
"use client";
import { Input } from "@/components/ui/input";
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`.

**Cleaner alternative when the effect reads fresh props/state: `useEffectEvent`.** `useMountEffect`'s `eslint-disable` is a blunt instrument — it silences the exhaustive-deps check for the whole effect body, so if the effect also needs to *read* a prop or piece of state without re-firing when that value changes, there's no clean way to say so. `useEffectEvent` (from `react`) solves exactly this: it wraps the part of the effect that must always see the latest values but must never itself be a reactive dependency.

```tsx
"use client";
import { useEffect, useEffectEvent } from "react";

export function ChatRoom({ roomId, theme }: { roomId: string; theme: string }) {
  const onConnected = useEffectEvent(() => {
    showNotification("Connected!", theme); // always reads the LATEST theme
  });

  useEffect(() => {
    const connection = createConnection(roomId);
    connection.on("connected", () => onConnected());
    connection.connect();
    return () => connection.disconnect();
  }, [roomId]); // theme is NOT a dependency — onConnected reads it fresh, no re-connect on theme change
}
```

Use `useMountEffect` for genuinely mount-only, no-fresh-reads side effects (focus on mount, one-shot third-party init). Reach for `useEffectEvent` when the effect must stay subscribed/connected across renders but a piece of its logic needs to read current props/state without becoming a re-run trigger. **Stable since React 19.2** (shipped alongside `<Activity>` and `cacheSignal`; current stable 19.2.x) — import `useEffectEvent` from `react`, not the `experimental_` prefix. On React 19.0/19.1 the prefixed form is still required.

### 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:**

```tsx
const [msgs, setMsgs] = useState(initial);
async function onSend(text: string) {
  setMsgs((m) => [...m, { text, pending: true }]);
  await sendMessage(text);
  setMsgs(await listMessages()); // re-read by hand
}
```

✅ **Green — `useOptimistic` + `revalidatePath` inside the action:**

```tsx
"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); // revalidatePath('/chat') inside the action
  }
  return /* render optimisticMsgs */;
}
```

## 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:

```tsx
"use client";
import { useTransition, useState } from "react";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

export function HeavyTabs() {
  const [tab, setTab] = useState("a");
  const [pending, startTransition] = useTransition();
  return (
    <Tabs value={tab} onValueChange={(value) => startTransition(() => setTab(String(value)))}>
      <TabsList>
        <TabsTrigger value="a">Tab A</TabsTrigger>
        <TabsTrigger value="b">{pending ? "Loading…" : "Tab B"}</TabsTrigger>
      </TabsList>
    </Tabs>
  );
}
```

For URL-state transitions, wrap the `router.replace` call in `startTransition` — `pending` becomes the loading state.

## `useActionState` — where it lands relative to the ladder

`useActionState` (React 19) is neither a new rung nor a replacement for any of the 8 — it's the primitive for **pending/error state of a single Server Action invocation** when the full `forms` toolkit (Zod schema, dirty/valid gating, baseline reset) is overkill: a one-button action (archive, delete, resend-invite, like), not a multi-field form.

```tsx
"use client";
import { useActionState } from "react";
import { Button } from "@/components/ui/button";
import { archiveClient } from "@/lib/server/clienti";

export function ArchiveButton({ id }: { id: number }) {
  const [state, formAction, isPending] = useActionState(archiveClient, { ok: true });
  return (
    <form action={formAction}>
      <input type="hidden" name="id" value={id} />
      <Button type="submit" disabled={isPending}>{isPending ? "Archiving…" : "Archive"}</Button>
      {!state.ok && <p className="text-error">{state.error}</p>}
    </form>
  );
}
```

- **Multi-field form with validation, dirty tracking, Save-button gating?** That's the `forms` skill's job — `useActionState` alone doesn't give you field-level dirty/valid state.
- **Single action, no fields (or hidden-only fields), just need pending + the action's returned result?** `useActionState` is the honest, minimal answer — don't hand-roll `useState` + `useTransition` + manual error bookkeeping to reinvent it (that's rung-8-gone-wrong: a `useState` that's really re-implementing a framework primitive).
- Don't reach for `useOptimistic` here unless the button also needs an instant UI flip before the action resolves — the two compose (`useOptimistic` for the instant flip, `useActionState` for the pending/error of the underlying action).

## `<Activity>` — hide without resetting (alternative to `key`)

`key` (rung 6) is for **resetting** state when identity changes — React throws the old subtree away and mounts a fresh one. `<Activity>` (React 19.2) is for the opposite need: **hide a subtree from the screen while keeping its state, DOM, and effects' cleanup alive**, so switching back doesn't lose scroll position, an in-progress form draft, or an expensive-to-rebuild tree (an inactive chat tab, an offscreen wizard step, a background route in a tab-like UI).

```tsx
import { Activity } from "react";

function Tabs({ activeTab }: { activeTab: "chat" | "settings" }) {
  return (
    <>
      <Activity mode={activeTab === "chat" ? "visible" : "hidden"}>
        <ChatPanel />
      </Activity>
      <Activity mode={activeTab === "settings" ? "visible" : "hidden"}>
        <SettingsPanel />
      </Activity>
    </>
  );
}
```

Rule of thumb: **`key` = reset, `<Activity>` = preserve.** If a bug report says "my draft disappeared when I switched tabs and came back," that's a `key`-shaped reset where `<Activity>` was needed. **Stable since React 19.2** — available from `react` directly (current stable 19.3.x as of 2026-10-17). On React 19.0/19.1 it was canary-only, so check the project's version before reaching for it.

## 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 server-side via the `searchParams` prop; write it client-side via `nuqs` `useQueryState` (typed + throttled) — or hand-rolled `router.replace` for a single param. 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

```json
{
  "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

**Re-verified 2026-10-17 against `react@19.3.1`** (npm `latest`) and `react@19.3.0` (`cjs/react.production.js`). All five concurrent-state APIs this skill leans on —
`useEffectEvent`, `Activity`, `useOptimistic`, `useActionState`, `useTransition` — are exported under
their **stable** names, with no `unstable_` or `experimental_` variants alongside them. So the ladder
below carries no canary hedge. (Note `expo@58.0.14` pins React at `19.3.0`, one patch behind npm; both
are past the 19.2 stabilisation, so nothing here changes on RN — and RN has no URL rung anyway.)
All ten cited URLs resolve.

Derived from the `nextjs-usestate` skill from **[lusentis/next-skills](https://github.com/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.

- Original: <https://github.com/lusentis/next-skills/tree/main/skills/nextjs-usestate>
- React docs (You Might Not Need an Effect): <https://react.dev/learn/you-might-not-need-an-effect>
- React docs (`useOptimistic`): <https://react.dev/reference/react/useOptimistic>
- React docs (`useTransition`): <https://react.dev/reference/react/useTransition>
- React docs (`key`): <https://react.dev/learn/preserving-and-resetting-state>
- React docs (`useActionState`): <https://react.dev/reference/react/useActionState>
- React docs (`useEffectEvent`) — **stable since React 19.2**: <https://react.dev/reference/react/useEffectEvent>
- React docs (`<Activity>`) — **stable since React 19.2**: <https://react.dev/reference/react/Activity>
- **`data-fetching/references/nuqs.md`** — the doc-grounded how-to for rung 2 (URL state) with `nuqs`: hooks, parsers, options, server-side cache. Read it before wiring URL state — don't improvise the API.

## 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.

Files in this skill

  • SKILL.md23.1 KB
  • references/audit-recipe.md5.6 KB
  • references/contracts.md80.5 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.