Skip to main content
React · lesson 17 of 22
React intermediate Lesson 17 of 22

Reading Promises with use, No Framework Needed

Read a promise directly inside a component with React's use() hook, then spike the two ways it goes wrong: a plain render() that never catches up, and a promise recreated every render that never settles at all.

By SkillByExample · Updated

react use suspense promises vitest

use(promise) reads a promise’s value straight inside a component — no await, no extra hook, just a function call in the middle of render. It’s the mechanism React.lazy already relies on internally, now available directly: pass use a promise instead of a React.lazy wrapper, and the nearest Suspense boundary shows its fallback until that promise settles. Pass it a different promise on every render instead of the same cached one, and it never settles at all — worked through below with real output, alongside two things the docs are easy to skim past: what plain render() does to a suspending component under Vitest, and that use(), unlike every other hook, can be called inside an if.

Project Setup

The same kind of Vitest project as Suspense and lazy, plus a tsconfig.json so npx tsx can run a plain script at the end without jsdom:

{
  "name": "react-app",
  "version": "1.0.0",
  "private": true,
  "type": "module",
  "dependencies": {
    "react": "19.3.1",
    "react-dom": "19.3.1"
  },
  "devDependencies": {
    "vitest": "5.0.5",
    "@testing-library/react": "16.3.3",
    "@testing-library/user-event": "14.6.8",
    "@testing-library/jest-dom": "7.0.1",
    "@vitejs/plugin-react": "6.1.2",
    "jsdom": "30.1.3",
    "tsx": "4.23.15"
  }
}
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    setupFiles: ["./vitest.setup.js"],
  },
});
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";

afterEach(() => {
  cleanup();
});
{
  "compilerOptions": {
    "module": "nodenext",
    "jsx": "react-jsx",
    "noEmit": true
  },
  "include": ["src"]
}

Every command below runs inside Docker’s node:24.18.1 image, from /root/react-app, with NO_COLOR=1, and grep strips Vitest’s own Start at/Duration lines, since their numbers change on every run:

$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npm install --no-audit --no-fund

added 101 packages in 21s
npm warn allow-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn allow-scripts   esbuild@0.28.2 (postinstall: node install.js)
npm warn allow-scripts
npm warn allow-scripts Run `npm approve-scripts --allow-scripts-pending` to review, or `npm approve-scripts <pkg>` to allow.

A Cached Promise Resolves — But Only If Something Awaits the Render That Suspends

getUser caches each promise it creates in a module-level Map, keyed by id, so calling it twice with the same id returns the exact same promise object both times:

// src/reading-promises-with-use/userCache.tsx
const cache = new Map();

export function getUser(id) {
  if (!cache.has(id)) {
    cache.set(
      id,
      new Promise((resolve) => {
        resolve({ id, name: "Ada" });
      }),
    );
  }
  return cache.get(id);
}

Profile reads that cached promise directly with use() — no useEffect, no useState for the loading flag:

// src/reading-promises-with-use/Profile.tsx
import { use } from "react";
import { getUser } from "./userCache.js";

export function Profile({ id }) {
  const user = use(getUser(id));
  return <p>Hello, {user.name}</p>;
}
// src/reading-promises-with-use/App.tsx
import { Suspense } from "react";
import { Profile } from "./Profile.js";

export function App() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Profile id={1} />
    </Suspense>
  );
}

A plain render(<App />), the way every earlier lesson in this topic has rendered a component, looks like it should work — the promise is already resolved by the time getUser creates it. It doesn’t:

// src/reading-promises-with-use/PlainRender.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { App } from "./App.js";

describe("use() on a cached promise with plain render", () => {
  it("never finds the real content inside a short wait window", async () => {
    render(<App />);

    const found = await screen
      .findByText("Hello, Ada", {}, { timeout: 300 })
      .then(() => true)
      .catch(() => false);

    console.log("found the resolved content within 300ms:", found);
    expect(found).toBe(false);
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/reading-promises-with-use/PlainRender.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stderr | src/reading-promises-with-use/PlainRender.test.tsx > use() on a cached promise with plain render > never finds the real content inside a short wait window
A component suspended inside an `act` scope, but the `act` call was not awaited. When testing React components that depend on asynchronous data, you must await the result:

await act(() => ...)

stdout | src/reading-promises-with-use/PlainRender.test.tsx > use() on a cached promise with plain render > never finds the real content inside a short wait window
found the resolved content within 300ms: false

 ✓ src/reading-promises-with-use/PlainRender.test.tsx > use() on a cached promise with plain render > never finds the real content inside a short wait window 325ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

React’s warning names the exact problem: render() wraps the first render in a synchronous act() call, and Profile suspends inside that call. React has more work left to do once the promise settles — show Profile’s real content instead of the fallback — but a synchronous act() has no way to wait around for a promise, so it logs the warning and returns immediately with the fallback still showing. findByText keeps polling the DOM after that, but nothing is left to drive the pending update forward within its own wait window, so it comes back empty.

Wrapping the same render() call in an awaited, async act() gives React somewhere to actually finish that work:

// src/reading-promises-with-use/ActRender.test.tsx
import { describe, it, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { App } from "./App.js";

describe("use() on a cached promise, rendered inside an awaited act", () => {
  it("resolves once the render itself is awaited", async () => {
    await act(async () => {
      render(<App />);
    });

    const greeting = await screen.findByText("Hello, Ada");
    expect(greeting).toHaveTextContent("Hello, Ada");
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/reading-promises-with-use/ActRender.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

 ✓ src/reading-promises-with-use/ActRender.test.tsx > use() on a cached promise, rendered inside an awaited act > resolves once the render itself is awaited 36ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

No warning this time, and the test passes cleanly: await act(async () => { render(<App />) }) gives React a chance to let the promise’s own microtask run and finish the component’s suspended work before act returns, so findByText has real content to find from the start. Any test that renders a component calling use() on a promise needs this await act(async () => { render(...) }) wrapper — not just a plain render().

A Promise Created Fresh on Every Render Never Settles

getUser works because the same promise object comes back on every call for a given id. Uncached breaks that on purpose — it builds a brand-new promise every single time it renders, instead of caching one:

// src/reading-promises-with-use/Uncached.tsx
import { use } from "react";

export let attempts = 0;

function uncachedDelay(ms) {
  // A new promise every call — nothing caches it between renders.
  return new Promise((resolve) => setTimeout(() => resolve("data"), ms));
}

export function Uncached({ ms }) {
  attempts++;
  use(uncachedDelay(ms));
  return <p>Loaded</p>;
}

This test renders Uncached with a plain createRoot call instead of Testing Library’s render, and sets IS_REACT_ACT_ENVIRONMENT = false so React doesn’t expect an act() wrapper around it at all — closer to how this would actually run in a browser, outside of any test harness:

// src/reading-promises-with-use/Uncached.test.tsx
import { describe, it, expect } from "vitest";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";
import { Uncached, attempts } from "./Uncached.js";

describe("use() on a freshly-created promise every render", () => {
  it("never settles, because no retry ever sees the same promise twice", async () => {
    globalThis.IS_REACT_ACT_ENVIRONMENT = false;

    const container = document.createElement("div");
    document.body.appendChild(container);
    const root = createRoot(container);

    root.render(
      <Suspense fallback={<p>Loading...</p>}>
        <Uncached ms={5} />
      </Suspense>,
    );

    await new Promise((resolve) => setTimeout(resolve, 500));

    console.log("content after 500ms:", container.textContent);
    console.log("rendered more than 20 times:", attempts > 20);

    expect(container.textContent).toBe("Loading...");
    expect(attempts).toBeGreaterThan(20);

    root.unmount();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/reading-promises-with-use/Uncached.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/reading-promises-with-use/Uncached.test.tsx > use() on a freshly-created promise every render > never settles, because no retry ever sees the same promise twice
content after 500ms: Loading...
rendered more than 20 times: true

 ✓ src/reading-promises-with-use/Uncached.test.tsx > use() on a freshly-created promise every render > never settles, because no retry ever sees the same promise twice 508ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

Half a second later, the DOM still shows Loading... — not because the 5ms delay inside uncachedDelay is slow, but because it never gets a chance to matter. Every time React retries Uncached, the component calls uncachedDelay again, which hands back a brand-new, still-pending promise that use() has never seen before; React has no choice but to suspend on it immediately and try again once that promise settles, which just creates another new one. The retry count varies between runs (this repo’s own runs land well past 20 in half a second — the exact figure depends on machine speed, which is exactly why it isn’t the thing to assert on), but the outcome doesn’t: the component can retry forever and the fallback never goes away, because “the promise settled” and “the promise use() is currently holding” are never the same promise twice. The fix is the same one getUser already applies above — cache the promise outside of render, keyed by whatever varies, so a retry reads the identical object it suspended on.

use() Can Be Called Inside an if — Unlike Any Other Hook

Every other hook has to run unconditionally, in the same order, on every render — that’s the rule a linter enforces for useState, useContext, and the rest. use() is explicitly exempt, including when what it’s reading is a context rather than a promise:

// src/reading-promises-with-use/ThemeContext.tsx
import { createContext } from "react";

export const ThemeContext = createContext("light");
// src/reading-promises-with-use/ConditionalTheme.tsx
import { use } from "react";
import { ThemeContext } from "./ThemeContext.js";

export function ConditionalTheme({ showTheme }) {
  if (showTheme) {
    const theme = use(ThemeContext);
    return <p>Theme: {theme}</p>;
  }
  return <p>Theme hidden</p>;
}

useContext(ThemeContext) written inside that same if would violate the rules of hooks — normally caught at lint time by eslint-plugin-react-hooks in a project that has it configured. use(ThemeContext) is exempt from that rule and renders correctly on both branches, still reading the right value after a re-render flips which branch runs:

// src/reading-promises-with-use/ConditionalTheme.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ThemeContext } from "./ThemeContext.js";
import { ConditionalTheme } from "./ConditionalTheme.js";

describe("use(context) inside an if", () => {
  it("reads the context only on the branch that calls use, including across a re-render", () => {
    const { rerender } = render(
      <ThemeContext.Provider value="dark">
        <ConditionalTheme showTheme={false} />
      </ThemeContext.Provider>,
    );
    console.log("showTheme=false:", screen.getByText(/Theme/).textContent);

    rerender(
      <ThemeContext.Provider value="dark">
        <ConditionalTheme showTheme={true} />
      </ThemeContext.Provider>,
    );
    console.log("after flipping to showTheme=true:", screen.getByText(/Theme/).textContent);

    expect(screen.getByText(/Theme/)).toHaveTextContent("Theme: dark");
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/reading-promises-with-use/ConditionalTheme.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/reading-promises-with-use/ConditionalTheme.test.tsx > use(context) inside an if > reads the context only on the branch that calls use, including across a re-render
showTheme=false: Theme hidden
after flipping to showTheme=true: Theme: dark

 ✓ src/reading-promises-with-use/ConditionalTheme.test.tsx > use(context) inside an if > reads the context only on the branch that calls use, including across a re-render 40ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

The branch with no use() call renders Theme hidden with no error at all — React doesn’t care that a hook call was skipped entirely on that render, only that no other hook’s own call count or order changed. Flip showTheme to true on the next render and use(ThemeContext) runs for the first time and reads dark immediately, same as useContext would have. The exemption only covers use() itself; every useState/useEffect/etc. elsewhere in the same component still has to run unconditionally.

renderToString Can’t Wait for use() — Something Else Has To

App, from the top of this lesson, reads a promise with use() inside a Suspense boundary. react-dom/server’s renderToString is synchronous — it returns a string, not a promise — so it has no way to pause for getUser’s promise to settle:

// src/reading-promises-with-use/ssr-render-to-string.tsx
import { renderToString } from "react-dom/server";
import { App } from "./App.js";

console.log(renderToString(<App />));
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx tsx src/reading-promises-with-use/ssr-render-to-string.tsx
<!--$!--><template data-msg="Switched to client rendering because the server rendering aborted due to:

The server used &quot;renderToString&quot; which does not support Suspense. If you intended for this Suspense boundary to render the fallback content on the server consider throwing an Error somewhere within the Suspense boundary. If you intended to have the server wait for the suspended component please switch to &quot;renderToPipeableStream&quot; which supports Suspense on the server" data-cstck="
    at Profile (/root/react-app/src/reading-promises-with-use/Profile.tsx:5:27)
    at Suspense (&lt;anonymous&gt;)
    at App (&lt;anonymous&gt;)"></template><p>Loading...</p><!--/$-->

Rather than silently printing stale or empty content, React emits the Suspense boundary’s own fallback (<p>Loading...</p>) plus a hidden <template data-msg="..."> element carrying the reason, wrapped in a pair of real HTML comments (<!--$!--> / <!--/$-->) that mark where the client needs to pick up the swap: renderToString doesn’t support Suspense, so it’s handing off to a second, client-side render to finish the job once JavaScript loads. That client-side render is still real work a browser has to do — this is a fallback for a renderer that can’t wait, not a substitute for one that can.

react-dom/static’s prerenderToNodeStream is built to actually wait. It returns a promise that only resolves once every pending Suspense boundary has settled, so the HTML it produces already has the real content in it:

// src/reading-promises-with-use/ssr-prerender.tsx
import { prerenderToNodeStream } from "react-dom/static";
import { App } from "./App.js";

const { prelude } = await prerenderToNodeStream(<App />);
prelude.pipe(process.stdout);
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx tsx src/reading-promises-with-use/ssr-prerender.tsx
<!--$--><p>Hello, <!-- -->Ada</p><!--/$-->

No fallback, no client-rendering hint — <p>Hello, Ada</p> is already in the markup prerenderToNodeStream produced, because it genuinely waited for getUser’s promise before resolving. The tradeoff is in the name: this is a prerender, not a stream of the first-available bytes — the whole page waits for the slowest Suspense boundary to settle before any HTML goes out at all. A framework’s streaming SSR (React’s own renderToPipeableStream, or Next.js’s App Router) sends the fast parts immediately and streams each suspended boundary’s real content in afterward, which Next.js’s rendering modes covers in detail — that choice between “wait for everything” and “stream as it resolves” is the actual decision a server has to make once any component on the page calls use() on a promise.

Practice

1. Change getUser to throw instead of resolving (new Promise((_, reject) => reject(new Error("not found")))) and confirm use() propagates that rejection the same way await would — something above Profile has to catch it, since Suspense only handles the pending case.

Solution
// src/reading-promises-with-use/practice/rejectedCache.tsx
const cache = new Map();

export function getFailingUser(id) {
  if (!cache.has(id)) {
    cache.set(
      id,
      new Promise((_, reject) => {
        reject(new Error(`user ${id} not found`));
      }),
    );
  }
  return cache.get(id);
}
// src/reading-promises-with-use/practice/RejectedProfile.tsx
import { use } from "react";
import { getFailingUser } from "./rejectedCache.js";

export function RejectedProfile({ id }) {
  const user = use(getFailingUser(id));
  return <p>Hello, {user.name}</p>;
}
// src/reading-promises-with-use/practice/RejectedProfile.test.tsx
import { describe, it, expect } from "vitest";
import { act, render } from "@testing-library/react";
import { RejectedProfile } from "./RejectedProfile.js";

describe("use() on a rejected promise", () => {
  it("throws out of the component, with nothing catching it", async () => {
    let caught: unknown;
    try {
      await act(async () => {
        render(<RejectedProfile id={1} />);
      });
    } catch (error) {
      caught = error;
    }
    console.log("caught:", caught instanceof Error ? caught.message : caught);
    expect(caught).toBeInstanceOf(Error);
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/reading-promises-with-use/practice/RejectedProfile.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/reading-promises-with-use/practice/RejectedProfile.test.tsx > use() on a rejected promise > throws out of the component, with nothing catching it
caught: user 1 not found

 ✓ src/reading-promises-with-use/practice/RejectedProfile.test.tsx > use() on a rejected promise > throws out of the component, with nothing catching it 46ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

The rejection surfaces as a thrown error out of act, exactly the shape a try/catch around a plain await would produce — use() really does behave like await for the failure case, not just the success case. Suspense only ever catches the pending state; an error boundary (covered in Error Boundaries) is what would need to sit above this component to turn that rejection into fallback UI instead of an uncaught exception.

2. Add a second, different id to the cached-promise example and confirm getUser creates a genuinely separate promise per id — rendering both Profiles inside one Suspense doesn’t block one on the other’s cache entry.

Solution
// src/reading-promises-with-use/practice/TwoProfiles.tsx
import { Suspense } from "react";
import { Profile } from "../Profile.js";

export function TwoProfiles() {
  return (
    <Suspense fallback={<p>Loading...</p>}>
      <Profile id={1} />
      <Profile id={2} />
    </Suspense>
  );
}
// src/reading-promises-with-use/practice/TwoProfiles.test.tsx
import { describe, it, expect } from "vitest";
import { act, render, screen } from "@testing-library/react";
import { TwoProfiles } from "./TwoProfiles.js";

describe("two Profiles reading two different cached promises", () => {
  it("both resolve under the same Suspense boundary", async () => {
    await act(async () => {
      render(<TwoProfiles />);
    });

    const greetings = screen.getAllByText(/Hello,/);
    console.log("greetings:", greetings.map((node) => node.textContent).join(" | "));

    expect(greetings).toHaveLength(2);
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/reading-promises-with-use/practice/TwoProfiles.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/reading-promises-with-use/practice/TwoProfiles.test.tsx > two Profiles reading two different cached promises > both resolve under the same Suspense boundary
greetings: Hello, Ada | Hello, Ada

 ✓ src/reading-promises-with-use/practice/TwoProfiles.test.tsx > two Profiles reading two different cached promises > both resolve under the same Suspense boundary 60ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

Both greetings show up, both reading Ada because getUser’s Map doesn’t vary the name by id — the point is that getUser(1) and getUser(2) are two separate Map entries, so neither Profile waits on the other’s promise. One shared Suspense boundary still only reveals once every child it’s directly responsible for has resolved, the same rule Suspense and lazy found for nested boundaries.

3. Raise Uncached’s delay to ms={50} and confirm the component still never settles, just with proportionally fewer retries in the same half second — the problem is the missing cache, not any particular delay length.

Solution
// src/reading-promises-with-use/practice/SlowDelay.test.tsx
import { describe, it, expect } from "vitest";
import { Suspense } from "react";
import { createRoot } from "react-dom/client";
import { Uncached, attempts } from "../Uncached.js";

describe("use() on a freshly-created promise, with a slower 50ms delay", () => {
  it("still never settles", async () => {
    globalThis.IS_REACT_ACT_ENVIRONMENT = false;

    const container = document.createElement("div");
    document.body.appendChild(container);
    const root = createRoot(container);

    root.render(
      <Suspense fallback={<p>Loading...</p>}>
        <Uncached ms={50} />
      </Suspense>,
    );

    await new Promise((resolve) => setTimeout(resolve, 500));

    console.log("content after 500ms:", container.textContent);
    console.log("rendered more than 5 times:", attempts > 5);

    expect(container.textContent).toBe("Loading...");
    expect(attempts).toBeGreaterThan(5);

    root.unmount();
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/reading-promises-with-use/practice/SlowDelay.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/reading-promises-with-use/practice/SlowDelay.test.tsx > use() on a freshly-created promise, with a slower 50ms delay > still never settles
content after 500ms: Loading...
rendered more than 5 times: true

 ✓ src/reading-promises-with-use/practice/SlowDelay.test.tsx > use() on a freshly-created promise, with a slower 50ms delay > still never settles 516ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

Same outcome as the lesson’s own ms={5} run, just paced out over fewer retries since each one now genuinely takes about 50ms: still stuck on Loading..., still comfortably past 5 attempts in half a second. A 10x longer delay doesn’t fix anything, because the delay was never the variable that mattered — it’s the lack of caching. A setTimeout of any length still produces a promise that’s pending the instant it’s created, and use() suspends on whatever promise it’s handed at the moment it’s handed it.

4. Give ConditionalTheme a third case — showTheme === "loud" — that reads the same context and renders it uppercased, still inside the same if/else if chain, and confirm all three branches work from one component.

Solution
// src/reading-promises-with-use/practice/ThreeWayTheme.tsx
import { use } from "react";
import { ThemeContext } from "../ThemeContext.js";

export function ThreeWayTheme({ mode }) {
  if (mode === "loud") {
    const theme = use(ThemeContext);
    return <p>THEME: {theme.toUpperCase()}</p>;
  }
  if (mode === "quiet") {
    const theme = use(ThemeContext);
    return <p>theme: {theme}</p>;
  }
  return <p>Theme hidden</p>;
}
// src/reading-promises-with-use/practice/ThreeWayTheme.test.tsx
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { ThemeContext } from "../ThemeContext.js";
import { ThreeWayTheme } from "./ThreeWayTheme.js";

describe("three branches, two of which call use(context)", () => {
  it("each branch renders its own shape, reading the same context value", () => {
    for (const mode of ["loud", "quiet", "off"]) {
      const { container, unmount } = render(
        <ThemeContext.Provider value="dark">
          <ThreeWayTheme mode={mode} />
        </ThemeContext.Provider>,
      );
      console.log(`mode=${mode}:`, container.querySelector("p").textContent);
      unmount();
    }
  });
});
$ docker run --rm -e NO_COLOR=1 -e NPM_CONFIG_UPDATE_NOTIFIER=false -v "$(pwd)":/root/react-app -v react-reading-promises-with-use-modules:/root/react-app/node_modules -w /root/react-app node:24.18.1 npx vitest run src/reading-promises-with-use/practice/ThreeWayTheme.test.tsx --reporter=verbose 2>&1 | grep -v -E "^\s*(Start at|Duration)"

 RUN  v5.0.5 /root/react-app

stdout | src/reading-promises-with-use/practice/ThreeWayTheme.test.tsx > three branches, two of which call use(context) > each branch renders its own shape, reading the same context value
mode=loud: THEME: DARK
mode=quiet: theme: dark
mode=off: Theme hidden

 ✓ src/reading-promises-with-use/practice/ThreeWayTheme.test.tsx > three branches, two of which call use(context) > each branch renders its own shape, reading the same context value 40ms

 Test Files  1 passed (1)
      Tests  1 passed (1)

All three renders work from a single component with no hooks-rule violation, even though two different branches each make their own, independent call to use(ThemeContext) and the third skips the hook entirely. Try writing the same component with useContext at the top of two separate if blocks instead, and it violates the rules of hooks — a project with eslint-plugin-react-hooks configured would flag it at lint time, before a test even ran — the conditional-call exemption is specific to use().

Frequently Asked Questions

What does React's use() hook actually do with a promise?
It reads the promise's eventual value directly inside render, the same way an awaited expression would in an async function, but render itself stays a synchronous function. If the promise hasn't settled yet, use() suspends the component — the same mechanism React.lazy relies on — so the nearest Suspense boundary shows its fallback until that exact promise resolves.
Why does render() followed by findByText fail for a component that calls use() on a promise?
Testing Library's render() wraps the initial render in a synchronous act() call. When the component suspends inside that call, React has more work left to do once the promise settles, but the synchronous act() has no way to wait for it — it logs a warning and returns immediately. findByText then polls a tree that never updates until something else drives that pending work forward, so it fails once its own wait window runs out instead of ever finding the resolved content.
Does a promise read with use() need to be memoized the way an object passed as a prop does?
It needs to be the same promise instance across retries, not merely an equal value. A promise created fresh inside render — new Promise(...) or a function call that doesn't cache its result — is a different object every time that component function runs, so use() never gets to observe the same promise settle twice in a row. React keeps retrying indefinitely because each retry hands it yet another promise that is, at that exact moment, still pending.
Can use() be called inside an if statement, unlike useState or useContext?
Yes. React's other hooks must run unconditionally, in the same order, on every render — that's the rule a linter enforces. use() is explicitly exempt: it can be called inside a condition, a loop, or after an early return, and calling it conditionally on a context works correctly, including across a re-render that flips which branch runs.
Does renderToString support a component that calls use() on a promise?
No. renderToString is synchronous and has no way to wait for a promise to settle, so when it reaches a Suspense boundary whose content hasn't resolved yet, it emits that boundary's fallback markup instead, along with a hidden template element carrying the reason, telling the browser to re-render that part of the page on the client. An API built for this case, such as react-dom/static's prerenderToNodeStream, awaits the pending content before producing any HTML at all.