C.W.K.
Stream
Lesson 07 of 07 · published

Rules of Hooks — And What use() Finally Relaxed

~13 min · rules-of-hooks, use, react-19

Level 0React Novice
0 XP0/54 lessons0/12 achievements
0/100 XP to next level100 XP to go0% complete
The Rules of Hooks were rigid because the reconciler needed them to be. React 19's use() is the first hook engineered to bend without breaking. Knowing which rule still applies — and which one specifically doesn't — is the entire point of this lesson.

The classic two rules

  1. Call hooks at the top level. Not inside conditions, not inside loops, not inside nested functions. The hook must run on every render in the same order.
  2. Call hooks from React functions. Function components or custom hooks (functions starting with use) — never regular event handlers or utility functions.

The reason: React tracks hook state by call order. useState(0) the first hook in your function is permanently 'slot 0' for this component instance. If a conditional skips a hook, the next render sees a different slot mapping — and state from one hook lands in another. The whole system silently breaks.

What use() is

A React 19 hook for reading the value of a Promise or a Context inline. It looks like useContext when you pass a Context (and works the same), but its breakthrough is that it can be called conditionally and inside loops.

Why use() is special

React was redesigned so that use()'s call site doesn't matter to the reconciler. It uses Suspense as the resume mechanism: when use() encounters a pending promise, React unwinds the component and re-runs it when the promise resolves. The 'slot tracking' that powers the other hooks doesn't apply.

This means you can write:

if (someCondition) {
  const data = use(somePromise); // OK!
}

Or:

for (const promise of promises) {
  const value = use(promise); // OK!
}

The trade-off

use() requires a Suspense boundary above it (for promises) and the calling component re-runs from the top when the promise resolves. That re-run is the cost. For most data-fetching, it's a win — your code reads top-to-bottom and Suspense handles the loading state declaratively. We'll exercise it in Track 5.

The other hooks still follow the rules. useState, useEffect, useRef, useContext, useReducer, custom hooks — all still must be called unconditionally at the top level. Don't read this lesson and start hiding hooks inside conditions. Only use() is the relaxation.

Code

What the classic rules forbid (with explanation)·tsx
// WRONG — hook inside a condition.
function Broken({ showCounter }: { showCounter: boolean }) {
  if (showCounter) {
    const [count, setCount] = useState(0); // Rules of Hooks violation
    return <p>{count}</p>;
  }
  return null;
}

// The pattern fails because the first render (with showCounter=true)
// puts useState at slot 0; the next render (with showCounter=false) skips
// the hook, so slot 0 is now empty — and React's tracker breaks.

// RIGHT — hook at top level, condition in the return.
function Fixed({ showCounter }: { showCounter: boolean }) {
  const [count, setCount] = useState(0);
  if (!showCounter) return null;
  return <p>{count}</p>;
}
use() — the exception·tsx
import { use, Suspense } from "react";

function Profile({ userPromise }: { userPromise: Promise<{ name: string }> }) {
  // Conditional use() — legal!
  const showName = window.location.hash === "#named";
  if (showName) {
    const user = use(userPromise);
    return <p>Hello, {user.name}</p>;
  }
  return <p>Anonymous</p>;
}

// use() suspends when the promise is pending, so the parent Suspense
// shows the fallback until resolution.
function Page() {
  const promise = fetch("/api/me").then((r) => r.json());
  return (
    <Suspense fallback={<p>Loading…</p>}>
      <Profile userPromise={promise} />
    </Suspense>
  );
}
ESLint config — let the linter enforce the rules·json
{
  "plugins": ["react-hooks"],
  "rules": {
    "react-hooks/rules-of-hooks": "error",
    "react-hooks/exhaustive-deps": "warn"
  }
}

External links

Exercise

Take a component that fetches data with useEffect + useState, and rewrite it using use() + Suspense (Track 5 lesson 4 builds this fully — start here). Notice the differences: no loading-state useState, no isLoading flag, no error state in the component (it goes to an error boundary). Compare the two versions side by side.
Hint
The pattern is: parent creates the promise, passes it as a prop, child calls use(promise). The Suspense boundary lives in the parent. Loading and error states are no longer the child's problem.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign inPlease sign in to comment.

No comments yet — be the first.