---
name: forge-react-hooks
description: Rules-of-Hooks discipline for React. Hooks must be called unconditionally at the top of a component or another hook - never inside an if/else, loop, ternary, short-circuit (&& / || / ??), try/catch, or after an early return. The verifier walks the AST and catches all six violation classes, including hooks reached via conditional expressions. Use when writing or reviewing React function components, custom hooks, or any code that imports from "react" / "preact" / "solid-js" (similar discipline).
license: MIT
---

# forge-react-hooks

React tracks hook state by call order. If a hook is called on render 1 but skipped on render 2, the order shifts and React reads the wrong cell. Result: state belongs to the wrong hook, a useEffect cleanup never runs, or React throws "Rendered fewer hooks than expected." The official lint rule (`react-hooks/rules-of-hooks`) ships with create-react-app but is routinely turned off in custom configs. AI-generated React code disables it by default.

This skill exists so the verifier catches every Rules-of-Hooks violation class, AST-precise, regardless of whether `eslint-plugin-react-hooks` is installed.

## Quick reference (the things you must never ship)

1. A hook call inside `if`, `else if`, or `else` body.
2. A hook call inside a `for`, `for...of`, `for...in`, `while`, or `do...while` loop.
3. A hook call as the right side of `&&`, `||`, or `??` short-circuits.
4. A hook call inside a ternary expression: `cond ? useThing() : null`.
5. A hook call inside `try` or `catch`.
6. A hook call after an early `return` in the same function body.
7. A hook call inside a callback that is not itself a hook or component (event handlers, `.map(() => useThing())`, etc.).
8. A custom hook that returns a value conditionally - all hooks inside it must still run unconditionally.
9. A hook with a stable identity passed to a child component as a prop (lift it instead).
10. Calling `useState` inside `useEffect` (or any hook inside another hook's *callback*, not the hook itself).

## Hard rules

### Top-of-function only

**1. Every hook call must be at the top level of a function component or another custom hook.**

```tsx
// GOOD
function Counter({ enabled }) {
  const [count, setCount] = useState(0);
  useEffect(() => {
    if (enabled) console.log(count);
  }, [enabled, count]);
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

// BAD - hook gated by a condition
function Counter({ enabled }) {
  if (enabled) {
    const [count, setCount] = useState(0);   // <- on enabled=false, this hook is skipped
    return <span>{count}</span>;
  }
  return null;
}
```

The fix: lift the condition INSIDE the hook usage, not around it.

```tsx
// GOOD
function Counter({ enabled }) {
  const [count, setCount] = useState(0);
  if (!enabled) return null;
  return <span>{count}</span>;
  //          ^ early return AFTER the hook is fine
}
```

**2. Loops are the same trap.** Even when the iteration count is stable across renders, hooks-in-loops break linting, ESLint type narrowing, and future refactors.

```tsx
// BAD
items.forEach((it) => {
  const [open, setOpen] = useState(false);  // <- different hook count per render
});

// GOOD - one hook holds the whole structure
const [openMap, setOpenMap] = useState<Record<string, boolean>>({});
items.forEach((it) => {
  const open = openMap[it.id];
  // ...
});
```

**3. Short-circuit and ternary count as "conditional."**

```ts
// BAD
const debug = enabled && useDebugValue("on");           // hook gated by &&
const value = enabled ? useState(0) : [undefined];      // hook in ternary
```

**4. Try/catch around a hook is a Rules-of-Hooks violation, period.** Even if the hook itself never throws, the surrounding try/catch makes React unable to guarantee call order during the catch path on rerender.

```ts
// BAD
try {
  const [data] = useFetchedData(url);
} catch (e) {
  logError(e);
}

// GOOD - the hook is unconditional; error handling lives inside the hook or in an Error Boundary
const [data, error] = useFetchedData(url);
if (error) return <ErrorPanel error={error} />;
```

**5. Early return must come AFTER all hooks.**

```tsx
// BAD
function Profile({ id }) {
  if (!id) return null;
  const [user, setUser] = useState<User | null>(null);   // <- never runs on first render when id=null
  // ...
}

// GOOD
function Profile({ id }) {
  const [user, setUser] = useState<User | null>(null);
  if (!id) return null;
  // ...
}
```

### Custom hooks

**6. A custom hook's name MUST start with `use` followed by a capital letter.** That is how the verifier (and lint rules) identifies it. `useThing` is a hook; `userInfo` is not; `getUser` is a normal function.

**7. Inside a custom hook, the same Rules-of-Hooks apply.** Hooks inside the custom hook must be unconditional too. The custom hook IS a hook to its caller, so they must run on every render of the caller.

**8. Custom hooks may have early returns - but only AFTER all internal hooks have been called.** Same rule as components.

### What is NOT a violation

**9. Conditional logic INSIDE a hook's callback is fine.** `useEffect(() => { if (x) doThing(); }, [x])` - the IF is inside the effect body, not around the hook call.

**10. Calling a hook in a conditional `useEffect` body is fine.** What matters is the call to `useEffect` itself - the body runs whenever React decides to run it.

```tsx
// GOOD
useEffect(() => {
  if (enabled) fetchData();   // <- conditional logic INSIDE the effect, not around it
}, [enabled]);
```

## AI-written hook patterns to flag

| The model writes…                                                       | The verifier flags it as… |
|-------------------------------------------------------------------------|---------------------------|
| `if (cond) { const [x] = useState(0); ... }`                            | hook inside an if/else branch |
| `for (const i of list) useEffect(...)`                                  | hook inside a for loop    |
| `cond && useState(0)` or `cond || useState(0)` or `a ?? useState(0)`    | hook inside short-circuit |
| `cond ? useState(0) : null`                                             | hook inside ternary       |
| `try { const [x] = useFetch() } catch { ... }`                          | hook inside try/catch     |
| `if (loading) return <Spin />; const [x] = useState(0);`                | hook after early return   |

Every row above is a real production bug the verifier ships to prevent.

## Worked example

Bad component:

```tsx
function Cart({ open, items }: Props) {
  if (!open) return null;
  const [edited, setEdited] = useState(false);
  for (const it of items) {
    const [hover, setHover] = useState(false);
  }
  const totalNode = items.length > 0 && useMemo(() => sum(items), [items]);
  return <Drawer>{totalNode}</Drawer>;
}
```

What the verifier reports:

```
VIOLATION (Cart.tsx:3:3): Hook 'useState' called after an early return. Hooks must run on every render.
VIOLATION (Cart.tsx:5:5): Hook 'useState' called inside a for loop. Hooks must run unconditionally.
VIOLATION (Cart.tsx:7:43): Hook 'useMemo' called inside a short-circuit (&&/||/??) expression. Hooks must run unconditionally.
```

Fixed component:

```tsx
function Cart({ open, items }: Props) {
  const [edited, setEdited] = useState(false);
  const [hoveredId, setHoveredId] = useState<string | null>(null);
  const total = useMemo(() => sum(items), [items]);

  if (!open) return null;
  return (
    <Drawer>
      {items.length > 0 && <Total amount={total} />}
      {items.map(it => (
        <Row
          key={it.id}
          hovered={hoveredId === it.id}
          onHover={() => setHoveredId(it.id)}
        />
      ))}
    </Drawer>
  );
}
```

## Workflow

1. Write the component.
2. Run `./verify/forge run forge-react-hooks <file>` or let the post-edit hook fire on save.
3. If violations come back, **do not try to silence the verifier**. Restructure the component:
   - Lift hooks to the top.
   - Move conditional logic *inside* hook callbacks.
   - Use one larger state object instead of per-iteration state.
4. Re-run. Clean = ship.

## Related skills

- [[forge-frontend]] - JSX discipline, card-nesting, palette
- [[forge-tests]] - test quality including hook-specific tests via `renderHook`
- [[forge-typescript]] - strict typing for props and hook return types
