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

19 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 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

@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

@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

  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

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

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/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

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.