Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -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
Expand Down Expand Up @@ -64,6 +69,22 @@ const store = createDataStore() // one per request, so the loaders memoize per r
</Suspense>
```

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
Expand Down
25 changes: 25 additions & 0 deletions app/data-patterns/loading.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { token } from '@/tokens'

export default function Loading() {
return (
<main
role="status"
aria-label="Loading data patterns"
aria-busy
style={{ maxWidth: '52rem', margin: '0 auto', padding: `${token('space', '8')} ${token('space', '4')}`, display: 'grid', gap: token('space', '4') }}
>
{Array.from({ length: 3 }, (_, i) => (
<span
key={i}
aria-hidden
style={{
height: i === 0 ? '2rem' : '3rem',
width: i === 0 ? '60%' : '100%',
borderRadius: token('radius', 'md'),
background: 'var(--color-border)'
}}
/>
))}
</main>
)
}
111 changes: 111 additions & 0 deletions app/data-patterns/note-form.tsx
Original file line number Diff line number Diff line change
@@ -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<FormState>
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 (
<button
type="submit"
disabled={pending}
aria-disabled={pending}
style={{
padding: `${token('space', '2')} ${token('space', '4')}`,
borderRadius: token('radius', 'md'),
border: '1px solid var(--color-border)',
background: pending ? 'var(--color-border)' : 'var(--color-accent)',
color: pending ? 'var(--color-muted)' : 'var(--color-surface)',
cursor: pending ? 'progress' : 'pointer'
}}
>
{pending ? 'Saving...' : 'Add note'}
</button>
)
}

export function NoteForm({ action, initial }: NoteFormProps) {
const [state, dispatch] = useActionState(action, initial)
const [optimistic, addOptimistic] = useOptimistic(toDisplayList(state.notes), reduceOptimistic)
const formRef = useRef<HTMLFormElement>(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 (
<div style={{ display: 'grid', gap: token('space', '6'), marginTop: token('space', '8') }}>
<form ref={formRef} action={onSubmit} style={{ ...cardStyle, display: 'grid', gap: token('space', '3') }}>
<label htmlFor="text" style={{ fontSize: token('fontSize', 'sm'), color: token('color', 'muted') }}>
New note
</label>
<input
id="text"
name="text"
type="text"
maxLength={MAX_NOTE_LENGTH}
autoComplete="off"
placeholder="Type something and submit"
style={{
padding: token('space', '2'),
borderRadius: token('radius', 'md'),
border: '1px solid var(--color-border)',
background: 'var(--color-bg)',
color: 'var(--color-text)'
}}
/>
<label style={{ display: 'flex', gap: token('space', '2'), fontSize: token('fontSize', 'sm'), color: token('color', 'muted') }}>
<input name="fail" type="checkbox" />
Simulate a failure
</label>
<div>
<SubmitButton />
</div>
</form>

{state.error !== null ? (
<p role="alert" style={{ margin: 0, color: token('color', 'danger') }}>
{state.error}
</p>
) : null}

{optimistic.length === 0 ? (
<p style={{ margin: 0, color: token('color', 'muted') }}>No notes yet. Add the first one.</p>
) : (
<ul aria-label="Notes" style={{ listStyle: 'none', margin: 0, padding: 0, display: 'grid', gap: token('space', '3') }}>
{optimistic.map((note) => (
<li
key={note.pending ? note.tempId : note.id}
aria-busy={note.pending}
style={{ ...cardStyle, opacity: note.pending ? 0.55 : 1 }}
>
{note.text}
{note.pending ? (
<span style={{ marginLeft: token('space', '2'), fontSize: token('fontSize', 'sm'), color: token('color', 'muted') }}>
saving...
</span>
) : null}
</li>
))}
</ul>
)}
</div>
)
}
42 changes: 42 additions & 0 deletions app/data-patterns/page.tsx
Original file line number Diff line number Diff line change
@@ -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<FormState> {
'use server'
const store = createNotesStore()
return applyAdd(prev, { text: formData.get('text'), fail: formData.get('fail') === 'on' }, store)
}

export default function DataPatternsPage() {
return (
<main style={{ maxWidth: '52rem', margin: '0 auto', padding: `${token('space', '8')} ${token('space', '4')}` }}>
<h1>Data fetching, server actions &amp; optimistic UI</h1>
<p style={{ color: token('color', 'muted'), marginTop: 0 }}>
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 <code>useOptimistic</code>, then reconciles against what the server returns. Tick
&ldquo;simulate a failure&rdquo; to watch the optimistic note revert and an error render in its place.
</p>

<NoteForm action={addNote} initial={SEED} />

<section aria-label="Notes" style={{ marginTop: token('space', '8'), color: token('color', 'muted') }}>
<h2 style={{ fontSize: token('fontSize', 'lg'), color: 'var(--color-text)' }}>Why this shape</h2>
<p>
Three states share one flow. <code>useActionState</code> holds the confirmed list and the last error and exposes
the pending flag. <code>useOptimistic</code> 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. <code>useFormStatus</code> 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.
</p>
</section>
</main>
)
}
7 changes: 4 additions & 3 deletions app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
91 changes: 91 additions & 0 deletions src/data-patterns/notes.ts
Original file line number Diff line number Diff line change
@@ -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<Note>
}

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<FormState> {
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.' }
}
}
3 changes: 2 additions & 1 deletion src/tokens/tokens.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export const tokens = {
border: '#262b36',
text: '#e7eaf0',
muted: '#9aa3b2',
accent: '#5eead4'
accent: '#5eead4',
danger: '#f87171'
},
space: {
'2': '0.5rem',
Expand Down
Loading
Loading