<essential_principles>
Stack (2025-2026)
| Layer | Tool | Notes |
|---|---|---|
| Build | Vite + SWC | npm create vite@latest app -- --template react-swc-ts |
| Framework | React 19 | Compiler auto-memoization |
| Language | TypeScript 5.x (strict) | noUncheckedIndexedAccess required |
| Components | shadcn/ui + Radix UI | Copy-paste blueprints, you own the code |
| Styling | Tailwind CSS v4 | CSS-first config, OKLCH colors |
| Client State | Zustand | One store per feature |
| Server State | TanStack Query v5 | Query key factories, queryOptions |
| Routing | React Router v7 | Data mode, middleware for auth, typegen |
| Forms | React Hook Form + Zod | z.infer single source of truth |
| API Client | Axios | Interceptors for JWT refresh |
| Testing | Vitest + Testing Library + userEvent | getByRole first |
| Icons | lucide-react | Consistent with shadcn |
Core Conventions
- •TypeScript strict mode is non-negotiable. Always
"strict": true+"noUncheckedIndexedAccess": true. - •Never use
any. Useunknownand narrow with type guards. - •File naming: kebab-case for files (
user-profile.tsx), PascalCase for components (UserProfile), camelCase for hooks and utils. - •Test behavior, not implementation. Query by
getByRolefirst. - •Profile before optimizing. Never blanket
memoeverything. - •Accessibility is a requirement, not a feature. Semantic HTML first, ARIA second.
</essential_principles>
<project_setup>
Scaffold:
npm create vite@latest my-app -- --template react-swc-ts cd my-app npm install
tsconfig.app.json (strict):
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"isolatedModules": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"paths": { "@/*": ["./src/*"] }
}
}
Path aliases: Use vite-tsconfig-paths plugin so @/components/Button resolves to src/components/Button.
Vite only transpiles -- it does NOT type-check. Add "typecheck": "tsc --noEmit" to package.json scripts and run it in CI.
ESLint: Use typescript-eslint with strict type-checked rules, plus eslint-plugin-react-hooks, eslint-plugin-react-refresh, eslint-plugin-jsx-a11y.
Directory structure:
src/
components/ # Shared components
ui/ # shadcn/ui generated components (don't modify)
features/ # Feature-specific components, hooks, utils
devices/
auth/
dashboard/
hooks/ # Shared custom hooks
lib/ # Utilities (cn, api client, etc.)
routes/ # Route components (pages)
schemas/ # Zod schemas (shared between forms and API)
stores/ # Zustand stores
types/ # Shared TypeScript types
tests/ # Test setup and helpers
</project_setup>
<shadcn_ui>
What it is: Copy-paste component blueprints built on Radix UI primitives + Tailwind CSS. No runtime package -- you own the code after generation.
Setup:
npx shadcn@latest init npx shadcn@latest add button card dialog input table form
The cn() utility (required for class merging):
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
Tailwind v4 changes:
- •CSS-first configuration:
@themedirective in CSS, notailwind.config.js - •Colors use OKLCH instead of HSL
- •Replace
tailwindcss-animatewithtw-animate-css
Conventions:
- •Keep
components/ui/for generated shadcn components; don't modify in place - •Use a
components/ui/overrides/folder for heavy customizations - •Declare
dark:variants early; retrofitting is double work - •Pin Radix versions -- major bumps change prop contracts
- •Use
size-*utility instead ofw-* h-*pairs
</shadcn_ui>
<state_management>
Rule: Zustand for client/UI state. TanStack Query for server state. Never mix them.
Zustand (client state)
Use for: sidebar open/closed, wizard steps, theme preference, form wizard state.
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";
interface SidebarStore {
isOpen: boolean;
toggle: () => void;
}
export const useSidebarStore = create<SidebarStore>()(
devtools(
persist(
(set) => ({
isOpen: true,
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
}),
{ name: "sidebar-storage" }
)
)
);
Conventions:
- •One store per domain/feature, not one monolithic store
- •Always type stores with
create<StoreType>()() - •Use
devtoolsmiddleware in development - •Use
persistfor state that survives page refresh - •Design selectors first:
const isOpen = useSidebarStore((s) => s.isOpen)-- never subscribe to whole store - •No async side effects in stores -- those belong in TanStack Query
TanStack Query v5 (server state)
Query key factory pattern:
export const deviceKeys = {
all: ["devices"] as const,
lists: () => [...deviceKeys.all, "list"] as const,
list: (filters: DeviceFilters) => [...deviceKeys.lists(), filters] as const,
details: () => [...deviceKeys.all, "detail"] as const,
detail: (id: string) => [...deviceKeys.details(), id] as const,
};
Colocate with queryOptions for type safety:
import { queryOptions } from "@tanstack/react-query";
export const deviceDetailOptions = (id: string) =>
queryOptions({
queryKey: deviceKeys.detail(id),
queryFn: () => api.devices.get(id),
});
// Usage: useQuery(deviceDetailOptions(id))
Mutation with invalidation:
export function useUpdateDevice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: UpdateDeviceInput) => api.devices.update(data),
onSuccess: (_data, variables) => {
queryClient.invalidateQueries({ queryKey: deviceKeys.detail(variables.id) });
queryClient.invalidateQueries({ queryKey: deviceKeys.lists() });
},
});
}
v5 breaking changes:
- •
isPendingreplacesisLoadingfor initial load - •
gcTimereplacescacheTime - •
onSuccess/onErrorremoved fromuseQuery-- useuseEffect - •Single object parameter API for all hooks
</state_management>
<routing>Use createBrowserRouter + <RouterProvider>, not <BrowserRouter>.
Protected routes with middleware (v7.9+):
const authMiddleware: Route.unstable_MiddlewareFunction = async ({ context }) => {
const user = await getUser();
if (!user) throw redirect("/login");
context.user = user;
};
export const middleware = [authMiddleware];
Loaders (data fetching before render):
export async function loader({ request }: Route.LoaderArgs) {
const devices = await api.devices.list();
return { devices };
}
export default function DevicesPage({ loaderData }: Route.ComponentProps) {
const { devices } = loaderData;
}
Error boundaries: Define ErrorBoundary per route. Nested routes propagate errors up to the nearest boundary.
React Router v7 notes:
- •Use
react-routeronly (notreact-router-dom-- merged in v7) - •TypeScript typegen auto-generates
.d.tsfor routes in.react-router/types/ - •Route-level code splitting with
React.lazy+Suspense
<api_integration>
Axios instance with JWT interceptors:
import axios from "axios";
export const api = axios.create({
baseURL: "/api/v1",
headers: { "Content-Type": "application/json" },
});
// Request: attach JWT
api.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response: refresh on 401
let isRefreshing = false;
let failedQueue: Array<{ resolve: Function; reject: Function }> = [];
api.interceptors.response.use(
(res) => res,
async (error) => {
const original = error.config;
if (error.response?.status === 401 && !original._retry) {
if (isRefreshing) {
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
}).then(() => api(original));
}
original._retry = true;
isRefreshing = true;
try {
const { data } = await axios.post("/api/v1/auth/refresh", {
refresh_token: localStorage.getItem("refresh_token"),
});
localStorage.setItem("access_token", data.access_token);
failedQueue.forEach(({ resolve }) => resolve());
return api(original);
} catch {
failedQueue.forEach(({ reject }) => reject(error));
localStorage.clear();
window.location.href = "/login";
return Promise.reject(error);
} finally {
isRefreshing = false;
failedQueue = [];
}
}
return Promise.reject(error);
}
);
Typed API functions:
export const devicesApi = {
list: (params?: { page?: number; search?: string }) =>
api.get<PaginatedResponse<Device>>("/devices", { params }).then((r) => r.data),
get: (id: string) =>
api.get<Device>(`/devices/${id}`).then((r) => r.data),
create: (input: CreateDeviceInput) =>
api.post<Device>("/devices", input).then((r) => r.data),
};
Never use the same Axios instance for refresh token requests (infinite loop). Use plain axios.post for the refresh call.
</api_integration>
<forms>React Hook Form + Zod: single source of truth.
import { z } from "zod";
export const deviceSchema = z.object({
name: z.string().min(1, "Name is required").max(100),
ipAddress: z.string().ip({ message: "Invalid IP address" }),
port: z.coerce.number().int().min(1).max(65535),
});
export type DeviceFormData = z.infer<typeof deviceSchema>;
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
function DeviceForm({ onSubmit }: { onSubmit: (data: DeviceFormData) => void }) {
const form = useForm<DeviceFormData>({
resolver: zodResolver(deviceSchema),
defaultValues: { name: "", ipAddress: "", port: 443 },
});
return (
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* shadcn Form components */}
</form>
);
}
Conventions:
- •Always use
z.infer<typeof schema>-- never write a separate TypeScript type - •Keep schemas in
schemas/for sharing between frontend and backend - •Use
z.coerce.number()for inputs that come in as strings - •Cross-field validation with
.refine()(e.g., password confirmation)
Setup (vite.config.ts):
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
globals: true,
setupFiles: "./src/tests/setup.ts",
css: true,
},
});
Setup file (src/tests/setup.ts):
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
import "@testing-library/jest-dom/vitest";
afterEach(() => { cleanup(); });
Test pattern:
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, it, expect, vi } from "vitest";
describe("DeviceCard", () => {
it("calls onSelect when clicked", async () => {
const onSelect = vi.fn();
const user = userEvent.setup();
render(<DeviceCard device={mockDevice} onSelect={onSelect} />);
await user.click(screen.getByRole("button", { name: /select/i }));
expect(onSelect).toHaveBeenCalledWith(mockDevice.id);
});
});
Query priority: getByRole > getByLabelText > getByText > getByTestId
Conventions:
- •Use
userEvent.setup()+await user.click()overfireEvent - •Use
findByRole/waitForfor async components - •Mock API calls, not component internals
- •Create a
renderWithProviderswrapper that includesQueryClientProvider,RouterProvider - •One assertion focus per test
Semantic HTML first, ARIA second. If a native element exists (<button>, <nav>, <dialog>), use it.
Requirements:
- •All interactive elements Tab-reachable
- •Focus order follows visual/logical sequence
- •Focus visible -- never
outline: nonewithout replacement - •Skip links to bypass navigation
- •Route changes: move focus to main heading
- •Modals: trap focus, restore on close
- •Data tables:
<table>withscope="col",aria-sortfor sortable columns - •Toast notifications:
role="status"oraria-live="polite" - •Charts:
aria-labelon container + visually-hidden data table
Tooling:
- •
eslint-plugin-jsx-a11yfor lint-time checks - •
axe-core/ Axe DevTools for automated WCAG 2.2 AA audits - •Radix UI (used by shadcn) handles most ARIA patterns correctly
Route-level code splitting (highest ROI):
const Dashboard = lazy(() => import("./pages/Dashboard"));
// In routes:
{ path: "/dashboard", element: <Suspense fallback={<PageSkeleton />}><Dashboard /></Suspense> }
Virtual lists for large datasets:
import { useVirtualizer } from "@tanstack/react-virtual";
Use @tanstack/react-virtual for lists > 100 items.
Concurrent features:
- •
useTransitionfor non-urgent updates (filtering, searching) - •
useDeferredValuefor expensive re-renders on search input
Conventions:
- •Profile with React DevTools Profiler before optimizing
- •
React.memoonly when parent re-renders frequently with stable child props - •Use skeleton loaders that match content structure (prevent layout shift)
- •Analyze bundle with
rollup-plugin-visualizer
<typescript_patterns>
Discriminated unions for state:
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; error: Error }
| { status: "success"; data: T };
Variant props (prevent impossible states):
type ButtonProps =
| { variant: "link"; href: string; onClick?: never }
| { variant: "button"; onClick: () => void; href?: never };
Generic components:
interface SelectProps<T> {
items: T[];
value: T;
onChange: (item: T) => void;
getLabel: (item: T) => string;
}
function Select<T>({ items, value, onChange, getLabel }: SelectProps<T>) { ... }
Conventions:
- •Props: use
interface(nottype) -- usetypefor unions and mapped types - •Extend native elements:
React.ComponentPropsWithoutRef<"button"> - •Custom hook returns:
as constfor tuple inference - •Config objects:
satisfiesoperator for type checking with literal preservation - •Empty objects:
Record<string, never>not{} - •Event handlers:
React.MouseEventHandler<HTMLButtonElement>
</typescript_patterns>
<anti_patterns>
- •Prop drilling through 3+ levels -- use Zustand or React context
- •Fetching in useEffect -- use TanStack Query or route loaders
- •Global CSS -- use Tailwind utility classes or CSS modules
- •Barrel exports (
index.tsre-exports) -- slows build and breaks tree-shaking - •Premature
useMemo/useCallback-- profile first, memoize only proven bottlenecks - •
anytypes -- useunknownwith type guards - •Testing implementation details -- query by role, not by class name or test-id
- •Mixing client and server state -- Zustand for UI, TanStack Query for API data
</anti_patterns>
<success_criteria> A well-built React frontend:
- •TypeScript strict mode with no
anyor@ts-ignore - •All components accessible (axe-core passes WCAG 2.2 AA)
- •Server state managed by TanStack Query with proper cache invalidation
- •Client state in Zustand with typed selectors
- •Forms validated with Zod schemas (single source of truth)
- •Route-level code splitting reduces initial bundle
- •Tests cover user-facing behavior with Testing Library
- •ESLint + Prettier pass with zero warnings </success_criteria>