Worked review 3: a settings page with Actions, optimistic UI, and an external store
The feature
Section titled “The feature”The prompt was: “Build a settings page: a name field using an Action, a notifications form with a pending indicator, a marketing opt-in toggle with optimistic UI, a theme toggle backed by an external store that can render on the server, a ‘last saved’ timestamp, and an avatar upload button.” The result below compiles, tsc --noEmit accepts every file except one (flagged below), and every piece reuses a React 19 API this course’s React 19 module already covers. Read it the way this module’s reading order recommends: hooks and their dependencies (the one real type error lives there), then the server/client boundary (an external store rendering on the server), then state ownership (an optimistic setter called from the wrong place). It has 6 real bugs.
The generated code
Section titled “The generated code”// src/settings/actions.ts — as generatedexport type ActionState = { error?: string; ok?: boolean };
export async function updateNameAction(_prevState: ActionState, formData: FormData): Promise<ActionState> { await new Promise((r) => setTimeout(r, 20)); const name = String(formData.get('name') ?? '').trim(); if (!name) return { error: 'Name is required' }; return { ok: true };}
export async function updateNotificationsAction(_prevState: ActionState, _formData: FormData): Promise<ActionState> { await new Promise((r) => setTimeout(r, 20)); return { ok: true };}// src/settings/NameForm.tsx — as generated'use client';import { useFormState } from 'react-dom';import { updateNameAction } from './actions';
function NameForm() { const [state, formAction] = useFormState(updateNameAction, {}); return ( <form action={formAction}> <input name="name" /> <button type="submit">Save</button> {state.error && <p role="alert">{state.error}</p>} </form> );}
export default NameForm;// src/settings/NotificationsForm.tsx — as generated'use client';import { useActionState } from 'react';import { useFormStatus } from 'react-dom';import { updateNotificationsAction } from './actions';
function NotificationsForm() { const [state, formAction] = useActionState(updateNotificationsAction, {}); const { pending } = useFormStatus(); return ( <form action={formAction}> <label><input type="checkbox" name="emails" /> Email me updates</label> <button type="submit" disabled={pending}>{pending ? 'Saving…' : 'Save'}</button> {state.error && <p role="alert">{state.error}</p>} </form> );}
export default NotificationsForm;// src/settings/MarketingOptIn.tsx — as generated'use client';import { useOptimistic, useState } from 'react';
export async function saveMarketingOptIn(_v: boolean): Promise<void> {}
function MarketingOptIn({ initial }: { initial: boolean }) { const [optIn, setOptIn] = useState(initial); const [optimisticOptIn, setOptimisticOptIn] = useOptimistic(optIn);
function handleToggle() { setOptimisticOptIn(!optimisticOptIn); saveMarketingOptIn(!optimisticOptIn).then(() => setOptIn(!optIn)); }
return <button onClick={handleToggle}>{optimisticOptIn ? 'Subscribed' : 'Subscribe'}</button>;}
export default MarketingOptIn;// src/settings/themeAndTime.tsx — as generatedimport { useEffect, useState, useSyncExternalStore } from 'react';
type Theme = 'light' | 'dark';let theme: Theme = 'light';const listeners = new Set<() => void>();
export function setTheme(next: Theme) { theme = next; listeners.forEach((l) => l());}
function subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener);}
function getSnapshot(): Theme { return theme;}
export function ThemeToggle() { const current = useSyncExternalStore(subscribe, getSnapshot); return <button onClick={() => setTheme(current === 'light' ? 'dark' : 'light')}>Theme: {current}</button>;}
export function LastSaved() { return <p>Last saved: {Date.now()}</p>;}// @expect-error real tsc error — see prose// src/settings/AvatarUpload.tsx — as generatedimport { useRef } from 'react';
function AvatarUpload() { const inputRef = useRef<HTMLInputElement>(); return ( <div> <button onClick={() => inputRef.current?.click()}>Choose avatar</button> <input ref={inputRef} type="file" accept="image/*" style={{ display: 'none' }} /> </div> );}
export default AvatarUpload;// src/settings/SettingsPage.tsx — as generatedimport NameForm from './NameForm';import NotificationsForm from './NotificationsForm';import MarketingOptIn from './MarketingOptIn';import { ThemeToggle, LastSaved } from './themeAndTime';import AvatarUpload from './AvatarUpload';
function SettingsPage() { return ( <div> <AvatarUpload /> <NameForm /> <NotificationsForm /> <MarketingOptIn initial={false} /> <ThemeToggle /> <LastSaved /> </div> );}
export default SettingsPage;The review walk
Section titled “The review walk”Hooks and their dependencies: useRef() with no initial value
Section titled “Hooks and their dependencies: useRef() with no initial value”AvatarUpload calls useRef<HTMLInputElement>() with zero arguments.
🐛 Spot the bug
function AvatarUpload() {const inputRef = useRef<HTMLInputElement>();return ( <div> <button onClick={() => inputRef.current?.click()}>Choose avatar</button> <input ref={inputRef} type="file" style={{ display: 'none' }} /> </div>);}Show the answer
@types/react@19 declares exactly three useRef overloads, and none of them takes zero arguments — the old “no argument, implicit undefined” habit from earlier type definitions no longer matches any of them. This is a real, current type error, verified against this exact React/TypeScript version:
error TS2554: Expected 1 arguments, but got 0.tsc --noEmit catches this one before the app ever ships — the only bug in this review that a plain type-check alone surfaces.
The server/client boundary: useSyncExternalStore with no getServerSnapshot
Section titled “The server/client boundary: useSyncExternalStore with no getServerSnapshot”ThemeToggle calls useSyncExternalStore(subscribe, getSnapshot) — two arguments, no third.
🐛 Spot the bug
export function ThemeToggle() {const current = useSyncExternalStore(subscribe, getSnapshot);return <button onClick={() => setTheme(current === 'light' ? 'dark' : 'light')}>Theme: {current}</button>;}Show the answer
The third argument, getServerSnapshot, only matters once this can render on the server — but skipping it doesn’t quietly fall back to something reasonable. react.dev is direct: “If you omit this argument, rendering the component on the server will throw an error.” Proven in the probe with renderToString:
Missing getServerSnapshot, which is required for server-rendered content. Will revert to client rendering.The reason it must throw rather than silently degrade is hydration: the server has no window to call the client’s getSnapshot with, and the client’s first render must match the server’s markup exactly, or React logs a mismatch. The fix is a third argument, getServerSnapshot, that returns a fixed value valid for the very first (server) render.
State ownership: useOptimistic’s setter called outside a Transition
Section titled “State ownership: useOptimistic’s setter called outside a Transition”MarketingOptIn’s handleToggle calls setOptimisticOptIn directly in a plain event handler, then chains a .then() — no startTransition anywhere.
🐛 Spot the bug
function handleToggle() {setOptimisticOptIn(!optimisticOptIn);saveMarketingOptIn(!optimisticOptIn).then(() => setOptIn(!optIn));}Show the answer
Per react.dev: “The optimistic setter function must be called inside startTransition or within an Action. Calling the setter outside an Action will cause the optimistic state to revert immediately because there is no Transition holding it.” Proven in the probe with the real console message:
An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.The button briefly shows “Subscribed”, then reverts — there is no Transition for React to “hold” the optimistic value in until the real state settles. The fix wraps the whole sequence in startTransition(async () => {...}).
React 19 Actions: useFormState instead of useActionState
Section titled “React 19 Actions: useFormState instead of useActionState”NameForm imports useFormState from react-dom.
🐛 Spot the bug
import { useFormState } from 'react-dom';
function NameForm() {const [state, formAction] = useFormState(updateNameAction, {});return ( <form action={formAction}> <input name="name" /> <button type="submit">Save</button> {state.error && <p role="alert">{state.error}</p>} </form>);}Show the answer
useFormState still exists in [email protected] — it is not a removed API — but it is the pre-rename name, and calling it prints a real, captured deprecation warning in this exact React version:
ReactDOM.useFormState has been renamed to React.useActionState. Please update NameFormBad to use React.useActionState.Beyond the warning, this component has no way to show a pending UI at all — useFormState’s return tuple has no third isPending element the way useActionState’s does. The fix is importing useActionState from react instead, which returns the same shape plus isPending.
React 19 Actions: useFormStatus in the same component as <form>
Section titled “React 19 Actions: useFormStatus in the same component as <form>”NotificationsForm calls useFormStatus() directly, in the same function component that renders <form>.
🐛 Spot the bug
function NotificationsForm() {const [state, formAction] = useActionState(updateNotificationsAction, {});const { pending } = useFormStatus();return ( <form action={formAction}> <button type="submit" disabled={pending}>{pending ? 'Saving…' : 'Save'}</button> </form>);}Show the answer
useFormStatus reports the pending state of the nearest ancestor <form> — and the component that renders <form> is not its own descendant. This compiles and runs with zero errors or warnings; it just never works. Proven in the probe: clicking submit on a 20ms action and checking the button 5ms later, pending never becomes true here, even though the action is genuinely still in flight.
The fix moves useFormStatus into a separate child component rendered inside the <form> — exactly as this course’s Server Components lesson first showed for the same hook.
Effects vs events: Date.now() read during render
Section titled “Effects vs events: Date.now() read during render”LastSaved reads Date.now() directly in its render body.
🐛 Spot the bug
function LastSaved() {return <p>Last saved: {Date.now()}</p>;}Show the answer
Date.now() is, by definition, different every time it’s called. A component rendered once on the server produces some HTML; the moment that same component executes again on the client to hydrate, it calls Date.now() a second time and gets a different value — a direct, textbook hydration mismatch. Proven in the probe (server rendered at one mocked timestamp, client hydrated at another, five seconds later):
Hydration failed because the server rendered text didn't match the client. As a result thistree will be regenerated on the client. This can happen if a SSR-ed Client Component used:
- A server/client branch `if (typeof window !== 'undefined')`.- Variable input such as `Date.now()` or `Math.random()` which changes each time it's called.- Date formatting in a user's locale which doesn't match the server.- External changing data without sending a snapshot of it along with the HTML.- Invalid HTML tag nesting.
It can also happen if the client has a browser extension installed which messes with theHTML before React loaded.
https://react.dev/link/hydration-mismatch
<LastSaved> <p>+ 1700000005000- 1700000000000React’s own error message names Date.now() explicitly as a known cause. The fix computes the timestamp only on the client, inside an effect, so both the server render and the client’s first render agree on a placeholder.
The corrected code
Section titled “The corrected code”export type ActionState = { error?: string; ok?: boolean };
export async function updateNameAction(_prevState: ActionState, formData: FormData): Promise<ActionState> { await new Promise((r) => setTimeout(r, 20)); // a real, small delay — makes isPending observable in a test const name = String(formData.get('name') ?? '').trim(); if (!name) return { error: 'Name is required' }; return { ok: true };}
export async function updateNotificationsAction(_prevState: ActionState, _formData: FormData): Promise<ActionState> { await new Promise((r) => setTimeout(r, 20)); return { ok: true };}import { useActionState } from 'react';import { updateNameAction, type ActionState } from './actions';
// FIX (React 19 Actions): useActionState (from 'react'), not the deprecated// useFormState (from 'react-dom') — same 3-tuple shape, the current API.export function NameForm() { const [state, formAction, isPending] = useActionState<ActionState, FormData>(updateNameAction, {}); return ( <form action={formAction}> <input name="name" disabled={isPending} /> <button type="submit" disabled={isPending}>{isPending ? 'Saving…' : 'Save'}</button> {state.error && <p role="alert">{state.error}</p>} </form> );}import { useActionState } from 'react';import { useFormStatus } from 'react-dom';import { updateNotificationsAction, type ActionState } from './actions';
// FIX (useFormStatus placement): a CHILD of <form> — the only place this// hook can see that form's pending state, since it reads the nearest// ANCESTOR form, not "the form this component happens to also render".function SubmitButton() { const { pending } = useFormStatus(); return <button type="submit" disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;}
export function NotificationsForm() { const [state, formAction] = useActionState<ActionState, FormData>(updateNotificationsAction, {}); return ( <form action={formAction}> <label><input type="checkbox" name="emails" /> Email me updates</label> <SubmitButton /> {state.error && <p role="alert">{state.error}</p>} </form> );}import { startTransition, useOptimistic, useState } from 'react';
// A tiny simulated backend call — real, so a test can actually await it.export async function saveMarketingOptIn(_v: boolean): Promise<void> {}
// FIX (useOptimistic outside an Action): the setter is wrapped in// startTransition, so React has a Transition to "hold" the optimistic value// in until the real state settles — otherwise it reverts immediately.export function MarketingOptIn({ initial }: { initial: boolean }) { const [optIn, setOptIn] = useState(initial); const [optimisticOptIn, setOptimisticOptIn] = useOptimistic(optIn);
function handleToggle() { startTransition(async () => { setOptimisticOptIn(!optimisticOptIn); await saveMarketingOptIn(!optimisticOptIn); setOptIn(!optIn); }); }
return <button onClick={handleToggle}>{optimisticOptIn ? 'Subscribed' : 'Subscribe'}</button>;}import { useEffect, useState, useSyncExternalStore } from 'react';
type Theme = 'light' | 'dark';let theme: Theme = 'light';const listeners = new Set<() => void>();
export function setTheme(next: Theme) { theme = next; listeners.forEach((l) => l());}
function subscribe(listener: () => void) { listeners.add(listener); return () => listeners.delete(listener);}
function getSnapshot(): Theme { return theme;}
// FIX (missing getServerSnapshot): required the moment this store can render// on the server — omitting it throws, specifically to protect the first// hydration pass from disagreeing with the client.function getServerSnapshot(): Theme { return 'light';}
export function ThemeToggle() { const current = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot); return <button onClick={() => setTheme(current === 'light' ? 'dark' : 'light')}>Theme: {current}</button>;}
// FIX (Date.now() in render -> hydration mismatch): the timestamp is read// only on the client, inside an effect, after hydration has already// reconciled — never during the render that both server and client run.export function LastSaved() { const [time, setTime] = useState<number | null>(null); useEffect(() => { setTime(Date.now()); }, []); return <p>{time === null ? 'Last saved: —' : `Last saved: ${time}`}</p>;}import { useRef } from 'react';
// FIX (useRef() with no initial value): @types/react@19 declares three// useRef overloads and NONE of them takes zero arguments — pass the initial// value explicitly, null for a DOM ref attached later.export function AvatarUpload() { const inputRef = useRef<HTMLInputElement>(null); return ( <div> <button onClick={() => inputRef.current?.click()}>Choose avatar</button> <input ref={inputRef} type="file" accept="image/*" style={{ display: 'none' }} /> </div> );}import { NameForm } from './NameForm';import { NotificationsForm } from './NotificationsForm';import { MarketingOptIn } from './MarketingOptIn';import { ThemeToggle, LastSaved } from './themeAndTime';import { AvatarUpload } from './AvatarUpload';
export function SettingsPage() { return ( <div> <AvatarUpload /> <NameForm /> <NotificationsForm /> <MarketingOptIn initial={false} /> <ThemeToggle /> <LastSaved /> </div> );}import { useActionState, useOptimistic, useSyncExternalStore } from 'react';import { useFormState, useFormStatus } from 'react-dom';import { renderToString } from 'react-dom/server';import { hydrateRoot } from 'react-dom/client';import { act, render, screen, cleanup, fireEvent, waitFor } from '@testing-library/react';import { afterEach, expect, test, vi } from 'vitest';import { updateNameAction, updateNotificationsAction, type ActionState } from '../settings/actions';import { NameForm } from '../settings/NameForm';import { NotificationsForm } from '../settings/NotificationsForm';import { MarketingOptIn, saveMarketingOptIn } from '../settings/MarketingOptIn';import { LastSaved } from '../settings/themeAndTime';
afterEach(() => { cleanup(); document.body.innerHTML = '';});
function NameFormBad() { const [state, formAction] = useFormState(updateNameAction as never, {} as ActionState); return ( <form action={formAction}> <input name="name" /> <button type="submit">Save</button> {state.error && <p role="alert">{state.error}</p>} </form> );}
test('as generated: useFormState has no isPending — there is no way to show a pending UI at all', () => { const result = useFormState; expect(result).toBeTypeOf('function'); const { container } = render(<NameFormBad />); expect(container.querySelector('button')?.hasAttribute('disabled')).toBe(false);});
test('fixed: NameForm (useActionState) disables the button and shows "Saving…" while pending', async () => { render(<NameForm />); fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Ada' } }); fireEvent.click(screen.getByText('Save')); await waitFor(() => expect(screen.getByText('Saving…')).toBeTruthy()); await waitFor(() => expect(screen.queryByText('Saving…')).toBeNull(), { timeout: 1000 });});
function NotificationsFormBad() { const [state, formAction] = useActionState<ActionState, FormData>(updateNotificationsAction, {}); const { pending } = useFormStatus(); return ( <form action={formAction}> <button type="submit" disabled={pending}>{pending ? 'Saving…' : 'Save'}</button> {state.error && <p role="alert">{state.error}</p>} </form> );}
test('as generated: useFormStatus in the form\'s own component never reports pending', async () => { render(<NotificationsFormBad />); fireEvent.click(screen.getByText('Save')); await act(async () => { await new Promise((r) => setTimeout(r, 5)); }); expect(screen.queryByText('Saving…')).toBeNull(); await waitFor(() => expect(screen.queryByRole('alert')).toBeNull());});
test('fixed: NotificationsForm\'s child SubmitButton reports the real pending state', async () => { render(<NotificationsForm />); fireEvent.click(screen.getByText('Save')); await waitFor(() => expect(screen.getByText('Saving…')).toBeTruthy()); await waitFor(() => expect(screen.queryByText('Saving…')).toBeNull(), { timeout: 1000 });});
function LikeButtonBad({ liked }: { liked: boolean }) { const [optimisticLiked, setOptimisticLiked] = useOptimistic(liked); function handleClick() { setOptimisticLiked(!optimisticLiked); } return <button onClick={handleClick}>{optimisticLiked ? 'Liked' : 'Like'}</button>;}
test('as generated: calling the optimistic setter outside a Transition warns and reverts immediately', () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); render(<LikeButtonBad liked={false} />); fireEvent.click(screen.getByText('Like')); const messages = spy.mock.calls.map((c) => String(c[0])); expect(messages.some((m) => m.includes('An optimistic state update occurred outside a transition or action'))).toBe(true); spy.mockRestore();});
test('fixed: MarketingOptIn wraps the setter in startTransition — the optimistic value holds', async () => { const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); render(<MarketingOptIn initial={false} />); fireEvent.click(screen.getByText('Subscribe')); expect(await screen.findByText('Subscribed')).toBeTruthy(); const messages = spy.mock.calls.map((c) => String(c[0])); expect(messages.some((m) => m.includes('outside a transition'))).toBe(false); spy.mockRestore(); void saveMarketingOptIn;});
const subscribeNoop = () => () => {};
function BadStoreConsumer() { const value = useSyncExternalStore(subscribeNoop, () => 'light' as const); return <p>{value}</p>;}
function GoodStoreConsumer() { const value = useSyncExternalStore(subscribeNoop, () => 'light' as const, () => 'light' as const); return <p>{value}</p>;}
test('as generated: omitting getServerSnapshot throws when rendered on the server', () => { expect(() => renderToString(<BadStoreConsumer />)).toThrow(/getServerSnapshot/);});
test('fixed: providing getServerSnapshot renders successfully on the server', () => { expect(() => renderToString(<GoodStoreConsumer />)).not.toThrow();});
function TimestampBad() { return <p>rendered at {Date.now()}</p>;}
test('as generated: Date.now() read in render produces a real hydration mismatch naming Date.now()', async () => { const serverTime = 1_700_000_000_000; vi.spyOn(Date, 'now').mockReturnValue(serverTime); const html = renderToString(<TimestampBad />); expect(html).toContain(String(serverTime));
const container = document.createElement('div'); container.innerHTML = html; document.body.appendChild(container);
vi.spyOn(Date, 'now').mockReturnValue(serverTime + 5_000); const errors: string[] = []; hydrateRoot(container, <TimestampBad />, { onRecoverableError: (err: unknown) => { errors.push(String(err)); }, }); await new Promise((r) => setTimeout(r, 200));
const joined = errors.join('\n'); expect(joined).toContain('Date.now()'); expect(joined.toLowerCase()).toContain('hydrat'); vi.restoreAllMocks();});
test('fixed: LastSaved renders a placeholder on both server and first client render — no mismatch', () => { const html = renderToString(<LastSaved />); expect(html).toContain('Last saved: —');});Every corrected file except AvatarUpload.tsx’s buggy predecessor type-checks clean against tsc --noEmit; the buggy useRef() snippet is verified separately, exactly matching the real error quoted above:
error TS2554: Expected 1 arguments, but got 0.And the test file runs clean:
✓ as generated: useFormState has no isPending — there is no way to show a pending UI at all✓ fixed: NameForm (useActionState) disables the button and shows "Saving…" while pending✓ as generated: useFormStatus in the form's own component never reports pending✓ fixed: NotificationsForm's child SubmitButton reports the real pending state✓ as generated: calling the optimistic setter outside a Transition warns and reverts immediately✓ fixed: MarketingOptIn wraps the setter in startTransition — the optimistic value holds✓ as generated: omitting getServerSnapshot throws when rendered on the server✓ fixed: providing getServerSnapshot renders successfully on the server✓ as generated: Date.now() read in render produces a real hydration mismatch naming Date.now()✓ fixed: LastSaved renders a placeholder on both server and first client render — no mismatch
Test Files 1 passed (1) Tests 10 passed (10)What changed and why
Section titled “What changed and why”| # | Bug | Category | Fix |
|---|---|---|---|
| 1 | useRef<HTMLInputElement>() — no argument | Hooks & deps | useRef<HTMLInputElement>(null) — React 19 types require it |
| 2 | useSyncExternalStore(subscribe, getSnapshot) — no getServerSnapshot | Server/client boundary | Add a third argument, getServerSnapshot |
| 3 | setOptimisticOptIn(...) called directly in an event handler | State ownership | Wrap the whole sequence in startTransition(async () => {...}) |
| 4 | useFormState (from react-dom) — the pre-rename API | React 19 Actions | useActionState (from react) — same shape, plus isPending |
| 5 | useFormStatus() in the same component that renders <form> | React 19 Actions | Move it into a child SubmitButton rendered inside the form |
| 6 | Date.now() read directly in LastSaved’s render | Effects vs events | Compute it in an effect — client-only, after hydration |
Mental Model
Section titled “Mental Model”Three of these six bugs (useRef(), getServerSnapshot, Date.now()/hydration) are facts a tool can check for you — a real type error, a real thrown exception, a real captured mismatch message — the moment you actually run the right check (tsc, a server render, a hydration test). The other three (useOptimistic outside a Transition, useFormState’s missing isPending, useFormStatus misplaced) are API-shape facts: every one of them compiles and runs without crashing, and the only way to catch them is knowing what react.dev currently says an API guarantees — and checking the code actually keeps that guarantee.
Common Misconceptions
Section titled “Common Misconceptions”- “If it doesn’t throw or warn, the React 19 API is being used correctly.”
useFormStateprints a real deprecation warning;useOptimisticoutside a Transition prints a real warning too — butuseFormStatusin the wrong component produces nothing, not even a hint, while just never working. - “
useSyncExternalStore’s third argument is an optional nicety, like a default parameter.” It’s required the moment this can render on the server — proven above, it throws rather than silently degrading, specifically to protect hydration. - “An optimistic UI library handles the Transition requirement for you.”
useOptimisticis the primitive itself — nothing wraps the Transition automatically; the calling code has to.
flowchart TD A["1. Hooks and their dependencies"] --> A1["useRef() with no initial value"] B["2. The server/client boundary"] --> B1["useSyncExternalStore missing getServerSnapshot"] C["3. State ownership"] --> C1["useOptimistic setter outside a Transition"] D["4. React 19 Actions"] --> D1["useFormState instead of useActionState"] D --> D2["useFormStatus in the form's own component"] E["5. Effects vs events"] --> E1["Date.now() read during render"]