start-vibing-stacks 2.18.0 → 2.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/setup.js +11 -0
- package/package.json +1 -1
- package/stacks/_shared/skills/quality-gate/SKILL.md +11 -4
- package/stacks/frontend/react/skills/preline-ui/SKILL.md +6 -3
- package/stacks/frontend/react/skills/react-patterns/SKILL.md +125 -26
- package/stacks/frontend/react/skills/react-standards/SKILL.md +17 -4
- package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +106 -31
- package/stacks/frontend/react/skills/shadcn-ui/SKILL.md +284 -56
- package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +75 -16
- package/stacks/frontend/react/skills/zod-validation/SKILL.md +157 -35
- package/stacks/frontend/react-api/skills/axios-laravel-api/SKILL.md +2 -6
- package/stacks/frontend/react-api/skills/react-api-standards/SKILL.md +10 -12
- package/stacks/nodejs/scripts/check-route-slugs.mjs +130 -0
- package/stacks/nodejs/skills/nextjs-app-router/SKILL.md +222 -1
- package/stacks/nodejs/stack.json +2 -1
- package/stacks/nodejs/workflows/ci.yml +11 -0
package/dist/setup.js
CHANGED
|
@@ -187,6 +187,17 @@ export async function setupProject(projectDir, config, options = {}) {
|
|
|
187
187
|
spinner.text = 'Installed CI workflow templates';
|
|
188
188
|
}
|
|
189
189
|
}
|
|
190
|
+
// 11e. Copy stack-level helper scripts (e.g. nodejs/scripts/check-route-slugs.mjs)
|
|
191
|
+
// copyDirRecursive is non-destructive by default: existing files in the target
|
|
192
|
+
// project's scripts/ dir are preserved unless --force is passed.
|
|
193
|
+
const stackScriptsDir = join(PACKAGE_ROOT, 'stacks', config.stack, 'scripts');
|
|
194
|
+
if (existsSync(stackScriptsDir)) {
|
|
195
|
+
const projectScriptsDir = join(projectDir, 'scripts');
|
|
196
|
+
const copied = copyDirRecursive(stackScriptsDir, projectScriptsDir, options.force);
|
|
197
|
+
if (copied > 0) {
|
|
198
|
+
spinner.text = `Installed ${copied} stack helper script(s) to scripts/`;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
190
201
|
// 12. Copy commands
|
|
191
202
|
const sharedCommandsDir = join(PACKAGE_ROOT, 'stacks', '_shared', 'commands');
|
|
192
203
|
if (existsSync(sharedCommandsDir)) {
|
package/package.json
CHANGED
|
@@ -24,12 +24,19 @@ vendor/bin/php-cs-fixer fix --dry-run # Code style
|
|
|
24
24
|
|
|
25
25
|
### Node.js Gates
|
|
26
26
|
```bash
|
|
27
|
-
bun run typecheck
|
|
28
|
-
bun run lint
|
|
29
|
-
bun run test
|
|
30
|
-
|
|
27
|
+
bun run typecheck # TypeScript errors
|
|
28
|
+
bun run lint # ESLint
|
|
29
|
+
bun run test # Vitest
|
|
30
|
+
node scripts/check-route-slugs.mjs # Next.js — only run if framework=nextjs
|
|
31
|
+
bun run build # Build verification (must come AFTER route-slugs)
|
|
31
32
|
```
|
|
32
33
|
|
|
34
|
+
> **Next.js note.** `next build` does NOT validate dynamic-segment slug
|
|
35
|
+
> consistency (e.g. `[id]` and `[userId]` under the same parent). The
|
|
36
|
+
> `check-route-slugs.mjs` script must run **before** `build` to catch this
|
|
37
|
+
> statically — see `nextjs-app-router` skill, section "Dynamic Route Slug
|
|
38
|
+
> Consistency".
|
|
39
|
+
|
|
33
40
|
## Gate Results
|
|
34
41
|
|
|
35
42
|
| Result | Action |
|
|
@@ -1,11 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: preline-ui
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Preline UI — semantic-token component system on top of Tailwind v4 (stable Jan 2025). 220+ design tokens covering background/foreground/primary/secondary/muted/destructive/border/surface/layer + per-component groups (navbar, sidebar, card, dropdown, select, overlay, tooltip, popover, scrollbar). 840+ free copy-paste blocks (hero, testimonials, pricing, dashboards, forms). Mandatory `HSStaticMethods.autoInit()` after client-side navigation in Next.js — without it, dropdowns/modals/accordions break. Theme generator CLI, custom palettes via `@theme inline { … }`, light + dark via `data-theme` + `.dark`. Chart tokens require HEX (no oklch in -hex). Pairs with React 19 (refs as props, document metadata). Invoke when using Preline components, creating themes, or customising tokens."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# Preline UI — Component & Theme System for
|
|
7
|
+
# Preline UI — Component & Theme System for Tailwind v4 (2026)
|
|
7
8
|
|
|
8
|
-
**ALWAYS invoke when using Preline components, creating themes, or
|
|
9
|
+
**ALWAYS invoke when using Preline components, creating themes, or customising design tokens.**
|
|
10
|
+
|
|
11
|
+
> Pairs with `tailwind-patterns` v2 (Tailwind v4 `@theme`, Oxide engine, OKLCH) and `react-standards` v2 (LABELS/STYLES const pattern). For shadcn-style primitives with Radix/Base UI underneath, see `shadcn-ui` v2 — pick **one** component system per project, don't mix Preline and shadcn.
|
|
9
12
|
|
|
10
13
|
## What is Preline
|
|
11
14
|
|
|
@@ -1,12 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: react-patterns
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Modern React 19 component architecture (stable Dec 5 2024; 19.1 in 2025). Covers the new hooks (`useActionState`, `useOptimistic`, `useFormStatus`, `use()` for promises and Context), refs-as-props (no more `forwardRef`), document metadata hoisting (write `<title>` and `<meta>` anywhere), Actions for async mutations, Server Components vs Client Components, compound + generic components, custom hooks, state-management decision matrix (useState → useReducer → Context → Zustand → server cache), Error Boundaries, performance via memo/useMemo/useCallback, and Suspense boundaries. Invoke for any new component, hook, state design, or React-architecture decision."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# React Patterns — Modern Component Architecture
|
|
7
|
+
# React 19 Patterns — Modern Component Architecture
|
|
7
8
|
|
|
8
9
|
**ALWAYS invoke when writing React components, hooks, or state management.**
|
|
9
10
|
|
|
11
|
+
## React 19 essentials (stable Dec 5, 2024 → 19.1 in 2025)
|
|
12
|
+
|
|
13
|
+
- **Actions** — async functions passed straight to forms; `useActionState` manages pending/error/optimistic state automatically
|
|
14
|
+
- **`use()`** — read promises and Context **conditionally** during render (paired with Suspense + Error Boundaries)
|
|
15
|
+
- **Refs as props** — pass `ref` like any other prop. **`forwardRef` is no longer needed** for new components
|
|
16
|
+
- **Document metadata hoisting** — `<title>`, `<meta>`, `<link>` get auto-hoisted into `<head>` from anywhere
|
|
17
|
+
- **Form `action` prop** — submits via the action, auto-resets uncontrolled inputs on success
|
|
18
|
+
- **Stylesheet management** — `<link rel="stylesheet">` in components is awaited before the Suspense boundary reveals
|
|
19
|
+
- **Suspense sibling pre-warming** — fallback shows immediately and queues sibling requests in parallel
|
|
20
|
+
|
|
10
21
|
## Component Patterns
|
|
11
22
|
|
|
12
23
|
### Compound Components
|
|
@@ -109,52 +120,125 @@ const Heavy = lazy(() => import('./HeavyComponent'));
|
|
|
109
120
|
<Suspense fallback={<Loading />}><Heavy /></Suspense>
|
|
110
121
|
```
|
|
111
122
|
|
|
112
|
-
## React 19
|
|
123
|
+
## React 19 — Actions & form-state hooks
|
|
124
|
+
|
|
125
|
+
### `useActionState` — pending + error + result for one form
|
|
113
126
|
|
|
114
127
|
```tsx
|
|
115
|
-
// useActionState — form submission state
|
|
116
128
|
import { useActionState } from 'react';
|
|
117
129
|
|
|
118
130
|
function LoginForm() {
|
|
119
131
|
const [state, formAction, isPending] = useActionState(
|
|
120
|
-
async (
|
|
132
|
+
async (_prev, formData: FormData) => {
|
|
121
133
|
const result = await login(formData);
|
|
122
134
|
if (result.error) return { error: result.error };
|
|
123
135
|
redirect('/dashboard');
|
|
124
136
|
},
|
|
125
|
-
{ error: null }
|
|
137
|
+
{ error: null as string | null }
|
|
126
138
|
);
|
|
127
139
|
|
|
128
140
|
return (
|
|
129
141
|
<form action={formAction}>
|
|
130
|
-
<input name="email" type="email" />
|
|
142
|
+
<input name="email" type="email" required />
|
|
131
143
|
{state.error && <p className="text-destructive">{state.error}</p>}
|
|
132
|
-
<button disabled={isPending}>
|
|
133
|
-
{isPending ? 'Signing in...' : 'Sign In'}
|
|
134
|
-
</button>
|
|
144
|
+
<button disabled={isPending}>{isPending ? 'Signing in…' : 'Sign In'}</button>
|
|
135
145
|
</form>
|
|
136
146
|
);
|
|
137
147
|
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### `useFormStatus` — child of a form reads its parent's submit state
|
|
151
|
+
|
|
152
|
+
```tsx
|
|
153
|
+
import { useFormStatus } from 'react-dom';
|
|
154
|
+
|
|
155
|
+
function SubmitButton() {
|
|
156
|
+
const { pending } = useFormStatus(); // ← reads the enclosing <form action={…}>
|
|
157
|
+
return <button disabled={pending}>{pending ? 'Saving…' : 'Save'}</button>;
|
|
158
|
+
}
|
|
159
|
+
```
|
|
138
160
|
|
|
139
|
-
|
|
161
|
+
Drop `<SubmitButton />` inside any `<form action={…}>` — no prop drilling, no context.
|
|
162
|
+
|
|
163
|
+
### `useOptimistic` — instant UI feedback that auto-reverts on error
|
|
164
|
+
|
|
165
|
+
```tsx
|
|
140
166
|
import { useOptimistic } from 'react';
|
|
141
167
|
|
|
142
168
|
function TodoList({ todos }: { todos: Todo[] }) {
|
|
143
169
|
const [optimisticTodos, addOptimistic] = useOptimistic(
|
|
144
170
|
todos,
|
|
145
|
-
(
|
|
171
|
+
(current, newTodo: Todo) => [...current, newTodo]
|
|
146
172
|
);
|
|
147
173
|
|
|
148
|
-
|
|
149
|
-
const todo = { id: crypto.randomUUID(), title: formData.get('title')
|
|
150
|
-
addOptimistic(todo);
|
|
151
|
-
await saveTodo(todo);
|
|
152
|
-
}
|
|
174
|
+
async function add(formData: FormData) {
|
|
175
|
+
const todo = { id: crypto.randomUUID(), title: String(formData.get('title')), done: false };
|
|
176
|
+
addOptimistic(todo); // instant
|
|
177
|
+
await saveTodo(todo); // server confirms (or throws → revert is automatic on rerender)
|
|
178
|
+
}
|
|
153
179
|
|
|
154
|
-
return
|
|
180
|
+
return (
|
|
181
|
+
<>
|
|
182
|
+
<form action={add}><input name="title" /></form>
|
|
183
|
+
<ul>{optimisticTodos.map(t => <li key={t.id}>{t.title}</li>)}</ul>
|
|
184
|
+
</>
|
|
185
|
+
);
|
|
155
186
|
}
|
|
156
187
|
```
|
|
157
188
|
|
|
189
|
+
### `use()` — read a promise or Context conditionally
|
|
190
|
+
|
|
191
|
+
```tsx
|
|
192
|
+
import { use, Suspense } from 'react';
|
|
193
|
+
|
|
194
|
+
function UserName({ userPromise }: { userPromise: Promise<User> }) {
|
|
195
|
+
const user = use(userPromise); // suspends until resolved
|
|
196
|
+
return <span>{user.name}</span>;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Parent
|
|
200
|
+
<Suspense fallback={<Skeleton />}>
|
|
201
|
+
<UserName userPromise={fetchUser(id)} />
|
|
202
|
+
</Suspense>
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
`use()` is the only hook allowed inside conditionals — perfect for "render after this resource resolves" without restructuring components.
|
|
206
|
+
|
|
207
|
+
## React 19 — refs as props (no `forwardRef`)
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
// React 19 — `ref` is just a prop
|
|
211
|
+
function Input({ ref, className, ...props }: React.InputHTMLAttributes<HTMLInputElement> & {
|
|
212
|
+
ref?: React.Ref<HTMLInputElement>;
|
|
213
|
+
}) {
|
|
214
|
+
return <input ref={ref} className={cn('h-10 px-3 …', className)} {...props} />;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Usage — no different from before
|
|
218
|
+
const myRef = useRef<HTMLInputElement>(null);
|
|
219
|
+
<Input ref={myRef} />
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`forwardRef` keeps working — but new components should ditch it.
|
|
223
|
+
|
|
224
|
+
## React 19 — document metadata anywhere
|
|
225
|
+
|
|
226
|
+
```tsx
|
|
227
|
+
function Article({ post }: { post: Post }) {
|
|
228
|
+
return (
|
|
229
|
+
<article>
|
|
230
|
+
<title>{post.title} — My Site</title> {/* hoisted into <head> */}
|
|
231
|
+
<meta name="description" content={post.summary} />
|
|
232
|
+
<link rel="canonical" href={`/blog/${post.slug}`} />
|
|
233
|
+
<h1>{post.title}</h1>
|
|
234
|
+
{/* … */}
|
|
235
|
+
</article>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
No `next/head` / `react-helmet` needed — React handles it. Server-rendered metadata appears in the initial HTML (good for SEO).
|
|
241
|
+
|
|
158
242
|
## State Management Selection
|
|
159
243
|
|
|
160
244
|
| Complexity | Solution | When |
|
|
@@ -219,11 +303,26 @@ class ErrorBoundary extends Component<
|
|
|
219
303
|
|
|
220
304
|
## FORBIDDEN
|
|
221
305
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
306
|
+
| Pattern | Why |
|
|
307
|
+
|---|---|
|
|
308
|
+
| `forwardRef` in new components | Refs are props in React 19 — drop the wrapper |
|
|
309
|
+
| `useEffect` to fetch data | Use Server Components, `use()` + Suspense, or TanStack Query |
|
|
310
|
+
| `useEffect` for derived state | Compute it in render or via `useMemo` |
|
|
311
|
+
| `next/head` / `react-helmet` in R19 | Use plain `<title>` / `<meta>` — React hoists |
|
|
312
|
+
| Class components | Function components only (Error Boundaries are the rare exception) |
|
|
313
|
+
| Prop drilling > 2 levels | Composition or `Context` |
|
|
314
|
+
| Inline objects/functions in hot paths | Stable refs via `useMemo` / `useCallback` (only when profiler shows it matters) |
|
|
315
|
+
| Direct state mutation | Always set new state — React relies on referential change |
|
|
316
|
+
| Index as `key` | Use stable unique IDs (`crypto.randomUUID()`, server IDs) |
|
|
317
|
+
| Premature memoisation | Profile with React DevTools first |
|
|
318
|
+
| Manual `isPending` + `error` `useState` for forms | Use `useActionState` |
|
|
319
|
+
| Custom optimistic-state code | `useOptimistic` + Action |
|
|
320
|
+
|
|
321
|
+
## See Also
|
|
322
|
+
|
|
323
|
+
- `react-standards` v2 — LABELS / STYLES const pattern
|
|
324
|
+
- `react-ui-patterns` v2 — loading/error/empty + TanStack Query v5
|
|
325
|
+
- `tailwind-patterns` v2 — `@theme`, container queries, OKLCH
|
|
326
|
+
- `shadcn-ui` v2 — primitives without `forwardRef`, `data-slot` styling
|
|
327
|
+
- `zod-validation` v2 — Zod 4 + RHF for form Actions
|
|
328
|
+
- `_shared/skills/playwright-automation` v2 — E2E coverage including R19 forms
|
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: react-standards
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Project conventions for React 19 + Tailwind v4 codebases. Mandates the LABELS / STYLES const pattern (defined at file top, before hooks, with `as const` for stable references — prevents re-renders, centralises styles, supports i18n drop-in), controlled debug logging (no raw console.log), semantic Tailwind tokens (no raw colours), separate-file SVG icons, mandatory loading/empty states, modal data-flow contract (`onUpdated` callback), and third-party chart integration (no useRef DOM mutation). Pairs with `tailwind-patterns` v2 for tokens and `react-ui-patterns` v2 for state choreography. Invoke at the start of any new React project or component file."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# React 19+ Standards
|
|
7
|
+
# React 19 + Tailwind v4 — Project Standards
|
|
8
|
+
|
|
9
|
+
> Conventions that apply to **every** component in the codebase. Pairs with `react-patterns` (architecture), `tailwind-patterns` (tokens), `react-ui-patterns` (state), and `shadcn-ui` (primitives).
|
|
7
10
|
|
|
8
11
|
## Version Requirements
|
|
9
12
|
|
|
10
|
-
- **
|
|
11
|
-
- **TailwindCSS
|
|
13
|
+
- **React ≥ 19.0** (stable Dec 5, 2024) — MANDATORY for new projects
|
|
14
|
+
- **TailwindCSS ≥ 4.0** (stable Jan 22, 2025) — MANDATORY (CSS-first `@theme`, Oxide engine)
|
|
15
|
+
- **TypeScript ≥ 5.6** strict mode
|
|
12
16
|
|
|
13
17
|
## Label Constants Pattern
|
|
14
18
|
|
|
@@ -279,3 +283,12 @@ const processedData = useMemo(() => {
|
|
|
279
283
|
- Conditional rendering: `data && <Component />`
|
|
280
284
|
- `useMemo` for expensive computations
|
|
281
285
|
- Loading states before rendering charts
|
|
286
|
+
|
|
287
|
+
## See Also
|
|
288
|
+
|
|
289
|
+
- `react-patterns` v2 — React 19 hooks, `use()`, refs as props
|
|
290
|
+
- `tailwind-patterns` v2 — `@theme`, Oxide engine, container queries, OKLCH
|
|
291
|
+
- `shadcn-ui` v2 — primitives without `forwardRef`, `data-slot` slots
|
|
292
|
+
- `react-ui-patterns` v2 — loading/error/empty + TanStack Query v5
|
|
293
|
+
- `zod-validation` v2 — Zod 4 schemas for forms + env vars
|
|
294
|
+
- `_shared/skills/ui-ux-audit` v2 — WCAG 2.2 AA validation
|
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: react-ui-patterns
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "React 19 + TanStack Query v5 UI patterns for production async UI: loading/error/empty state hierarchy, skeleton vs spinner choice, ErrorState/EmptyState/LoadingState reusable components, button states with disabled+loading indicator, RHF + Zod 4 forms, optimistic updates via React 19 `useOptimistic` (built-in, simpler than TanStack `onMutate`/rollback) AND TanStack Query v5 onMutate pattern (when you need cache surgery), Suspense + useSuspenseQuery boundaries, signal threading for cancellation. TanStack Query v5 renamed `loading` → `pending` (note isPending vs isFetching vs isLoading). Invoke when handling any async UI state, form, mutation, or user feedback."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# React UI Patterns — Loading, Errors, Empty States & Forms
|
|
7
|
+
# React UI Patterns — Loading, Errors, Empty States & Forms (R19 + TanStack v5)
|
|
7
8
|
|
|
8
9
|
**ALWAYS invoke when handling async UI states, forms, or user feedback.**
|
|
9
10
|
|
|
11
|
+
## Stack snapshot (2026)
|
|
12
|
+
|
|
13
|
+
| Concern | Pick |
|
|
14
|
+
|---|---|
|
|
15
|
+
| Server cache + refetch + dedup | **TanStack Query v5** (`useQuery`, `useMutation`, `useSuspenseQuery`) |
|
|
16
|
+
| Form state + validation | **react-hook-form** + **`@hookform/resolvers/zod`** (Zod 4) |
|
|
17
|
+
| Optimistic UI for **submit-then-confirm** flows | React 19 **`useOptimistic`** (no cache surgery needed) |
|
|
18
|
+
| Optimistic UI when you must mutate the cache | TanStack `onMutate` + `setQueryData` rollback |
|
|
19
|
+
| Suspense / streaming data | `useSuspenseQuery` + `<Suspense>` |
|
|
20
|
+
| Toasts | **Sonner** (`toast.success`, `toast.error`) |
|
|
21
|
+
|
|
22
|
+
> **TanStack Query v5 renamed `loading` → `pending`.** Use `isPending` (no data yet, no error), `isFetching` (any fetch in flight, including refetch), `isLoading` (legacy alias for `isPending && isFetching`). The render-tree decision below is unchanged.
|
|
23
|
+
|
|
10
24
|
## Core Principles
|
|
11
25
|
|
|
12
26
|
1. **Never show stale UI** — loading only when actually loading
|
|
@@ -33,18 +47,40 @@ Has data?
|
|
|
33
47
|
```
|
|
34
48
|
|
|
35
49
|
```tsx
|
|
36
|
-
// ✅ CORRECT —
|
|
37
|
-
const { data,
|
|
50
|
+
// ✅ CORRECT — TanStack v5: isPending = no data yet
|
|
51
|
+
const { data, isPending, error, refetch } = useQuery({
|
|
52
|
+
queryKey: ['items'],
|
|
53
|
+
queryFn: ({ signal }) => fetchItems({ signal }), // thread signal for cancellation
|
|
54
|
+
});
|
|
38
55
|
|
|
39
|
-
if (error)
|
|
40
|
-
if (
|
|
56
|
+
if (error) return <ErrorState error={error} onRetry={refetch} />;
|
|
57
|
+
if (isPending) return <Skeleton />;
|
|
41
58
|
if (!data?.items.length) return <EmptyState />;
|
|
42
59
|
return <ItemList items={data.items} />;
|
|
43
60
|
|
|
44
|
-
// ❌ WRONG — flashes spinner on refetch when cached data exists
|
|
61
|
+
// ❌ WRONG (v3 idiom) — flashes spinner on refetch when cached data exists
|
|
45
62
|
if (isLoading) return <Spinner />;
|
|
46
63
|
```
|
|
47
64
|
|
|
65
|
+
### Suspense alternative
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
// useSuspenseQuery — cleaner when you wrap with <Suspense> + <ErrorBoundary>
|
|
69
|
+
function ItemList() {
|
|
70
|
+
const { data } = useSuspenseQuery({
|
|
71
|
+
queryKey: ['items'],
|
|
72
|
+
queryFn: ({ signal }) => fetchItems({ signal }),
|
|
73
|
+
});
|
|
74
|
+
return <ul>{data.items.map(i => <li key={i.id}>{i.title}</li>)}</ul>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
<ErrorBoundary fallback={<ErrorState />}>
|
|
78
|
+
<Suspense fallback={<Skeleton />}>
|
|
79
|
+
<ItemList />
|
|
80
|
+
</Suspense>
|
|
81
|
+
</ErrorBoundary>
|
|
82
|
+
```
|
|
83
|
+
|
|
48
84
|
### Skeleton vs Spinner
|
|
49
85
|
|
|
50
86
|
| Use Skeleton | Use Spinner |
|
|
@@ -276,49 +312,83 @@ export default function CreateUser() {
|
|
|
276
312
|
}
|
|
277
313
|
```
|
|
278
314
|
|
|
279
|
-
## Optimistic Updates
|
|
315
|
+
## Optimistic Updates — pick by use case
|
|
316
|
+
|
|
317
|
+
### Option A — React 19 `useOptimistic` (simpler, NEW DEFAULT for forms)
|
|
318
|
+
|
|
319
|
+
When the optimistic update is **local to a component** and tied to a form Action, this is the cleanest option — no manual rollback, no cache surgery.
|
|
320
|
+
|
|
321
|
+
```tsx
|
|
322
|
+
"use client";
|
|
323
|
+
import { useOptimistic } from "react";
|
|
324
|
+
|
|
325
|
+
function TodoList({ todos, addTodo }: { todos: Todo[]; addTodo: (t: Todo) => Promise<void> }) {
|
|
326
|
+
const [optimisticTodos, addOptimistic] = useOptimistic(
|
|
327
|
+
todos,
|
|
328
|
+
(current, newTodo: Todo) => [...current, newTodo],
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
async function action(formData: FormData) {
|
|
332
|
+
const todo = { id: crypto.randomUUID(), title: String(formData.get("title")), done: false };
|
|
333
|
+
addOptimistic(todo); // instant
|
|
334
|
+
await addTodo(todo); // server confirms — on error React reverts on next render
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return (
|
|
338
|
+
<>
|
|
339
|
+
<form action={action}>
|
|
340
|
+
<input name="title" required />
|
|
341
|
+
<button>Add</button>
|
|
342
|
+
</form>
|
|
343
|
+
<ul>{optimisticTodos.map(t => <li key={t.id}>{t.title}</li>)}</ul>
|
|
344
|
+
</>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
### Option B — TanStack Query `onMutate` + cache rollback
|
|
350
|
+
|
|
351
|
+
Reach for this when the mutation has to update a **shared server cache** that other components also read (toggling a favourite that appears in three lists, for instance).
|
|
280
352
|
|
|
281
353
|
```tsx
|
|
282
|
-
import { useMutation, useQueryClient } from
|
|
354
|
+
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
283
355
|
|
|
284
356
|
function ToggleFavorite({ item }: { item: Item }) {
|
|
285
|
-
const
|
|
357
|
+
const qc = useQueryClient();
|
|
286
358
|
|
|
287
359
|
const mutation = useMutation({
|
|
288
360
|
mutationFn: () =>
|
|
289
|
-
fetch(`/api/items/${item.id}/favorite`, { method:
|
|
290
|
-
if (!
|
|
291
|
-
return
|
|
361
|
+
fetch(`/api/items/${item.id}/favorite`, { method: "POST" }).then(r => {
|
|
362
|
+
if (!r.ok) throw new Error("Failed");
|
|
363
|
+
return r.json();
|
|
292
364
|
}),
|
|
293
365
|
onMutate: async () => {
|
|
294
|
-
await
|
|
295
|
-
const previous =
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
old?.map((i) =>
|
|
299
|
-
i.id === item.id ? { ...i, isFavorite: !i.isFavorite } : i
|
|
300
|
-
)
|
|
366
|
+
await qc.cancelQueries({ queryKey: ["items"] });
|
|
367
|
+
const previous = qc.getQueryData<Item[]>(["items"]);
|
|
368
|
+
qc.setQueryData<Item[]>(["items"], (old) =>
|
|
369
|
+
old?.map(i => i.id === item.id ? { ...i, isFavorite: !i.isFavorite } : i),
|
|
301
370
|
);
|
|
302
|
-
|
|
303
371
|
return { previous };
|
|
304
372
|
},
|
|
305
373
|
onError: (_err, _vars, context) => {
|
|
306
|
-
|
|
307
|
-
toast.error(
|
|
374
|
+
qc.setQueryData(["items"], context?.previous); // rollback
|
|
375
|
+
toast.error("Failed to update");
|
|
308
376
|
},
|
|
309
377
|
onSettled: () => {
|
|
310
|
-
|
|
378
|
+
qc.invalidateQueries({ queryKey: ["items"] }); // truth from server
|
|
311
379
|
},
|
|
312
380
|
});
|
|
313
381
|
|
|
314
382
|
return (
|
|
315
|
-
<button onClick={() => mutation.mutate()} className="text-xl">
|
|
316
|
-
{item.isFavorite ?
|
|
383
|
+
<button onClick={() => mutation.mutate()} className="text-xl" aria-label="Toggle favorite">
|
|
384
|
+
{item.isFavorite ? "❤️" : "🤍"}
|
|
317
385
|
</button>
|
|
318
386
|
);
|
|
319
387
|
}
|
|
320
388
|
```
|
|
321
389
|
|
|
390
|
+
> Don't combine both for the same flow — `useOptimistic` for forms, TanStack `onMutate` for cross-cache updates. Mixing them creates invisible double rollbacks.
|
|
391
|
+
|
|
322
392
|
## Checklist — Before Shipping Any UI Component
|
|
323
393
|
|
|
324
394
|
- [ ] Error state handled and shown to user
|
|
@@ -332,8 +402,13 @@ function ToggleFavorite({ item }: { item: Item }) {
|
|
|
332
402
|
|
|
333
403
|
## FORBIDDEN
|
|
334
404
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
405
|
+
| Anti-pattern | Fix |
|
|
406
|
+
|---|---|
|
|
407
|
+
| `if (isLoading) return <Spinner />` (v3 idiom) | TanStack v5: check `isPending` (no data yet), keep cached data visible during refetch |
|
|
408
|
+
| Silent `catch (e) { console.log(e) }` | Always surface via toast / inline error |
|
|
409
|
+
| List without an empty state | Every collection needs `<EmptyState />` |
|
|
410
|
+
| Clickable button while a mutation is pending | `disabled={mutation.isPending}` + spinner |
|
|
411
|
+
| `console.log`-only errors | User must see feedback (toast / banner / inline) |
|
|
412
|
+
| `useOptimistic` AND TanStack `onMutate` for the same flow | Pick one — mixing produces double rollbacks |
|
|
413
|
+
| `mutationFn` that doesn't thread the `signal` | Add `({ signal }) => fetch(url, { signal })` so unmount cancels in-flight |
|
|
414
|
+
| `forwardRef` wrappers around shadcn primitives | React 19 — refs are plain props |
|