Files
best-practices/react-vite-dashboard.md
Paul O'Reilly 7e348f5ee3 distill: 48 cross-project best-practices from 2026-07 reflection sweep
Promotions from reflecting 21 projects' session logs (incl. agent-runtimes
122-log drain). Adds coverage across networking (eBPF VIP/VPN SNAT/VLAN
bridge/forward-auth preflight/ingress TLS), kubernetes (CSI hotplug/PodSecurity
debug/self-managed GitOps/runtime annotations), CI (dispatch tokens/runner
death/base image), git (CI-rebase/shallow reset/PR governance), python (async
session pool/httpx redirects/logging), TDD (AsyncMock/xfail lifecycle),
api-integration (SDK parse/token-scope 404/schema probing), plus docker,
scripting, debugging, security-architecture, secrets, react, octopus.

State: .distill-state.json refreshed with current HEADs + 5 newly-tracked projects.
2026-07-02 15:57:42 +12:00

494 lines
19 KiB
Markdown

# React + Vite Dashboard Development
Best practices for building admin/operations dashboards with React 19, Vite, Tailwind CSS v4, shadcn/ui, TanStack Query/Table, and Vitest. Distilled from community consensus and official docs (2025-2026).
## Stack Baseline
| Layer | Tool | Notes |
|---|---|---|
| Framework | React 19 + TypeScript | ref-as-prop, useActionState, useOptimistic, React Compiler 1.0 |
| Build | Vite + @vitejs/plugin-react | `moduleResolution: "bundler"` in tsconfig |
| Styling | Tailwind CSS v4 (CSS-first) | `@import "tailwindcss"`, `@theme` directive, no JS config |
| Components | shadcn/ui (new-york) + Radix | CVA variants, `cn()` utility, `asChild` pattern |
| Data fetching | TanStack Query v5 | `queryOptions` factories, `useSuspenseQuery` |
| Tables | TanStack Table v8 | `createColumnHelper`, server-side processing |
| Forms | react-hook-form + Zod | `zodResolver`, discriminated unions for conditional fields |
| Routing | React Router 7 | Framework or Data mode, nested layout routes |
| Auth | oidc-client-ts + react-oidc-context | PKCE + refresh tokens, `automaticSilentRenew` |
| Testing | Vitest + Testing Library + MSW v2 | happy-dom default, `userEvent.setup()` |
| Icons | lucide-react | Tree-shakeable, consistent sizing |
## 1. Component Architecture
### Compound components for complex UI
Root component owns shared state via Context; child subcomponents read it. Mirrors native HTML semantics (`<select>`/`<option>`) and eliminates prop soup.
```tsx
const DataTableContext = createContext<DataTableContextValue | null>(null)
function useDataTableContext() {
const ctx = useContext(DataTableContext)
if (!ctx) throw new Error('Must be used within DataTable.Root')
return ctx
}
export const DataTable = {
Root: DataTableRoot,
Header: DataTableHeader,
Row: DataTableRow,
} as const
```
**Rationale:** Components with 10+ config props are unreadable and untestable. Compound components let each subcomponent own its concerns.
### Slot / asChild over render props
Radix `Slot` (via `asChild`) replaces the rendered element while merging event handlers, refs, and classNames automatically. Hooks have replaced render props for logic sharing entirely.
**Constraints:** `asChild` child must be focusable (`button`, `a`, `input` -- never `div`), must spread all props, must forward refs.
### Discriminated unions for mutually exclusive props
```tsx
type ButtonProps =
| { variant: 'link'; href: string; onClick?: never }
| { variant: 'action'; onClick: () => void; href?: never }
```
**Rationale:** Optional props that are actually required together lead to impossible states at runtime. The compiler catches them at build time with discriminated unions.
### Prefer `asChild` over `as` prop
Polymorphic `as` prop creates complex TypeScript types that degrade TS server performance on large codebases. Use `asChild` (Radix Slot) unless you genuinely need the `as` API.
## 2. State Management
### Server state vs client state separation
| State type | Tool |
|---|---|
| Remote data (API, DB) | TanStack Query v5 |
| UI toggles, modals, selected tab | `useState` / `useReducer` |
| Cross-tree client state | Zustand or Context + `useReducer` |
| Form state | react-hook-form (never sync to TanStack Query) |
| Real-time updates | SSE/WebSocket into TanStack Query cache |
**Rationale:** Storing server responses in `useState` and managing fetch lifecycle manually is the single most common source of stale data, loading state bugs, and race conditions.
### queryOptions factories
The v5 standard for co-locating query key + queryFn + config. Enables type-safe prefetching and invalidation.
```tsx
export const taskQueries = {
all: () => queryOptions({ queryKey: ['tasks'], queryFn: fetchTasks }),
detail: (id: string) => queryOptions({
queryKey: ['tasks', id],
queryFn: () => fetchTask(id),
staleTime: 30_000,
}),
}
```
Hierarchical keys enable prefix-based invalidation: `invalidateQueries({ queryKey: ['tasks'] })` hits all task queries.
### useSuspenseQuery for dashboard data
Wrap in `<Suspense>` + `<ErrorBoundary>`, get fully typed non-nullable data. No loading/error state variables.
**Anti-pattern:** Wrapping every query in a custom hook. `queryOptions` objects work in components, route loaders, event handlers, and server-side code. Reserve custom hooks for genuinely complex orchestration logic.
## 3. Vite Configuration
### Path aliases must be mirrored
```typescript
// vite.config.ts
resolve: { alias: { '@': path.resolve(__dirname, './src') } }
// tsconfig.json
"paths": { "@/*": ["src/*"] }
```
Alternative: `vite-tsconfig-paths` plugin reads tsconfig automatically.
### Environment variables
- Prefix client-visible vars with `VITE_`, accessed via `import.meta.env.VITE_FOO`
- Type-safe: extend `ImportMetaEnv` in `src/vite-env.d.ts`
- Non-prefixed vars are server-only (not bundled)
### Build optimization
- Route-level splitting via `React.lazy()` + `<Suspense>` is the primary mechanism
- `manualChunks` function form (not object) for vendor cache separation
- Measure first with `rollup-plugin-visualizer` before splitting -- premature chunk surgery causes regressions
**Anti-pattern:** Single monolithic vendor chunk. Separate react-vendor, icons, and other vendor for long-term cache stability.
## 4. Tailwind CSS v4
### CSS-first configuration
```css
@import "tailwindcss";
@theme {
--font-sans: "Inter", sans-serif;
--color-brand-500: oklch(0.62 0.19 250);
}
```
- No `tailwind.config.js` needed -- all customisation lives in CSS
- Auto content detection (respects `.gitignore`), no `content: []` array
- `@theme` tokens are emitted as native CSS custom properties
- Colors in OKLCH (not HSL) -- v4's native color space
### Dark mode
```css
@custom-variant dark (&:where(.dark, .dark *));
```
Toggle `.dark` on `<html>` via JS. Replaces the v3 `darkMode: 'class'` config key.
### CVA for component variants
```typescript
const button = cva('inline-flex items-center ...', {
variants: {
variant: { default: '...', destructive: '...', ghost: '...' },
size: { default: 'h-9 px-4', sm: 'h-8 px-3', lg: 'h-10 px-8' },
},
defaultVariants: { variant: 'default', size: 'default' },
})
```
Always pass `className` through `cn(cva(...), className)` so consumers can override.
### When to extract
- **Inline utilities:** unique to one place, fewer than 5-6 classes
- **CVA extraction:** 2+ visual variants, reused across codebase
- **`@apply`:** avoid except for base element resets -- breaks JIT scan and hides applied styles
## 5. shadcn/ui
### Theming with CSS variables (v4 pattern)
```css
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
}
```
`@theme inline` bridges CSS vars into Tailwind's utility system. Without it, `bg-primary` won't resolve.
### Extending vs wrapping vs forking
1. **Extend** (add CVA variant) -- preferred
2. **Wrap** (compose) -- for structural additions like IconButton
3. **Fork** (copy + modify) -- last resort; you own the component, CLI updates won't apply
### Token discipline
- Semantic pairs: always define `--foo` + `--foo-foreground` together
- `--primary` = action color (buttons, links), not brand identity
- `--radius` drives the entire radius scale from one value
- Never hardcode colors (`text-blue-600`) in component copies -- use semantic tokens
## 6. Data Fetching and Real-Time
### SSE for operations dashboards
| Factor | Polling | SSE | WebSocket |
|---|---|---|---|
| Direction | Client-pull | Server-push | Bidirectional |
| Latency | Interval-bound | Near-instant | Near-instant |
| Complexity | Lowest | Low | Highest |
| Dashboard recommendation | Low-frequency only | Default choice | Chat/collab only |
### SSE + TanStack Query integration
**Invalidation-based** (refetch is cheap): `queryClient.invalidateQueries({ queryKey: ['tasks', event.taskId] })`
**Direct cache mutation** (high-frequency): `queryClient.setQueryData(['tasks', event.taskId], (old) => ({ ...old, status: event.status }))`
Set `staleTime: Infinity` and `refetchOnWindowFocus: false` for SSE-managed queries.
### Fetch-based SSE for auth headers
Native `EventSource` has no custom headers. Use `@microsoft/fetch-event-source` for OIDC bearer token injection. Clean up via `AbortController.abort()` in useEffect return.
Store EventSource in `useRef`, not `useState` -- avoids extra renders.
### Connection status
Track `connecting | connected | reconnecting | error | closed` and show a persistent banner when offline or reconnecting. Exponential backoff with jitter, capped at 30s, max 5 retries.
## 7. Forms
### react-hook-form + Zod pattern
```tsx
const TaskSchema = z.object({
title: z.string().min(1, 'Required'),
priority: z.enum(['low', 'medium', 'high', 'critical']),
})
type TaskForm = z.infer<typeof TaskSchema>
const form = useForm<TaskForm>({
resolver: zodResolver(TaskSchema),
defaultValues: { title: '', priority: 'medium' },
})
```
- Type inferred from Zod schema (single source of truth)
- Use `z.discriminatedUnion()` over `z.union()` -- better error messages and performance
- Server validation errors: `setError('root.serverError', { message })` for global, `setError(field, { message })` for field-level
- Dynamic fields: `useFieldArray` with `key={field.id}` (never array index)
- Wizard/multi-step: single `useForm` in Context, step-level validation via `trigger(['field1', 'field2'])`
## 8. Data Tables
### TanStack Table v8
- `createColumnHelper<T>()` for full type inference on column definitions
- Server-side processing: set `manualSorting`, `manualFiltering`, `manualPagination` and omit `getSortedRowModel`/`getFilteredRowModel`
- Include sorting/filter/pagination state in TanStack Query `queryKey` for automatic refetching
- Use `placeholderData: keepPreviousData` to prevent table flash between pages
- Reset pagination when filters change
- Virtualisation (`@tanstack/react-virtual`) and `getPaginatedRowModel` are mutually exclusive
- Expandable rows: `getSubRows` for tree data, `colSpan` detail panel for custom content
## 9. Routing
### React Router 7 modes
- **Framework Mode** (recommended for new projects): Vite plugin, type-safe loaders, automatic code splitting
- **Data Mode**: full bundling control, `createBrowserRouter`
- **Declarative Mode**: migration path from RR v5/v6 only
### Layout routes for AppFrame shell
Layout routes create nesting without adding URL segments. The three-column shell (icon rail + category panel + main content) lives in a layout route with `<Outlet>` for page content.
**Anti-pattern:** Placing sidebar/nav inside each page component -- they re-render and reset state on every navigation.
### Error boundaries per route
Each route gets its own `errorElement`/`ErrorBoundary` -- errors stay isolated to the affected panel rather than crashing the whole shell.
### URL state for shareable views
Treat URL search params as first-class state for filters, sort, pagination, selected tab. Use `useSearchParams` with `{ replace: true }` to avoid history entries per keystroke. Include URL params in TanStack Query `queryKey`.
## 10. OIDC Authentication
### Setup essentials
- PKCE + refresh token rotation is the 2025 baseline -- implicit flow is deprecated
- `automaticSilentRenew: true` for background token refresh
- `onSigninCallback` must clean OIDC params from URL -- without it, `signinSilent` breaks on refresh
- `offline_access` scope required for refresh token flow
### Protected routes
Use a layout route as the auth guard wrapping the AppFrame layout. Preserves current path in `state.returnTo` for post-login redirect.
### Token expiry handling
- Listen to `auth.events.addAccessTokenExpiring()` for proactive renewal
- Validate token expiry before API calls (not in a 401 interceptor)
- **Anti-pattern:** 401 interceptor calling `signinSilent()` creates parallel renewal attempts and infinite retry loops
## 11. Testing
### Vitest configuration
- `environment: 'happy-dom'` default (2.5x faster than jsdom); override per-file with `// @vitest-environment jsdom` when full CSS cascade needed
- `setupFiles` with `@testing-library/jest-dom` and cleanup
- `css: true` to process Tailwind imports
### Testing Library priorities
1. `getByRole` (accessible + specific) -- always first choice
2. `getByLabelText` for form inputs
3. `getByText` for visible text
4. `getByTestId` -- last resort only
### userEvent over fireEvent
```tsx
const user = userEvent.setup()
await user.type(screen.getByLabelText('Email'), 'test@example.com')
await user.click(screen.getByRole('button', { name: 'Sign in' }))
```
Always async, setup per test block for isolation.
### MSW v2 for API mocking
```tsx
const server = setupServer(...handlers)
beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
```
`onUnhandledRequest: 'error'` catches missing handlers that would silently return undefined.
### shadcn/ui component testing
Radix components use portals -- scope queries with `within()` into dialogs/popovers.
## 12. Error Handling
### Error boundary placement
- **Route-level boundary:** catches page crashes, preserves nav/sidebar
- **Feature-level boundary:** isolates widgets -- metrics chart crash doesn't take down the task list
- **Never** one boundary at app root only
### QueryErrorResetBoundary
Must wrap ErrorBoundary (outermost) -- provides the `reset` that clears TanStack Query's internal error lock. Without it, retrying re-throws immediately without re-fetching.
### Retry strategy
- Never retry 4xx client errors (except 408, 429)
- Always retry network errors and 5xx
- Exponential backoff: `Math.min(1000 * Math.pow(2, attempt), 30_000)`
### Offline detection
`navigator.onLine` is unreliable. Use TanStack Query's `fetchStatus: 'paused'` -- it listens to window online/offline events and auto-retries on reconnection.
## 13. Performance (React 19)
### React Compiler 1.0
Stable since October 2025. Handles automatic memoization via build-time analysis. Opt-in via build config (Babel/SWC plugin).
- Stop writing `useMemo`/`useCallback` defensively -- the compiler handles it
- `React.memo()` still needed for: third-party components, custom equality logic, compiler bail-out paths
- ref-as-prop (no `forwardRef` needed) simplifies component APIs
### Suspense + lazy loading
Route-level code splitting via `React.lazy()` + `<Suspense fallback={<PageSkeleton />}>`. Missing `<ErrorBoundary>` above `<Suspense>` causes rejected promises to crash silently.
### useTransition for non-urgent updates
```tsx
const [isPending, startTransition] = useTransition()
const handleFilterChange = (value: string) => {
startTransition(() => setFilter(value))
}
```
## 14. Developer Experience
### HMR / React Fast Refresh
Breaks silently when a file exports both components and non-component values, or uses anonymous default exports. Keep component files component-only.
### TypeScript strict mode
Minimum: `strict: true`, `noUncheckedIndexedAccess: true`, `moduleResolution: "bundler"`, `jsx: "react-jsx"`.
### ESLint flat config
- `typescript-eslint` with `recommendedTypeChecked`
- `eslint-plugin-react-hooks` + `eslint-plugin-react-refresh`
- Run Prettier separately from ESLint -- never use `eslint-plugin-prettier`
## 15. Accessibility
### What Radix/shadcn handles automatically
Focus trapping in dialogs, arrow key navigation in menus/tabs/listboxes, `aria-expanded`/`aria-selected` state attributes, Escape to close overlays.
### What you must provide
- `aria-label` on icon-only buttons
- `<Label>` wired to every form input via `htmlFor`
- Context for screen readers ("Delete user John Smith", not just "Delete")
- `role="status"` for real-time updates (polite), `role="alert"` for errors (assertive)
- Keyboard-only navigation smoke test before every PR
### Anti-patterns
- Icon-only buttons without `aria-label` or `<span className="sr-only">`
- `onClick` on `div` without `role="button"`, `tabIndex={0}`, and keyboard handler
- Relying on colour alone for validation state (fails WCAG 1.4.1)
- Overriding Radix's `role` attribute -- silently breaks keyboard patterns
## 16. Component Organization
### Feature-based structure
```
src/
features/
tasks/
components/
task-table/
index.ts # public API only
task-table.tsx # implementation
task-table.test.tsx
hooks/
queries/
task-queries.ts # queryOptions factories
index.ts # feature public API
components/ # shared UI only (Button, Modal)
lib/ # utils, api client, auth helpers
```
- Features don't import from each other -- shared code surfaces to `lib/` or `components/`
- Co-locate tests, types, and styles with the component they belong to
- Barrel exports only for the feature's public API -- not a catch-all re-export (48%+ bundle bloat reported)
- Kebab-case for files (`task-table.tsx`); PascalCase for component names inside files
## Page Patterns
### List page
- `isLoading` (first load) -> full skeleton; `isFetching` (background refetch) -> subtle indicator
- Empty state varies by context: "no results for filter" (offer clear) vs "nothing exists" (offer create)
- Use `<Skeleton>` with fixed heights matching real content to prevent layout shift
### Detail page
- Tab state in URL via `useSearchParams`
- Lazy render inactive tab content
### Dashboard page
- Each card fetches its own data (independent loading states)
- TanStack Query deduplicates identical queryKey calls
- `staleTime: 30_000` + `refetchInterval: 60_000` for real-time feel without WebSocket overhead
## API Client Architecture
### Typed fetch wrapper
For projects with an OpenAPI spec, `openapi-fetch` (2.9 KB) provides full type safety from the spec. Without a spec, use a factory function wrapping native fetch.
### RFC 9457 error handling
Gate on `application/problem+json` content type. Create an `ApiProblemError` class with `isValidation()` and `isRateLimit()` helpers.
### Token injection
Validate token expiry before making API calls (proactive), not in a 401 response interceptor (reactive). Use the OIDC client's `getAccessTokenSilently()`.
### Run the Real Build (`tsc -b`), Not Just `tsc --noEmit`, Before Pushing
`tsc --noEmit` (what many `typecheck` scripts run) does NOT check test files or apply project-reference settings. `tsc -b` / `vite build` resolves project references and applies `noUnusedLocals` across ALL files including tests — so CI's `npm run build` fails on `TS6133 'X' is declared but never read` in a test file that `typecheck` passed clean. Make the pre-push hook run the actual build command CI runs, not the lighter check. Related strict-mode friction with `noUncheckedIndexedAccess: true` (recommended in §14): every `arr[i]` is `T | undefined` — guard the access or use a justified non-null assertion.