diff --git a/README.md b/README.md index 8cb27ab..7dbf139 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,9 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b - React Server Components: server-first rendering, less client JavaScript - Streaming SSR with Suspense boundaries and progressive hydration - Suspense-based data fetching instead of scattered loading flags +- Server Actions: mutations that run on the server with no client fetch handler +- Optimistic UI with `useOptimistic`: predicted state that reverts on failure +- `useActionState` and `useFormStatus` for form result, error, and pending states - Request-scoped memoization for fetch deduplication (the `React.cache()` pattern) - Out-of-order streaming: boundaries flush by resolution time, not tree order - App Router file conventions (`layout`, `page`, `loading`, `error`) @@ -26,6 +29,8 @@ Front-end work has changed a lot since the SPA-everything era: rendering moved b - Design tokens: a typed token tree in `src/tokens` that generates the app's CSS custom properties. The root layout injects the generated `:root` rule at render time, so the token module is the runtime source and there is no second copy to drift. See the `/tokens` route. - Server Components + streaming: the `/streaming` route is a Server Component whose three cards are each their own async Server Component behind a Suspense boundary. The shell flushes first, then each card streams in as its own fetch settles, fastest first. Data comes from a small simulated layer built on a request-scoped memoizing loader (the `React.cache()` pattern), so components that read the same key during one render share a single fetch and a rejection is cached the same way. Tests cover the loader dedup, out-of-order settle order, and the async components rendered to static markup. See the `/streaming` route. +- Server actions & optimistic UI: the `/data-patterns` route mutates a note list through a Server Action, so the browser never runs a fetch handler or a client API layer. `useActionState` holds the confirmed list plus the last error, `useOptimistic` overlays the in-flight note on top of it, and `useFormStatus` drives the pending button. The overlay is only a prediction, so React discards it when the action settles and a rejected write reverts on its own with no manual rollback. The mutation core (`applyAdd`) takes an injected store, which keeps validation, the fail path, and the store-throws path testable without the Next runtime. Tests cover trimming and length validation, the optimistic reducer folding pending dispatches newest-first, and confirmed state staying put on every failure mode. See the `/data-patterns` route. + ## Stack - Next.js 15 (App Router) and React 19 @@ -64,6 +69,22 @@ const store = createDataStore() // one per request, so the loaders memoize per r ``` +Optimistic UI overlays the in-flight write on the confirmed list, and the mutation core stays a plain function so it is tested without the framework: + +```tsx +const [state, dispatch] = useActionState(addNote, { notes: [], error: null }) +const [optimistic, addOptimistic] = useOptimistic(toDisplayList(state.notes), reduceOptimistic) + +async function onSubmit(formData: FormData) { + addOptimistic({ tempId: crypto.randomUUID(), text: String(formData.get('text')) }) + await dispatch(formData) // Server Action; React drops the overlay when it settles +} + +// applyAdd({ notes: [], error: null }, { text: ' hi ', fail: false }, store) +// -> { notes: [{ id, text: 'hi', createdAt }], error: null } +// fail or a thrown store -> confirmed list unchanged, error set +``` + Styles reference tokens through a typed helper, so a bad name fails to compile: ```tsx diff --git a/app/data-patterns/loading.tsx b/app/data-patterns/loading.tsx new file mode 100644 index 0000000..2387c33 --- /dev/null +++ b/app/data-patterns/loading.tsx @@ -0,0 +1,25 @@ +import { token } from '@/tokens' + +export default function Loading() { + return ( +
+ {Array.from({ length: 3 }, (_, i) => ( + + ))} +
+ ) +} diff --git a/app/data-patterns/note-form.tsx b/app/data-patterns/note-form.tsx new file mode 100644 index 0000000..74b6e68 --- /dev/null +++ b/app/data-patterns/note-form.tsx @@ -0,0 +1,111 @@ +'use client' + +import { useActionState, useOptimistic, useRef } from 'react' +import { useFormStatus } from 'react-dom' +import { MAX_NOTE_LENGTH, reduceOptimistic, toDisplayList, type FormState } from '@/data-patterns/notes' +import { token } from '@/tokens' + +interface NoteFormProps { + action: (prev: FormState, formData: FormData) => Promise + initial: FormState +} + +const cardStyle: React.CSSProperties = { + padding: token('space', '4'), + border: '1px solid var(--color-border)', + borderRadius: token('radius', 'md'), + background: 'var(--color-surface)' +} + +function SubmitButton() { + const { pending } = useFormStatus() + return ( + + ) +} + +export function NoteForm({ action, initial }: NoteFormProps) { + const [state, dispatch] = useActionState(action, initial) + const [optimistic, addOptimistic] = useOptimistic(toDisplayList(state.notes), reduceOptimistic) + const formRef = useRef(null) + + async function onSubmit(formData: FormData) { + const text = String(formData.get('text') ?? '').trim() + if (text.length > 0) addOptimistic({ tempId: crypto.randomUUID(), text }) + formRef.current?.reset() + await dispatch(formData) + } + + return ( +
+
+ + + +
+ +
+
+ + {state.error !== null ? ( +

+ {state.error} +

+ ) : null} + + {optimistic.length === 0 ? ( +

No notes yet. Add the first one.

+ ) : ( +
    + {optimistic.map((note) => ( +
  • + {note.text} + {note.pending ? ( + + saving... + + ) : null} +
  • + ))} +
+ )} +
+ ) +} diff --git a/app/data-patterns/page.tsx b/app/data-patterns/page.tsx new file mode 100644 index 0000000..927af54 --- /dev/null +++ b/app/data-patterns/page.tsx @@ -0,0 +1,42 @@ +import { applyAdd, createNotesStore, type FormState } from '@/data-patterns/notes' +import { token } from '@/tokens' +import { NoteForm } from './note-form' + +export const metadata = { + title: 'Data fetching, server actions & optimistic UI - Modern Frontend Lab' +} + +const SEED: FormState = { notes: [], error: null } + +async function addNote(prev: FormState, formData: FormData): Promise { + 'use server' + const store = createNotesStore() + return applyAdd(prev, { text: formData.get('text'), fail: formData.get('fail') === 'on' }, store) +} + +export default function DataPatternsPage() { + return ( +
+

Data fetching, server actions & optimistic UI

+

+ The form posts to a Server Action, so there is no client fetch handler and no client-side API layer. The new note + shows the instant you submit via useOptimistic, then reconciles against what the server returns. Tick + “simulate a failure” to watch the optimistic note revert and an error render in its place. +

+ + + +
+

Why this shape

+

+ Three states share one flow. useActionState holds the confirmed list and the last error and exposes + the pending flag. useOptimistic overlays the in-flight note on top of that confirmed list, and React + discards the overlay automatically when the action settles, so a rejected write reverts on its own with no manual + rollback. useFormStatus reads the pending state from inside the form to disable the button. The + overlay is only a prediction: the server is still the source of truth, and on error the confirmed list never + moved. +

+
+
+ ) +} diff --git a/app/page.tsx b/app/page.tsx index be835e7..b7a9b15 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -15,9 +15,10 @@ const CONCEPTS: readonly Concept[] = [ href: '/streaming' }, { - title: 'Suspense data fetching', - description: 'Coordinate loading states with Suspense boundaries instead of scattered isLoading flags.', - status: 'planned' + title: 'Server actions & optimistic UI', + description: 'Mutate through a Server Action and show the result instantly with useOptimistic, reverting on failure.', + status: 'done', + href: '/data-patterns' }, { title: 'Design tokens', diff --git a/src/data-patterns/notes.ts b/src/data-patterns/notes.ts new file mode 100644 index 0000000..2862f08 --- /dev/null +++ b/src/data-patterns/notes.ts @@ -0,0 +1,91 @@ +export interface Note { + id: string + text: string + createdAt: number +} + +export const MAX_NOTE_LENGTH = 140 + +export type Validation = + | { ok: true; text: string } + | { ok: false; error: string } + +export function validateNoteText(raw: unknown): Validation { + if (typeof raw !== 'string') return { ok: false, error: 'A note is required.' } + const text = raw.trim() + if (text.length === 0) return { ok: false, error: 'A note cannot be empty.' } + if (text.length > MAX_NOTE_LENGTH) { + return { ok: false, error: `Keep it to ${MAX_NOTE_LENGTH} characters or fewer.` } + } + return { ok: true, text } +} + +export interface PendingNote { + tempId: string + text: string +} + +export type DisplayNote = + | { pending: false; id: string; text: string; createdAt: number } + | { pending: true; tempId: string; text: string } + +export function toDisplay(note: Note): DisplayNote { + return { pending: false, id: note.id, text: note.text, createdAt: note.createdAt } +} + +export function toDisplayList(notes: readonly Note[]): DisplayNote[] { + return notes.map(toDisplay) +} + +// useOptimistic folds each pending dispatch over the confirmed base one at a +// time, so this reducer only handles a single add. Newest sits on top, matching +// how the confirmed list is stored (most recent first). +export function reduceOptimistic(state: readonly DisplayNote[], pending: PendingNote): DisplayNote[] { + return [{ pending: true, tempId: pending.tempId, text: pending.text }, ...state] +} + +export interface Clock { + now(): number +} + +export type IdGen = () => string + +export interface NotesStore { + add(text: string): Promise +} + +export function createNotesStore(opts: { clock?: Clock; ids?: IdGen } = {}): NotesStore { + const clock = opts.clock ?? { now: () => Date.now() } + const ids = opts.ids ?? (() => crypto.randomUUID()) + return { + async add(text) { + return { id: ids(), text, createdAt: clock.now() } + } + } +} + +export interface FormState { + notes: readonly Note[] + error: string | null +} + +export interface AddInput { + text: unknown + fail: boolean +} + +// The authoritative mutation. On failure the previous list is returned untouched +// so the client can revert its optimistic guess and re-show the typed value. A +// thrown store error is treated the same as an explicit `fail`: the write did +// not land, so confirmed state must not move. +export async function applyAdd(prev: FormState, input: AddInput, store: NotesStore): Promise { + const validation = validateNoteText(input.text) + if (!validation.ok) return { notes: prev.notes, error: validation.error } + if (input.fail) return { notes: prev.notes, error: 'The server rejected the note. Try again.' } + try { + const note = await store.add(validation.text) + return { notes: [note, ...prev.notes], error: null } + } catch { + return { notes: prev.notes, error: 'Something went wrong saving the note.' } + } +} diff --git a/src/tokens/tokens.ts b/src/tokens/tokens.ts index b2cfddd..9216fb8 100644 --- a/src/tokens/tokens.ts +++ b/src/tokens/tokens.ts @@ -8,7 +8,8 @@ export const tokens = { border: '#262b36', text: '#e7eaf0', muted: '#9aa3b2', - accent: '#5eead4' + accent: '#5eead4', + danger: '#f87171' }, space: { '2': '0.5rem', diff --git a/test/notes.test.ts b/test/notes.test.ts new file mode 100644 index 0000000..fd17988 --- /dev/null +++ b/test/notes.test.ts @@ -0,0 +1,118 @@ +import { + applyAdd, + createNotesStore, + MAX_NOTE_LENGTH, + reduceOptimistic, + toDisplayList, + validateNoteText, + type Clock, + type DisplayNote, + type FormState, + type IdGen, + type Note +} from '@/data-patterns/notes' + +const fixedClock: Clock = { now: () => 1000 } + +function sequentialIds(): IdGen { + let n = 0 + return () => `n${(n += 1)}` +} + +describe('validateNoteText', () => { + it('trims and accepts real text', () => { + expect(validateNoteText(' hello ')).toEqual({ ok: true, text: 'hello' }) + }) + + it('rejects empty and whitespace-only input', () => { + expect(validateNoteText('')).toEqual({ ok: false, error: 'A note cannot be empty.' }) + expect(validateNoteText(' ')).toEqual({ ok: false, error: 'A note cannot be empty.' }) + }) + + it('rejects non-strings coming off FormData', () => { + expect(validateNoteText(null)).toMatchObject({ ok: false }) + expect(validateNoteText(undefined)).toMatchObject({ ok: false }) + expect(validateNoteText(42)).toMatchObject({ ok: false }) + }) + + it('accepts exactly the max length and rejects one over', () => { + const atLimit = 'a'.repeat(MAX_NOTE_LENGTH) + expect(validateNoteText(atLimit)).toEqual({ ok: true, text: atLimit }) + const over = 'a'.repeat(MAX_NOTE_LENGTH + 1) + expect(validateNoteText(over)).toMatchObject({ ok: false }) + }) + + it('measures length after trimming, so padded max-length text still passes', () => { + const padded = ` ${'a'.repeat(MAX_NOTE_LENGTH)} ` + expect(validateNoteText(padded)).toEqual({ ok: true, text: 'a'.repeat(MAX_NOTE_LENGTH) }) + }) +}) + +describe('reduceOptimistic', () => { + it('prepends a single pending note to the confirmed base', () => { + const base = toDisplayList([{ id: '1', text: 'first', createdAt: 0 }]) + const next = reduceOptimistic(base, { tempId: 't1', text: 'typing' }) + expect(next).toHaveLength(2) + expect(next[0]).toEqual({ pending: true, tempId: 't1', text: 'typing' }) + expect(next[1]).toMatchObject({ pending: false, id: '1' }) + }) + + it('stacks when folded twice, newest on top, matching React folding pending dispatches', () => { + const base: DisplayNote[] = [] + const once = reduceOptimistic(base, { tempId: 't1', text: 'one' }) + const twice = reduceOptimistic(once, { tempId: 't2', text: 'two' }) + expect(twice.map((n) => (n.pending ? n.text : null))).toEqual(['two', 'one']) + }) + + it('does not mutate the base list', () => { + const base = toDisplayList([{ id: '1', text: 'first', createdAt: 0 }]) + reduceOptimistic(base, { tempId: 't1', text: 'x' }) + expect(base).toHaveLength(1) + }) +}) + +describe('applyAdd', () => { + const store = createNotesStore({ clock: fixedClock, ids: sequentialIds() }) + + it('adds a validated note to the front and clears the error', async () => { + const prev: FormState = { notes: [{ id: 'seed', text: 'seed', createdAt: 0 }], error: 'stale' } + const next = await applyAdd(prev, { text: ' new ', fail: false }, store) + expect(next.error).toBeNull() + expect(next.notes[0]).toMatchObject({ text: 'new', createdAt: 1000 }) + expect(next.notes[1]?.id).toBe('seed') + }) + + it('keeps the confirmed list untouched on a validation error', async () => { + const prev: FormState = { notes: [{ id: 'seed', text: 'seed', createdAt: 0 }], error: null } + const next = await applyAdd(prev, { text: ' ', fail: false }, store) + expect(next.notes).toBe(prev.notes) + expect(next.error).toBe('A note cannot be empty.') + }) + + it('does not mutate confirmed state when the write is asked to fail', async () => { + const prev: FormState = { notes: [{ id: 'seed', text: 'seed', createdAt: 0 }], error: null } + const next = await applyAdd(prev, { text: 'valid', fail: true }, store) + expect(next.notes).toBe(prev.notes) + expect(next.error).toMatch(/rejected/) + }) + + it('reverts to the prior list when the store throws', async () => { + const throwing = { + add(): Promise { + return Promise.reject(new Error('network down')) + } + } + const prev: FormState = { notes: [{ id: 'seed', text: 'seed', createdAt: 0 }], error: null } + const next = await applyAdd(prev, { text: 'valid', fail: false }, throwing) + expect(next.notes).toBe(prev.notes) + expect(next.error).toMatch(/went wrong/) + }) + + it('assigns distinct ids across sequential writes so concurrent optimistic notes reconcile cleanly', async () => { + const fresh = createNotesStore({ clock: fixedClock, ids: sequentialIds() }) + const a = await applyAdd({ notes: [], error: null }, { text: 'a', fail: false }, fresh) + const b = await applyAdd(a, { text: 'b', fail: false }, fresh) + const ids = b.notes.map((n) => n.id) + expect(new Set(ids).size).toBe(ids.length) + }) +})