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.
18 KiB
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.
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
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.
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
// 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 viaimport.meta.env.VITE_FOO - Type-safe: extend
ImportMetaEnvinsrc/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 manualChunksfunction form (not object) for vendor cache separation- Measure first with
rollup-plugin-visualizerbefore 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
@import "tailwindcss";
@theme {
--font-sans: "Inter", sans-serif;
--color-brand-500: oklch(0.62 0.19 250);
}
- No
tailwind.config.jsneeded -- all customisation lives in CSS - Auto content detection (respects
.gitignore), nocontent: []array @themetokens are emitted as native CSS custom properties- Colors in OKLCH (not HSL) -- v4's native color space
Dark mode
@custom-variant dark (&:where(.dark, .dark *));
Toggle .dark on <html> via JS. Replaces the v3 darkMode: 'class' config key.
CVA for component variants
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)
: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
- Extend (add CVA variant) -- preferred
- Wrap (compose) -- for structural additions like IconButton
- Fork (copy + modify) -- last resort; you own the component, CLI updates won't apply
Token discipline
- Semantic pairs: always define
--foo+--foo-foregroundtogether --primary= action color (buttons, links), not brand identity--radiusdrives 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
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()overz.union()-- better error messages and performance - Server validation errors:
setError('root.serverError', { message })for global,setError(field, { message })for field-level - Dynamic fields:
useFieldArraywithkey={field.id}(never array index) - Wizard/multi-step: single
useFormin Context, step-level validation viatrigger(['field1', 'field2'])
8. Data Tables
TanStack Table v8
createColumnHelper<T>()for full type inference on column definitions- Server-side processing: set
manualSorting,manualFiltering,manualPaginationand omitgetSortedRowModel/getFilteredRowModel - Include sorting/filter/pagination state in TanStack Query
queryKeyfor automatic refetching - Use
placeholderData: keepPreviousDatato prevent table flash between pages - Reset pagination when filters change
- Virtualisation (
@tanstack/react-virtual) andgetPaginatedRowModelare mutually exclusive - Expandable rows:
getSubRowsfor tree data,colSpandetail 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: truefor background token refreshonSigninCallbackmust clean OIDC params from URL -- without it,signinSilentbreaks on refreshoffline_accessscope 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 jsdomwhen full CSS cascade neededsetupFileswith@testing-library/jest-domand cleanupcss: trueto process Tailwind imports
Testing Library priorities
getByRole(accessible + specific) -- always first choicegetByLabelTextfor form inputsgetByTextfor visible textgetByTestId-- last resort only
userEvent over fireEvent
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
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/useCallbackdefensively -- the compiler handles it React.memo()still needed for: third-party components, custom equality logic, compiler bail-out paths- ref-as-prop (no
forwardRefneeded) 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
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-eslintwithrecommendedTypeCheckedeslint-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-labelon icon-only buttons<Label>wired to every form input viahtmlFor- 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-labelor<span className="sr-only"> onClickondivwithoutrole="button",tabIndex={0}, and keyboard handler- Relying on colour alone for validation state (fails WCAG 1.4.1)
- Overriding Radix's
roleattribute -- 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/orcomponents/ - 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_000for 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().