feat: add React + Vite dashboard best practices topic
Cross-project reference covering React 19, Vite, Tailwind v4, shadcn/ui, TanStack Query/Table, react-hook-form + Zod, OIDC auth, Vitest + Testing Library + MSW, and 16 other dashboard patterns.
This commit is contained in:
489
react-vite-dashboard.md
Normal file
489
react-vite-dashboard.md
Normal file
@@ -0,0 +1,489 @@
|
||||
# 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()`.
|
||||
Reference in New Issue
Block a user