
Building React Web Application
- 52 installs
- Updated August 4, 2026
- cedmandocdoc/awesome-skills
Guides building Vite + React SPAs in TypeScript with TanStack Router file-based routes, Tailwind v4, TanStack Query, Zustand, and shadcn-style UI primitives.
About
Provides an opinionated Vite + React SPA architecture covering routing, UI primitives, forms, state, API hooks, styling, and E2E testing. A developer uses it when creating or updating a Vite React project that follows this library stack and folder structure.
- Stack: Vite, TanStack Router file-based routing, Tailwind v4, TanStack Query, Zustand
- Task-to-docs table covering routes, forms, API hooks, styling, and E2E testing
Building React Web Application by the numbers
- 52 all-time installs (skills.sh)
- Ranked #1,286 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cedmandocdoc/awesome-skills --skill building-react-web-applicationAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 52 |
|---|---|
| Last updated | August 4, 2026 |
| Repository | cedmandocdoc/awesome-skills ↗ |
What it does
Guides building Vite + React SPAs in TypeScript with TanStack Router file-based routes, Tailwind v4, TanStack Query, Zustand, and shadcn-style UI primitives.
Files
React web application
Opinionated ecosystem for building Vite-based React SPAs with a consistent architecture, library stack, and UI system (src/ui).
Tech stack
| Layer | Choice |
|---|---|
| Bundler | Vite |
| Language | TypeScript |
| Styling | Tailwind CSS v4, class-variance-authority (cva + cx from the same package) |
| Routing | TanStack Router (file-based, src/routes) |
| Server state | TanStack Query |
| Client global state | Zustand |
| HTTP | Axios |
| Presentational UI | shadcn/ui-style primitives in src/ui/ |
When to use
Load this skill for:
- New or existing Vite + React SPAs that use TanStack Router, Tailwind v4, and shared UI in
src/ui/ - Architecture, library, or folder-structure decisions aligned with managing-project-structure
- UI, state, API, routing (layouts, path params, search params), or styling work that should follow this skill’s conventions
Match the work to every Task type that applies — many tasks span multiple rows (e.g. new route + form). Open every link in the Docs column from each matching row before coding.
| Task type | Docs |
|---|---|
| New route / feature | managing-project-structure, creating-feature, creating-route-component, creating-screen-component, creating-component, TanStack Router — File-based routing |
UI primitive (src/ui/) | creating-component, creating-ui-component, managing-wrapper-components, add-registry-component.cjs |
| Feature component | creating-component, creating-feature-component, managing-wrapper-components |
| Form (single step) | creating-form-component, managing-form-error, managing-state, TanStack Form — Basic concepts |
| Multi-step form | managing-stepper-hook, managing-stepper-form, creating-form-component, managing-form-error, TanStack Form — Basic concepts, TanStack Form — Form composition |
| API + data hooks | creating-api, setting-up-axios, managing-api-error, managing-state |
| Refactor / move component | creating-component |
| Styling / theme | styling, styling-preference, setting-up-theming, setting-up-tailwind-theme, overriding-classname, Tailwind CSS — Using Vite |
| Fonts | MDN — @font-face, Google Fonts, setting-up-theming, setting-up-tailwind-theme |
| Project bootstrap | managing-project-structure, managing-environment, linting, Tailwind CSS — Using Vite, TanStack Router — Installation with Vite |
| Routing only | creating-route-component, creating-navigation-component, TanStack Router — Installation with Vite, TanStack Router — File-based routing |
| E2E testing | creating-e2e-testing, managing-project-structure |
Creating API
Overview
Use this guide to keep API code small, typed, and independent from React. Put HTTP clients and request functions in src/api/, then call them from feature hooks.
The transport is not fixed: use Axios, fetch, Supabase, or another client. This doc defines layer boundaries and a default structure; client-specific setup lives in client.ts and optional companion references (for example setting-up-axios.md).
Prerequisites
- managing-api-error.md
Guidelines
Structure
The layout below is the default starting point, not a closed set. Add files or folders when multiple pieces share the same role (for example interceptors/ for auth refresh logic, or schemas/ for request validation).
- Use hyphen-case backend folders under
src/api/. - Keep one backend per folder.
- Group request functions by domain.
src/libs/
└── ApiError.ts
src/api/<backend-name>/
├── client.ts # create and export the configured HTTP client
├── env.ts # parsed env vars for this backend
├── utils.ts # shared helpers when small (< ~200 lines total)
├── utils/ # one file per helper when utils grow (e.g. toApiError.ts)
├── models/
│ └── Workshop.ts
└── modules/
└── workshops.ts| File / folder | Role |
|---|---|
env.ts | Parse and export environment values for this backend (see managing-environment.md). |
client.ts | Create and export one shared client instance for the backend; read options from env.ts. |
utils.ts / utils/ | Shared helpers such as toApiError and FALLBACK_MESSAGE. |
models/ | Request/response types and domain error enums. |
modules/ | Typed functions per domain; import and use the shared client from client.ts. |
Layout rules
- Start shared helpers in
utils.ts(error mappers, response parsers). Split intoutils/<helperName>.tswhen the file exceeds ~200 lines or helpers are easier to find by name — same rule as creating-feature.md. - Start shared types in
models/<Domain>.ts; add amodels/subfolder or split files when types grow. - Add role-based folders (
interceptors/,schemas/,mappers/, etc.) when grouping improves clarity.
Client rules
- Keep
src/api/free of React, feature modules, and Zustand stores. - Configure the HTTP client in
client.ts; export one sharedclientinstance per backend (see managing-environment.md). - Module functions import
clientfromclient.ts—do not accept the client as a parameter. - Use explicit return types on exported functions.
Error handling
- Import `ApiError` from
@/libs/ApiError; map failures to it insrc/api/(see managing-api-error.md). - Map transport-specific errors in
utils.ts,utils/, or modulecatchblocks—not in feature hooks or components. - Do not invent user-facing copy in feature hooks or components.
Examples
Example: Axios module function
When using Axios, see setting-up-axios.md for createClient and responseData.
import type { Workshop } from "../models/Workshop";
import { client, responseData } from "../client";
import { toApiError } from "../utils";
export async function getWorkshops(): Promise<Workshop[]> {
try {
return await responseData(client.get<Workshop[]>("/workshops"));
} catch (err) {
throw toApiError(err);
}
}Example: Supabase module function
When using Supabase, configure and export the shared client in client.ts; module functions import it the same way.
import type { Workshop } from "../models/Workshop";
import { client } from "../client";
import { toApiError } from "../utils";
export async function getWorkshops(): Promise<Workshop[]> {
try {
const { data, error } = await client.from("workshops").select("*");
if (error) throw error;
return data ?? [];
} catch (err) {
throw toApiError(err);
}
}Use API code from a feature hook
Feature hooks call module functions directly—the shared client stays inside src/api/.
import { useQuery } from "@tanstack/react-query";
import { getWorkshops } from "@/api/app-api/modules/workshops";
export function useWorkshops() {
return useQuery({
queryKey: ["app-api", "workshops", "list"],
queryFn: getWorkshops,
});
}Keep components unaware of the HTTP client
- Presentational components use feature hooks; HTTP stays in hooks and
src/api/. - Do not call the transport client directly from presentational components.
Creating Component
Overview
Start here for any component work. This guide routes you to the right deep-dive doc. Read the decision tree first, then open only the linked creation guide for your case.
Decision tree
| You are building… | Go to |
|---|---|
Shared UI primitive (Button, Input, Dialog…) | creating-ui-component.md |
Domain UI block (CartItemRow, CheckoutSummary…) | creating-feature-component.md |
| Route-facing page or layout for a feature | creating-screen-component.md → register in creating-route-component.md |
Navigation component (AppShell, AppSidebar…) | creating-navigation-component.md → wire in creating-route-component.md |
Route layer file under src/routes/ | creating-route-component.md |
Pre-bound form field / form shell (*Field, FieldShell) | creating-form-component.md |
Already built but wrong layer? Re-run the tree in Recategorizing.
Placement
| Kind | Location |
|---|---|
| Screen / page component | src/features/<feature-name>/*Page.tsx |
| Feature components | src/features/<feature-name>/components/ |
| Navigation components | src/features/navigation/components/ |
| Navigation hooks | src/features/navigation/hooks/ |
| Route layer | src/routes/ |
| Shared UI primitives | src/ui/ (flat unless a subsystem owns a folder) |
| Composition roots | src/ui/Form/ when multiple related files belong together |
| Design tokens / theme | src/theme.css; root global.css imports Tailwind + theme |
Class merging (cx) | class-variance-authority (with cva) |
- Put product rules and domain behavior in
src/features/<feature-name>/— queries, stores, and domain logic inhooks/per managing-state.md. - Put reusable navigation components in
src/features/navigation/. - Put reusable, presentation-only UI primitives in
src/ui/. - Put route registration and wiring in
src/routes/. - Import primitives with
@/ui/<file>; use relative imports insidesrc/ui/.
Shared rules
- Use functional components and named exports.
- Prefer
interfacefor props. - Export one component per file; name the file after the export (
ProfileCard.tsx→ProfileCard). - Keep every component at 200 lines or fewer; split into smaller parts before implementing.
- Prefer compound parts (
Button,ButtonText,ButtonIcon) overtypeof childrenswitches. - UI primitives (
src/ui/) are presentation-only — no business logic, data fetching, mutations, or routing decisions.
Naming (baseline)
- PascalCase exports; singular nouns (
UserCard, notUsersCard). - Match file name to export name.
- `src/ui/` — generic names:
Button,TextInput,Dialog. - *`src/features//components/
** — feature-prefixed when domain-specific:CheckoutButton,CartItemRow`. - *`src/features/<feature>/Page.tsx
** — route-facing pages:WorkshopListPage,SettingsPage`. - Use
<Feature><Entity><Type>for feature components (AuthLoginForm,OrderSummaryCard). - Use props or CVA variants for state — not
PrimaryButtonorDisabledInput. - Related parts share a prefix:
CartItem,CartItemImage,CartItemPrice.
Each creation guide adds type-specific naming rules.
Recategorizing an existing component
When reuse grows, re-run the decision tree:
- Presentation-only and cross-feature → move to
src/ui/per creating-ui-component.md. - Business logic, data access, or stores in `src/ui/` → extract logic to feature
hooks/per managing-state.md; leave a presentation-only primitive. - Navigation components reused across routes → move to
src/features/navigation/per creating-navigation-component.md. - Domain behavior used across routes → extract to a new feature module per creating-feature.md.
- Still tied to one route flow → keep in the current feature.
Update folder placement and the feature barrel export contract when moving code.
Related
- managing-wrapper-components.md — shallow wrappers and
cxmerging - managing-state.md — queries, stores, and where feature logic lives
- creating-feature.md — feature module structure, folder layout, and barrels
- creating-route-component.md — route layer wiring
Creating E2E Testing
Overview
Use this guide to test complete user journeys and page interactions with Playwright. Use the Page Object Model for maintainable test code focused on critical user workflows.
Guidelines
Core principles
1. Test complete journeys — Focus on full page interactions and user flows. 2. Use Page Object Model — Encapsulate page interactions in reusable objects. 3. Organize by structure — Maintain clear directories for scalability. 4. Use TestId locators — Apply data-testid with namespace conventions. 5. Configure browser projects — Set up different viewports in config, not mid-test.
When to create E2E tests
Choose the test type based on what you are protecting.
Single page test — Use for mostly static marketing or informational pages (for example home, about us, contact us). Assert that key content, layout, and interactive elements render correctly on one route. These are visual and content smoke tests; they do not cross routes.
Flow page test — Prefer this when testing user behavior or a feature end to end (for example logging in, booking, checking out). The test navigates across pages and verifies the full workflow. Flow tests are the most valuable E2E coverage because they catch routing, state, and integration issues that single-page tests miss.
| Test type | Best for | Examples |
|---|---|---|
| Single page | One-route content and layout | Home hero, about page copy, contact form visible |
| Flow page | Multi-step user behavior | Login → dashboard, browse → book → confirm |
Use E2E for:
- Complete user journeys (login → navigate → action)
- Cross-page navigation and routing
- Critical business workflows
- Marketing page content and layout smoke checks (single page tests)
Do not use E2E for:
- Component logic (use unit tests)
- API validation (use integration tests)
- Individual form field validation
- CSS styling details
Test organization
Keep E2E code at the project root under tests/ (see managing-project-structure.md).
tests/
├── e2e/
│ ├── home.spec.ts # Single page tests
│ └── user-login.spec.ts # Flow tests
├── pages/ # Page Object Model
│ ├── BasePage.ts
│ └── HomePage.ts
├── fixtures/ # Test data
│ └── auth.ts
└── utils/ # Test utilities
└── helpers.tsFile naming
- Single page tests:
{page-name}.spec.ts - Flow tests:
{journey-name}.spec.ts - Page objects:
{PageName}Page.ts
TestId conventions
Use namespaced data-testid attributes on interactive and assertion targets in src/:
// Pattern: {feature}:{component}:{element}
data-testid="auth:login:submit"
data-testid="workshops:list:create-button"
data-testid="nav:header:logo"Add data-testid in feature or route components when building UI that E2E tests must target. Prefer test IDs over CSS selectors or brittle text matches.
Examples
Page object
// tests/pages/HomePage.ts
import { expect, Locator, Page } from '@playwright/test';
export class HomePage {
private readonly heading: Locator;
constructor(private page: Page) {
this.heading = page.getByTestId('home:hero:heading');
}
async goto(): Promise<void> {
await this.page.goto('/');
}
async verifyContent(): Promise<void> {
await expect(this.heading).toBeVisible();
}
}Single page test
// tests/e2e/home.spec.ts
import { test } from '@playwright/test';
import { HomePage } from '../pages/HomePage';
test('displays page content correctly', async ({ page }) => {
const homePage = new HomePage(page);
await homePage.goto();
await homePage.verifyContent();
});Flow page test
// tests/e2e/user-login.spec.ts
import { test, expect } from '@playwright/test';
import { LoginPage } from '../pages/LoginPage';
import { HomePage } from '../pages/HomePage';
import { authFixture } from '../fixtures/auth';
test.describe('User Login Journey', () => {
test('user can login and navigate to dashboard', async ({ page }) => {
const loginPage = new LoginPage(page);
const homePage = new HomePage(page);
await loginPage.goto();
await loginPage.loginWith(authFixture.validUser);
await expect(page).toHaveURL('/');
await homePage.verifyContent();
await expect(page.getByText('Welcome, John Doe')).toBeVisible();
});
test('login failure shows error and stays on login page', async ({ page }) => {
const loginPage = new LoginPage(page);
await loginPage.goto();
await loginPage.loginWith(authFixture.invalidUser);
await expect(page.getByText('Invalid credentials')).toBeVisible();
await expect(page).toHaveURL('/login');
});
});Test fixtures
// tests/fixtures/auth.ts
export const authFixture = {
validUser: {
email: 'test@example.com',
password: 'password123',
},
invalidUser: {
email: 'invalid@example.com',
password: 'wrongpassword',
},
adminUser: {
email: 'admin@example.com',
password: 'admin123',
},
};Test utilities
// tests/utils/helpers.ts
import type { Page } from '@playwright/test';
export class TestHelpers {
static async clearStorage(page: Page): Promise<void> {
await page.evaluate(() => localStorage.clear());
}
static async mockApi(page: Page, url: string, response: object): Promise<void> {
await page.route(url, (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(response),
}),
);
}
}Review checklist
- Focus on pages and user journeys only
- Choose single page vs flow test type deliberately
- Use Page Object Model for all page interactions
- Organize tests in the proper directory structure
- Follow file naming conventions
- Use fixtures for test data management
- Implement proper wait strategies
- Use
data-testidlocators with namespace conventions - Test complete user workflows for flow tests
Creating Feature Component
Overview
Create domain UI components in src/features/<feature-name>/components/. These compose @/ui/* primitives and may contain feature-specific logic, hooks, and event handlers.
Start from creating-component.md. For module barrels and exports, see creating-feature.md.
Prerequisites
- creating-component.md — placement and shared rules
- creating-ui-component.md — when you need a missing shared primitive first
Naming
- Use `<Feature><Entity><Type>` when the component carries domain meaning:
AuthLoginForm,CartItemRow,OrderSummaryCard. - Prefix with the feature when the name only makes sense in that product area:
CheckoutButton,SearchInput. - Group related parts with a shared prefix:
CartItem,CartItemImage,CartItemPrice,CartItemQuantity. - Suffixes:
Card,Item/Row,Form,Modal/Dialog— pick one list pattern and stay consistent.
Guidelines
What belongs here
- UI blocks tied to product rules or feature state.
- Event handlers and side effects specific to the feature.
- Composition of
@/ui/*primitives and other feature components. - Derived display logic that callers should not duplicate.
What does not belong here
- Reusable presentation-only primitives → creating-ui-component.md.
- Route layer files →
src/routes/per creating-route-component.md. - HTTP clients and request functions →
src/api/per creating-api.md.
Composition
- Prefer smaller feature components over one large file.
- Feature components may import sibling feature components in the same module.
- Import shared primitives from
@/ui/<file>— run the UI registry path first when a primitive is missing.
Extraction heuristic
When building a route view, split recurring rendering blocks into named feature components if they clarify the tree and stay under 200 lines. If a block is presentation-only and reused across features, promote it to src/ui/ instead.
Examples
Feature component composing UI primitives
import { Button } from "@/ui/Button";
import { useWorkshopStore } from "../hooks/useWorkshopStore";
export function WorkshopEnrollCta({ workshopId }: { workshopId: string }) {
const enroll = useWorkshopStore((s) => s.enroll);
return (
<Button type="button" onClick={() => enroll(workshopId)}>
Enroll now
</Button>
);
}Grouped sub-parts
src/features/cart/components/
CartItem.tsx
CartItemImage.tsx
CartItemPrice.tsx
CartItemQuantity.tsxRelated
- creating-feature.md — feature module structure and barrels
- managing-state.md — Query, Zustand, and local state in features
Creating Feature
Overview
Use this guide to write feature modules in src/features/<feature-name>/.
A feature module packages domain logic with the feature UI, and exposes a small export surface for routes and other features — commonly a page, plus hooks, types, helpers, and components as needed. This keeps reusable primitives in src/ui/ and keeps data fetching/API code in src/api/ (usually via feature hooks).
For components inside a feature folder, see creating-feature-component.md. For route-facing pages, see creating-screen-component.md. For navigation components, see creating-navigation-component.md.
Feature folder layout
The layout below is the default starting point, not a closed set. Add folders when multiple files share the same role (for example schemas/ for Zod form schemas).
src/features/<feature-name>/
├── <Feature>Page.tsx # route-facing page (see creating-screen-component.md)
├── index.ts # public barrel
├── components/ # domain UI blocks
├── hooks/ # query hooks, stores, feature hooks (see managing-state.md)
├── types.ts # shared types; split to types/ when large
├── utils.ts # shared pure helpers when small (< ~200 lines total)
├── utils/ # one file per helper when utils grow (e.g. getUser.ts, formatDate.ts)
├── constants.ts # shared constants
├── env.ts # when this feature reads env (see managing-environment.md)
└── schemas/ # example extension — form-driven featuresLayout rules
- Place the route-facing page at the feature root (
<Feature>Page.tsx). - Place supporting UI in
components/. - Place hooks in
hooks/— including Zustand stores (use<Feature>Store.ts). - Start shared types in
types.ts; move totypes/<domain>.tsor atypes/folder when the file grows. - Start shared pure helpers in
utils.ts(formatters, getters, mappers). Split intoutils/<actionName>.tswhen the file exceeds ~200 lines or helpers are easier to find by name. - Keep shared constants in
constants.ts. - Add
env.tswhen only this feature reads those variables — see managing-environment.md. - Add role-based folders (
schemas/,mappers/, etc.) when grouping improves clarity; keep internal files off the barrel unless they are part of the public API.
Guidelines
Mental model
Keep feature categories predictable; real features can still be grouped in different ways.
Isolated vs grouped features
- Isolated features: one complete package that typically exports one primary page (for example,
WorkshopListPage) plus the hooks, types, and helpers callers need. - Grouped features: a feature exports multiple related public pieces when they are meant to be used together — for example a page, a toolbar component, and a search helper.
A feature is not limited to page exports. The barrel may also publish components, hooks, pure functions, types, and constants that belong in the public API.
This guide favors isolated features first, but it allows grouped features when it improves clarity.
Per-route is a common grouping
- A route module under
src/routes/stays thin — see creating-route-component.md. - The route registers the feature page and optionally wires navigation components from
src/features/navigation/. - The feature folder owns the behavior that would otherwise bloat the route file.
Isolation is about dependency boundaries
- Internal files stay private to the feature; the barrel is the only surface other modules depend on.
Respect the team's categorization when it helps
Some teams categorize features based on product language (e.g. "billing", "onboarding") rather than UI structure. When that makes the app easier to maintain, this guide allows that variation.
Placement rules
- Follow Feature folder layout for internal files.
- Place shared presentation-only primitives in
src/ui/. - Keep HTTP clients and request functions in
src/api/and call them from feature hooks.
Grouping rules
- Prefer an isolated feature when callers can use it as a single package.
- Prefer a grouped feature when the exports are inseparable in practice.
- Prefer per-route feature modules when the logic is primarily owned by one route flow.
Module size heuristic
- If a feature folder becomes hard to reason about, split it into smaller feature modules and compose them from the route or a parent feature.
- When the same pieces are repeatedly composed across multiple routes, that repetition is usually a signal to extract a reusable feature module.
Export contract
- Each feature folder exposes a barrel (commonly
src/features/<feature-name>/index.ts). - Callers import from
@/features/<feature-name>only; keep the barrel stable over time. - Export only what other modules need: pages, components, hooks, pure helpers, types, and constants that form the public API.
- An isolated feature typically exports one primary page plus supporting hooks and types.
- A grouped feature exports multiple named parts (for example a page, a component, and a helper function).
- Keep internal implementation files off the barrel.
Examples
Isolated feature barrel
export { WorkshopListPage } from "./WorkshopListPage";
export { useWorkshops } from "./hooks/useWorkshops";
export type { Workshop } from "./types";Grouped feature barrel
Exports a page plus related components and helpers — not every feature needs a primary page:
export { WorkshopListPage } from "./WorkshopListPage";
export { WorkshopToolbar } from "./components/WorkshopToolbar";
export { buildWorkshopSearch } from "./search/buildWorkshopSearch";
export type { WorkshopSearchParams } from "./search/types";Creating Form Component
Overview
Create pre-bound form fields and shells under src/ui/Form/ with TanStack Form. Routes and features compose *Field components and stay free of repeated wiring (labels, errors, field state).
Start from creating-component.md. TanStack Form is headless — this guide covers where code lives and how to compose and reuse fields in this stack.
Prerequisites
- TanStack Form — Basic concepts — form instances, fields, meta, subscribers
- TanStack Form — Form composition —
createFormHook, pre-bound components, contexts
Guidelines
Library and folder placement
- Use `@tanstack/react-form` for form state. Bind field values and handlers to DOM or
@/ui/*controls in pre-bound components (for example `Input`, `Checkbox`, `Select` from the registry). - Keep all pre-bound form pieces under `src/ui/Form/`: contexts,
createFormHook, shared shells, and every component passed intofieldComponents/formComponents. Export the app hook from `src/ui/Form/index.tsx` so features import@/ui/Form— not from scattered helpers. - Put one pre-bound field per file when it grows beyond a few lines (for example
InputField.tsx). Keep small shared pieces such as `FieldShell.tsx` and `SubscribeButton.tsx` alongsideindex.tsx.
Expected layout:
src/ui/Form/
contexts.ts — createFormHookContexts (avoids circular imports with field files)
index.tsx — createFormHook, registrations, exports
FieldShell.tsx — label + children + error slot (optional if tiny → index.tsx)
InputField.tsx — pre-bound field for Input
SubscribeButton.tsx — pre-bound submit (optional if tiny → index.tsx)Pre-bound strategy
- Abstract field state in pre-bound components — each field file uses
useFieldContextand is registered infieldComponents. Call sites pass name viaform.AppFieldand domain props (for examplelabel) only; they do not reimplementuseFieldwiring per screen. - Reuse a shared field shell (
FieldShellor an existing registryField) for label, layout, and the error slot. Pass the control (Input,Textarea, etc.) inside the pre-bound field so style and layout stay centralized. - If no shell exists, add `FieldShell.tsx` under
Form/(see Creating field components). Do not duplicate that wrapper in every feature.
Naming
- Pre-bound fields use `NameOfControl + Field` (for example
Input→ `InputField`, registered keyInputField→ `field.InputField`). - Form-level components use a clear name (for example `SubscribeButton`, `TransientServerError`).
Submit actions
- Pre-bind the app’s submit control in
formComponents(the TanStack docs often use `SubscribeButton`). Implement it with `@/ui/Button`, or a native `<button type="submit">`, wired throughuseFormContextandform.SubscribeforisSubmittingand related state. Keep the control in theForm/folder and register it fromindex.tsx. - The key in `formComponents` becomes `form.<Key>` (for example
SubscribeButton→ `form.SubscribeButton`). Same for `fieldComponents`: `InputField` → `field.InputField` in `form.AppField` children.
Routes and features
- UI in
src/features/<feature>/or route modules compose fields and the form hook; they do not redefinecreateFormHookor field contexts. - Keep API submission beside other server logic (TanStack Query mutations, Axios clients) per creating-api.md; use the form’s submit handler to call validated values into those layers.
Official guides (behavior and APIs)
Use these for validation timing, submit lifecycle, and fine-grained reactivity. This skill does not duplicate those pages.
| Topic | Doc |
|---|---|
| Validation | Form and field validation |
| Submission | Submission handling |
| Reactivity | Reactivity |
Setup
Install the package in the app project:
npm install @tanstack/react-formComposition shape
Build contexts in `contexts.ts`, define field and form components in sibling files, then pass them into `createFormHook` from `index.tsx`.
// src/ui/Form/contexts.ts
import { createFormHookContexts } from "@tanstack/react-form";
export const { fieldContext, formContext, useFieldContext, useFormContext } =
createFormHookContexts();// src/ui/Form/index.tsx — illustrative layout
import { createFormHook } from "@tanstack/react-form";
import { fieldContext, formContext } from "./contexts";
import { InputField } from "./InputField";
import { SubscribeButton } from "./SubscribeButton";
const { useAppForm, withForm } = createFormHook({
fieldContext,
formContext,
fieldComponents: {
InputField,
},
formComponents: {
SubscribeButton,
},
});
export { useAppForm, withForm };Registered field components appear on the `field` object inside `form.AppField` (for example <field.InputField label="…" />). Registered form components appear on `form` (for example <form.SubscribeButton label="…" /> inside `form.AppForm`). See Form composition for withForm, lazy loading, and tree-shaking.
Creating field components
1) Shared field shell
Create `FieldShell.tsx` so every field gets consistent label and error rendering. Wire the error slot to accept values from field meta and server mapping (see managing-form-error.md).
// src/ui/Form/FieldShell.tsx
import type { ReactNode } from "react";
import type { ApiError } from "@/libs/ApiError";
import type { ZodError } from "zod";
import { FormError } from "@/ui/FormError";
import { Label } from "@/ui/Label";
export function FieldShell({
children,
error,
label,
}: {
children: ReactNode;
error?: ApiError | ZodError | string;
label?: string;
}) {
return (
<div className="flex flex-col gap-2">
{label ? (
<Label className="text-foreground text-label font-body-semibold">
{label}
</Label>
) : null}
{children}
<FormError error={error ?? ""} />
</div>
);
}When the project already has a registry `Field` primitive with label and error slots, use that instead of FieldShell and keep the same pre-bound field pattern below.
2) Pre-bound field file
Add one file per control, for example `InputField.tsx`. Use `useFieldContext`, connect the control to field state, and wrap with `FieldShell`.
// src/ui/Form/InputField.tsx
import { useFieldContext } from "./contexts";
import { FieldShell } from "./FieldShell";
import { Input } from "@/ui/Input";
export function InputField({ label }: { label: string }) {
const field = useFieldContext<string>();
return (
<FieldShell label={label} error={field.state.meta.errors[0]}>
<Input
value={field.state.value}
onChange={(e) => field.handleChange(e.target.value)}
onBlur={field.handleBlur}
/>
</FieldShell>
);
}Register `InputField` in fieldComponents inside `index.tsx`. Add Zod validators on the form so front-end validation runs automatically; error display in the shell is covered in managing-form-error.md.
Repeat for other controls (TextareaField, CheckboxField, etc.) using the same `NameOfControl + Field` naming.
Examples
Feature or route composes AppField and pre-bound components
Call `useAppForm` from @/ui/Form, then use `form.AppField` so `AppField` supplies field context to pre-bound components.
import { useAppForm } from "@/ui/Form";
export function SignInForm() {
const form = useAppForm({
defaultValues: { email: "", password: "" },
onSubmit: async ({ value }) => {
/* call mutation / API — see submission guide */
},
});
return (
<form.AppForm>
<form.AppField
name="email"
children={(field) => <field.InputField label="Email" />}
/>
<form.AppField
name="password"
children={(field) => <field.InputField label="Password" />}
/>
<form.SubscribeButton label="Sign in" />
</form.AppForm>
);
}Use `form.AppForm` where the composition guide requires the form context wrapper (for example around `form.SubscribeButton`). Rename keys in formComponents / fieldComponents if the app prefers different *`form.** / **field.`* names.
Related
- managing-form-error.md —
onServer,onSubmit.fields, and wiring errors intoFieldShell - managing-state.md — where API and client state live relative to forms
- creating-api.md — submitting validated payloads through feature hooks
Creating Navigation Component
Overview
Create reusable navigation components in src/features/navigation/ — stack headers, bottom tab bars, drawer content, app shells, sidebars, and related hooks. These components are consumed from src/routes/ by default; other features may import them when route-level wiring is impractical.
This module is navigation infrastructure, not a user-facing product feature. Import via @/features/navigation.
Start from creating-component.md. For wiring navigation components in route modules, see creating-route-component.md.
Prerequisites
- creating-ui-component.md — when the navigation component is a reusable primitive
- creating-route-component.md — default wiring in
src/routes/ - creating-screen-component.md — route-facing screens (navigation components stay out of screen trees by default)
Naming
Derive the component name from the route navigator module name in src/routes/ (see creating-route-component.md). Drop the Navigator suffix and append the navigation slot type:
| Route navigator | Navigation component |
|---|---|
MainBottomNavigator | MainBottomTabBar |
ProfileStackNavigator | ProfileStackHeader |
MainDrawerNavigator | MainDrawerContent |
- Stack-style layout:
[Module]StackHeader— e.g.ProfileStackNavigator→ProfileStackHeader. - Bottom-tab-style layout:
[Module]BottomTabBar— e.g.MainBottomNavigator→MainBottomTabBar. - Drawer-style layout:
[Module]DrawerContent— e.g.MainDrawerNavigator→MainDrawerContent. - App-wide shell:
AppShellwhen a single root wrapper wraps all authenticated routes. - Hooks:
useProfileStackHeader,useMainBottomTabBar— live insrc/features/navigation/hooks/. - Use one navigator-scoped component per layout slot instead of per-page duplicates.
Guidelines
Prefer whole navigation components
Default: replace the entire layout navigation surface with a custom component. Keep nav items, icons, labels, and spacing inside that component so navigation UI changes stay in one place.
Whether to use a custom navigation component at all depends on the prompt; when unspecified, use custom navigation components.
| Layout pattern | Component | Wire in |
|---|---|---|
| Stack-style section header | [Module]StackHeader | Layout route that owns the section |
| Bottom-tab-style sub-nav | [Module]BottomTabBar | Layout route around child <Outlet /> |
| Drawer-style sidebar | [Module]DrawerContent | Layout route around child <Outlet /> |
| App shell | AppShell or [Module]Shell | Root or authenticated layout route |
Placement
src/features/navigation/
├── components/
│ ├── AppShell.tsx
│ ├── MainBottomTabBar.tsx
│ ├── ProfileStackHeader.tsx
│ └── MainDrawerContent.tsx
├── hooks/
│ └── useMainBottomTabBar.ts
└── index.ts| Piece | Location |
|---|---|
| Shared layout / navigation components | src/features/navigation/components/ |
| Navigation-related hooks | src/features/navigation/hooks/ |
| Generic presentation-only primitives | src/ui/ when not navigation-specific |
| Screen / page body | src/features/<feature-name>/*Page.tsx |
Promote a component to src/ui/ only when it is reused outside navigation and carries no route-specific wiring.
Default path — wire from routes
- Build navigation components once in
src/features/navigation/. - Import and wire the whole component from layout routes in
src/routes/— see creating-route-component.md. - Keep screen/page components focused on feature UI.
Exception — compose in a feature screen
When route-level wiring is too complex (dynamic navigation components driven by screen-local state, tight coupling between navigation components and page data), import navigation components directly in the feature screen/page. Prefer this only when src/routes/ wiring would be harder to follow than localized composition.
Layout navigation components (web)
- App shell / drawer / tab bar: structural wrappers rendered in layout routes around
<Outlet />. - Accept children for the main content area; keep URL and outlet wiring in
src/routes/. - Put all nav items, icons, labels, and spacing inside the custom navigation component.
- Reuse across route sections via nested layout routes.
What to avoid
- Copying the same header, sidebar, tab bar, or shell JSX into every feature page.
- Putting domain business logic in navigation components — navigation components are presentation and layout.
Examples
Drawer-style layout wired in a route navigator
src/features/navigation/components/MainDrawerContent.tsx:
import type { ReactNode } from "react";
import { Link } from "@tanstack/react-router";
export function MainDrawerContent({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen">
<aside className="w-64 border-r border-border bg-background p-4">
<nav className="flex flex-col gap-2">
<Link to="/workshops">Workshops</Link>
<Link to="/settings">Settings</Link>
</nav>
</aside>
<main className="flex-1">{children}</main>
</div>
);
}src/features/navigation/index.ts:
export { MainDrawerContent } from "./components/MainDrawerContent";Wire in src/routes/MainDrawerNavigator.tsx — see creating-route-component.md.
Stack-style header in a section layout
src/features/navigation/components/ProfileStackHeader.tsx:
import type { ReactNode } from "react";
export function ProfileStackHeader({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<div>
<header className="border-b border-border px-4 py-3">
<h1 className="text-lg font-semibold">{title}</h1>
</header>
{children}
</div>
);
}Wire in src/routes/ProfileStackNavigator.tsx around <Outlet />.
Feature page imports navigation components directly (exception)
import { WorkshopToolbar } from "@/features/navigation";
import { WorkshopListItem } from "./components/WorkshopListItem";
export function WorkshopListPage() {
return (
<div>
<WorkshopToolbar />
{/* page content */}
</div>
);
}Use when toolbar state is owned by the page and route-level wiring would obscure the flow.
Related
- creating-route-component.md — register pages and wire navigation components in
src/routes/ - creating-screen-component.md — feature page components
- setting-up-theming.md — design tokens for navigation component styling
Creating Route Component
Overview
Create the route layer under src/routes/: file-based route modules that register screen/page components from features and wire navigation components from src/features/navigation/.
Each route entry configures a screen and/or navigation:
| Configures | Source | Guide |
|---|---|---|
| Screen / page UI | src/features/<feature-name>/*Page.tsx | creating-screen-component.md |
| Layout navigation components (header, tab bar, drawer, shell) | src/features/navigation/ | creating-navigation-component.md |
Keep route files thin — URL structure, layouts, loaders, and wiring only. Domain UI stays in features.
Prerequisites
- managing-project-structure.md
- creating-screen-component.md
- creating-navigation-component.md — when wiring shared layout navigation components
- TanStack Router — Installation with Vite
Guidelines
Naming
Name layout route modules that own navigation UI `[Name][NavigatorType]` — prefix with a module or feature name, then the layout kind:
| File | Layout pattern |
|---|---|
MainBottomNavigator.tsx | Bottom-tab-style sub-nav |
ProfileStackNavigator.tsx | Stack-style section with header |
MainDrawerNavigator.tsx | Drawer-style sidebar |
- Use PascalCase for dedicated layout route module files.
- Match navigation component names to the navigator — see creating-navigation-component.md.
- TanStack Router file-based segments (
_authenticated/route.tsx, folder layouts) may re-export or compose these named layout modules when the plugin layout differs from the navigator name.
Structure
Default plugin layout (adjust only if you change plugin options):
src/routes/
├── __root.tsx # root layout; may wire app shell from features/navigation
├── index.tsx # example: /
├── MainDrawerNavigator.tsx # drawer-style layout module
├── ProfileStackNavigator.tsx # stack-style layout module
├── _authenticated/ # nested layout segment
│ ├── route.tsx # may compose MainDrawerNavigator + <Outlet />
│ └── workshops/
│ └── index.tsx # leaf — registers feature page
└── ...
src/routeTree.gen.ts # generated from src/routes/- Add route modules under
src/routes/using TanStack Router file-based conventions. - Import screen/page exports from
@/features/<feature-name>; import navigation components from@/features/navigation. - Use path params and search params with typed validation when URLs should be shareable (see Router docs).
Route responsibilities
- Register the feature screen/page as the route
component. - Wire whole layout navigation components in layout routes — headers, tab bars, sidebars, persistent shell around
<Outlet />. - Own loaders, pending/error boundaries, and search-param validation when the route needs them.
- Do not embed domain logic or reusable UI blocks — extract to features.
Wiring navigation components
Default: import whole navigation components from @/features/navigation in a layout route and render them around <Outlet /> — see creating-navigation-component.md.
| Layout pattern | Component | Wire in |
|---|---|---|
| Stack-style section | [Module]StackHeader | Layout route around <Outlet /> |
| Bottom-tab-style sub-nav | [Module]BottomTabBar | Layout route around <Outlet /> |
| Drawer-style sidebar | [Module]DrawerContent | Layout route around <Outlet /> |
- Exception: when route-level wiring is too complex (dynamic navigation components per nested state, tight coupling to screen data), compose navigation components directly in the feature screen/page — see creating-navigation-component.md.
Plugin setup
1. Install @tanstack/router-plugin (and router packages per the official guide). 2. Register tanstackRouter before @vitejs/plugin-react in vite.config.ts, with target: 'react' and options such as autoCodeSplitting: true as needed.
See the full snippet in Installation with Vite.
Generated route tree
routeTree.gen.tsis generated fromsrc/routes/; change route modules, not this file.- Lint / format ignore: exclude it from ESLint and Prettier (or Biome). See linting.md.
- VS Code: optionally mark the file readonly and exclude from search/watch for quieter diffs after renames.
Choosing layout patterns
| Pattern | Use it when |
|---|---|
| Root layout | Shared shell: html/body class, devtools, providers that wrap all routes |
| Stack-style layout | A route section needs a persistent header above child pages |
| Drawer-style layout | Sidebar or slide-out navigation wraps child paths |
| Bottom-tab-style layout | Peer sections at the same URL depth with a persistent sub-nav |
| Index + siblings | Peer URLs at the same segment; use folder route.tsx layouts as needed |
Refer to Routing concepts for path syntax, splats, and layout routes.
Setup
Install packages
Follow the router's installation guide for @tanstack/react-router and the Vite plugin versions compatible with your app.
Render the router at the root
After QueryClientProvider (if used), render RouterProvider with the generated route tree:
import { createRouter, RouterProvider } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";
const router = createRouter({ routeTree });
declare module "@tanstack/react-router" {
interface Register {
router: typeof router;
}
}
export function AppRouter() {
return <RouterProvider router={router} />;
}Adjust to match the current TanStack Router API for your version.
Examples
Leaf route — register a feature page
src/routes/workshops/index.tsx:
import { createFileRoute } from "@tanstack/react-router";
import { WorkshopListPage } from "@/features/workshop-list";
export const Route = createFileRoute("/workshops/")({
component: WorkshopListPage,
});Drawer-style layout route — wire navigation components
src/routes/MainDrawerNavigator.tsx:
import type { ReactNode } from "react";
import { MainDrawerContent } from "@/features/navigation";
export function MainDrawerNavigator({ children }: { children: ReactNode }) {
return <MainDrawerContent>{children}</MainDrawerContent>;
}src/routes/_authenticated/route.tsx:
import { createFileRoute, Outlet } from "@tanstack/react-router";
import { MainDrawerNavigator } from "@/routes/MainDrawerNavigator";
export const Route = createFileRoute("/_authenticated")({
component: () => (
<MainDrawerNavigator>
<Outlet />
</MainDrawerNavigator>
),
});Stack-style layout route — wire navigation components
src/routes/ProfileStackNavigator.tsx:
import type { ReactNode } from "react";
import { ProfileStackHeader } from "@/features/navigation";
export function ProfileStackNavigator({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return <ProfileStackHeader title={title}>{children}</ProfileStackHeader>;
}Navigate from a component
Use Link, useNavigate, and related APIs from @tanstack/react-router. Prefer typed route APIs when the project enables them.
import { Link } from "@tanstack/react-router";
export function WorkshopCta({ id }: { id: string }) {
return <Link to="/workshops/$workshopId" params={{ workshopId: id }}>Open</Link>;
}Related
- creating-screen-component.md — feature page components
- creating-navigation-component.md — shared layout navigation components
- creating-feature.md — feature module barrels
Creating Screen Component
Overview
Create route-facing page components exported from a feature module and registered by thin route modules in src/routes/. Pages own feature UI for a route; the route file wires params, layout, and navigation components.
Start from creating-component.md. For feature module structure, see creating-feature.md.
Prerequisites
- creating-feature.md — barrels and export contract
- creating-feature-component.md — smaller blocks inside the page
- creating-route-component.md — register pages and wire navigation components in
src/routes/
Naming
- Use the `Page` suffix for components rendered as a route destination:
WorkshopListPage,SettingsPage. - Use `Layout` for structural wrappers shared across routes when exported from a feature:
AuthLayout,AppLayout. - Page files live at the feature root:
src/features/<feature-name>/<Feature>Page.tsx.
Guidelines
Page responsibilities
- Compose feature components from
components/and@/ui/*primitives for the route's UI. - Read path or search params via TanStack Router hooks when needed.
- Wire TanStack Query hooks, mutations, and feature stores for this flow.
- Keep
src/routes/files thin — import and render the feature export.
What to avoid
- Defining reusable presentation-only primitives inline — extract to
src/ui/or feature components. - Bloating the route file with domain logic that belongs in the feature module.
- Duplicating layout navigation components — wire from
src/routes/via@/features/navigationper creating-navigation-component.md, unless localized composition is clearer.
Size and structure
- Keep pages focused; extract sub-trees to creating-feature-component.md when the file grows.
- Supporting UI blocks belong in
src/features/<feature-name>/components/, not beside the page file.
Examples
Feature page composed by a route
src/features/workshop-list/WorkshopListPage.tsx:
import { useWorkshops } from "./hooks/useWorkshops";
import { WorkshopListItem } from "./components/WorkshopListItem";
export function WorkshopListPage() {
const workshops = useWorkshops();
if (workshops.isLoading) return <p>Loading…</p>;
if (workshops.isError) return <p>{workshops.error.message}</p>;
return (
<ul>
{workshops.data?.map((workshop) => (
<WorkshopListItem key={workshop.id} workshop={workshop} />
))}
</ul>
);
}src/features/workshop-list/index.ts:
export { WorkshopListPage } from "./WorkshopListPage";
export { useWorkshops } from "./hooks/useWorkshops";src/routes/workshops/index.tsx:
import { createFileRoute } from "@tanstack/react-router";
import { WorkshopListPage } from "@/features/workshop-list";
export const Route = createFileRoute("/workshops/")({
component: WorkshopListPage,
});Related
- creating-route-component.md — layouts, params, and navigation wiring
- creating-navigation-component.md — shared layout navigation components
Creating UI Component
Overview
Create shared presentational primitives in src/ui/. Start from the creating-component.md decision tree.
Registry-first: check src/ui/ for an existing primitive. If missing, validate with shadcn/ui via shadcn view before vendoring. Build manually only when validation fails or no registry item fits.
Prerequisites
- creating-component.md — placement and shared rules
- setting-up-theming.md, managing-project-structure.md —
global.css/src/theme.cssandsrc/uilayout - managing-wrapper-components.md — shallow wrappers and
cxmerging
Folder layout
src/ui/ stays flat and presentation-only — no business logic, features, API, or stores. The layout below is the default; group files when a subsystem owns multiple related pieces.
src/ui/
├── Button.tsx # single primitive — one export per file
├── ButtonText.tsx # compound part (sibling file)
├── hooks/ # reusable UI-only hooks (e.g. useMediaQuery)
└── Form/ # composition root — see creating-form-component.md
├── index.tsx # public barrel for the group
└── InputField.tsxLayout rules
- Prefer
src/ui/<Component>.tsxfor standalone primitives; import with@/ui/<Component>. - Group related files under
src/ui/<GroupName>/when the subsystem has multiple files; export the public API fromindex.tsxorindex.ts. - Put reusable UI-only hooks in
src/ui/hooks/— not feature or data hooks. - Registry-added primitives land as flat files unless the add script creates a group.
Naming
- Generic, unprefixed names:
Button,Input,Dialog,Card. - Compound parts share the root prefix:
Button,ButtonText,ButtonIcon— one export per file. - Do not encode variant state in the name (
PrimaryButton→Buttonwithtoneprop).
Validate with shadcn view
Before running the add script, confirm the registry entry resolves:
1. Run (registry slug or full URL):
npx shadcn@latest view "${url}"2. Confirm exit code 0.
3. Parse stdout as JSON (strip markdown code fences if present).
4. Expect a JSON array with at least one object containing:
"$schema": "https://ui.shadcn.com/schema/registry-item.json"
If validation fails, do not run the add script. Build manually per Manual primitive below.
Run the add script
From the app project root:
npx shadcn@latest view button
node path/to/building-react-web-application/scripts/add-registry-component.cjs buttonPass a full registry item URL when the slug is not enough. Use --root <project-dir> when the cwd is not the app root.
The script vendors files into src/ui/ (for example Button.tsx), rewrites cn → cx, and fixes import paths. Import with @/ui/Button.
When the script cannot resolve a dependency, add the component by hand and keep it presentation-only.
Guidelines
- Use native HTML elements or
@/ui/*when composing. - Keep components presentation-only — props in, UI out.
- Normalize `cn` → `cx`: import `cx` from `class-variance-authority` when editing registry output by hand.
Examples
Use a vendored primitive in a feature
import { Button } from "@/ui/Button";
export function WorkshopCta() {
return <Button>Join workshop</Button>;
}Add a custom src/ui/ primitive (no registry item)
import type { ComponentProps, ReactNode } from "react";
import { cva, cx } from "class-variance-authority";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors",
{
variants: {
tone: {
primary: "bg-primary text-primary-foreground hover:bg-primary/90",
secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/80",
},
},
defaultVariants: { tone: "primary" },
},
);
interface ButtonProps extends ComponentProps<"button"> {
tone?: "primary" | "secondary";
children: ReactNode;
}
export function Button({ tone, className, children, ...props }: ButtonProps) {
return (
<button type="button" className={cx(buttonVariants({ tone }), className)} {...props}>
{children}
</button>
);
}Split complex controls into parts
Put each part in its own file under src/ui/:
src/ui/Button.tsx:
import type { ComponentProps, ReactNode } from "react";
import { cva, cx } from "class-variance-authority";
const rootVariants = cva("inline-flex items-center gap-2 rounded-md px-4 py-2");
export function Button({ children, className, ...props }: ComponentProps<"button">) {
return (
<button type="button" className={cx(rootVariants(), className)} {...props}>
{children}
</button>
);
}src/ui/ButtonText.tsx / src/ui/ButtonIcon.tsx — same pattern; compose in features:
<Button>
<ButtonIcon>{/* icon */}</ButtonIcon>
<ButtonText>Save</ButtonText>
</Button>Related
- add-registry-component.cjs — vendoring script
- styling.md — Tailwind utilities and CVA patterns
- overriding-classname.md — targeted
!overrides on shared components
Linting
Overview
Use this guide to set up ESLint and Prettier for this Vite + React TypeScript stack with flat config.
Prerequisites
- creating-route-component.md for where
routeTree.gen.tscomes from - TanStack Router — Installation with Vite for official ignore patterns and plugin setup
Guidelines
Tool ownership
- Let Prettier own formatting.
- Let ESLint own correctness, React rules, and TypeScript rules.
File-type scoped linting
- Use one config block for TypeScript source files (
**/*.{ts,tsx}) with@typescript-eslint/parser. - Use a separate block for JavaScript and config files (
**/*.{js,cjs,mjs,jsx}) with lighter rules. - Keep React Hooks and strict TypeScript rules in TypeScript app code.
Generated route tree
routeTree.gen.ts(typical path:src/routeTree.gen.ts) is generated fromsrc/routes/; edits belong in route modules, not this file.- Lint / format ignore: exclude it from ESLint and Prettier (or Biome) so generated code stays untouched. The TanStack doc links patterns for Prettier ignore and ESLint ignore.
- VS Code: optionally mark the file readonly and exclude from search/watch, as recommended in the installation doc, for quieter diffs after renames.
Setup
Install dependencies
npm install --save-dev eslint prettier eslint-plugin-prettier eslint-config-prettier @typescript-eslint/eslint-plugin @typescript-eslint/parser eslint-plugin-react eslint-plugin-react-hooksAdd a Prettier config
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100,
"tabWidth": 2
}Add ESLint rules (example)
const { defineConfig } = require("eslint/config");
const eslintPluginPrettierRecommended = require("eslint-plugin-prettier/recommended");
const reactPlugin = require("eslint-plugin-react");
const reactHooksPlugin = require("eslint-plugin-react-hooks");
const typescriptEslintPlugin = require("@typescript-eslint/eslint-plugin");
module.exports = defineConfig([
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
parser: require("@typescript-eslint/parser"),
parserOptions: {
project: "./tsconfig.json",
},
},
plugins: {
"@typescript-eslint": typescriptEslintPlugin,
react: reactPlugin,
"react-hooks": reactHooksPlugin,
},
rules: {
"react/jsx-no-leaked-render": [
"error",
{ validStrategies: ["ternary", "coerce"] },
],
"react-hooks/rules-of-hooks": "error",
"react-hooks/exhaustive-deps": "warn",
"@typescript-eslint/consistent-type-imports": "error",
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"eol-last": ["error", "always"],
},
},
{
files: ["**/*.{js,cjs,mjs,jsx}"],
plugins: {
react: reactPlugin,
"react-hooks": reactHooksPlugin,
},
rules: {
"react/jsx-no-leaked-render": "off",
"react-hooks/rules-of-hooks": "off",
"react-hooks/exhaustive-deps": "off",
"eol-last": ["error", "always"],
},
},
eslintPluginPrettierRecommended,
{
ignores: ["dist/*", "node_modules/*", "**/routeTree.gen.ts"],
},
]);Ignore generated routes
In eslint.config or .eslintignore, exclude **/routeTree.gen.ts. Add the same pattern to .prettierignore.
Add package scripts
{
"scripts": {
"lint": "eslint .",
"format": "prettier --write src/"
}
}Usage
CI and editors
- Apply the same ignore patterns in CI as locally so
npm run lintand Prettier skip the generated file. - After changing route files, regenerate (or let the dev server regenerate) and confirm the generated file stays ignored.
- Run
npm run lintin CI without--fix. - Optionally run
prettier --check src/in CI.
Managing API Error
Overview
Use this guide to keep user-facing error copy in src/api/. Every failed API call throws `ApiError`; feature hooks pass it through; routes and components only choose where and when to show error.message.
Prerequisites
- creating-api.md
- setting-up-axios.md
Guidelines
ApiError contract
Use one class in src/libs/ApiError.ts that extends Error.
| Field | Role |
|---|---|
message | User-facing copy. UI reads this only. |
status | Optional HTTP status (logging, API-layer branching—not new UI copy). |
code | Optional app-level code (domain enum). Not raw transport strings in UI. |
cause | Optional original error for debugging. |
Structure
src/libs/ApiError.ts
src/api/<backend-name>/
├── client.ts
├── env.ts
├── utils.ts # toApiError, FALLBACK_MESSAGE
├── models/ # types; <Domain>Error enum when needed
└── modules/ # endpoint functions; inline custom mapping in catch- Put domain error enums in
models/<Domain>.tsonly when UI needs a stable app code. - Keep
toApiErrorinutils.tsfor the default path. - Put rare, endpoint-specific mapping inline in that function’s
catch—no separate*.errors.tsfiles.
Layer responsibilities
| Layer | Owns | Does not own |
|---|---|---|
| `src/api/` | Map transport failures to ApiError; finalize message (reuse backend copy when safe; else fallback). | React, routing, toast vs inline layout. |
| Feature hooks | Call API functions; let failures propagate. | Rewriting messages for display. |
| Routes / components | Pass query.error through; show error.message on initial failure; retry via refetch. | Parsing Axios or duplicating fallback strings. |
Mapping rules
1. Reuse backend user copy when the payload already has a safe message (response.data.message, documented error field, etc.). 2. Map only when needed—missing payload, network failure, or non-user-facing text. Use a short generic fallback (for example “Something went wrong. Please try again.”). 3. Always throw ApiError from exported module functions. 4. Fall back to toApiError(err) when no special case matches.
Export FALLBACK_MESSAGE from utils.ts so UI fallbacks stay aligned with the API layer.
App codes vs transport codes
| Aspect | Transport | App (models + ApiError.code) |
|---|---|---|
| Parsed in | modules/<domain>.ts catch | new ApiError(…, { code: ProfileError.… }) |
| UI usage | Never branch on raw transport strings | Branch on domain enum only when layout/flow differs; otherwise use message |
Custom mapping (uncommon)
Use only when toApiError cannot produce the right message or code for that endpoint.
| Situation | Approach |
|---|---|
| Safe user copy on the payload | throw toApiError(err) |
| Non-user-facing transport signal | Inline map → domain enum + message, then throw new ApiError(…) |
| Pre-request validation | throw new ApiError("…", { code: … }) before the network call |
TanStack Query
queryFn/mutationFncall API functions directly; do not catch and reword for display.- On failure,
query.error/mutation.errorisApiErrorin normal operation. - Initial load error: show
query.error.messagein route or feature error UI. - Background refetch: keep cached data visible; optional toast is placement only, not a new copy source.
- Mutations: show
mutation.error.messagebeside the control or in a toast.
Examples
Default module function
import { client, responseData } from "../client";
export async function getWorkshops(): Promise<Workshop[]> {
try {
return await responseData(client.get<Workshop[]>("/workshops"));
} catch (err) {
throw toApiError(err);
}
}Inline custom mapping
} catch (err) {
if (axios.isAxiosError(err) && err.response?.status === 409) {
throw new ApiError("This handle is already taken.", {
status: 409,
code: ProfileError.HandleTaken,
cause: err,
});
}
throw toApiError(err);
}Display in route or feature UI
if (workshops.isError) {
return (
<div>
<QueryError error={workshops.error} />
<button type="button" onClick={() => void workshops.refetch()}>
Try again
</button>
</div>
);
}function QueryError({ error }: { error: unknown }) {
const message =
error instanceof ApiError ? error.message : FALLBACK_MESSAGE;
return <p role="alert">{message}</p>;
}Managing Environment
Overview
Use this guide when a feature, API backend folder, or other module reads configuration from the environment. Each such module keeps a dedicated env.ts that validates every variable that module needs with Zod, exports parseSchema, and exports the parsed values (for example env). That file is the only place that defines and validates env for the module; the parsed export is what the rest of the module uses at runtime.
Prerequisites
- managing-project-structure.md
Guidelines
Structure
- Add
env.tsat the boundary of the unit that owns the configuration: src/features/<feature-name>/env.tswhen only that feature reads those variables.src/api/<backend-name>/env.tswhen the API client layer for that backend reads them.- Another folder may use the same pattern when a cohesive module has its own env surface.
- List every key that module reads from `import.meta.env` in that single
env.ts; other files in the same module import the parsedenv(or equivalent) only.
Validation rules
- Define one Zod object schema that describes all required (and optional) variables for the module.
- Parse once when the module loads. Export:
parseSchema: the Zod object schema for this module (tests, composition, or reuse).- The parsed, typed result (convention:
envor a module-specific name such asappApiEnv). The parsed export is the source of truth for runtime values elsewhere in the module. - Prefer
.safeParseat the app root if the app should show a controlled startup error; inside leaf modules, failing fast with.parseis acceptable when misconfiguration should crash during development or CI.
Vite and public variables
- Client-visible values must be defined in
.envwith the `VITE_` prefix so Vite exposes them onimport.meta.env(see Vite — Env variables). VITE_*values ship in the client bundle; store secrets in server-side config, auth, or proxy patterns instead.
TypeScript
- Reference types for
import.meta.envvia Vite’s client types (e.g./// <reference types="vite/client" />insrc/vite-env.d.tsor equivalent).
Setup
Install Zod
npm install zodExamples
Feature module env.ts
import { z } from "zod";
export const parseSchema = z.object({
VITE_ANALYTICS_KEY: z.string().min(1),
});
export const env = parseSchema.parse({
VITE_ANALYTICS_KEY: import.meta.env.VITE_ANALYTICS_KEY,
});Use parsed env inside the same feature
import { env } from "./env";
export function trackEvent(name: string) {
return fetch("/analytics", {
method: "POST",
body: JSON.stringify({ name, key: env.VITE_ANALYTICS_KEY }),
});
}API backend env.ts
import { z } from "zod";
export const parseSchema = z.object({
VITE_API_URL: z.string().url(),
});
export const env = parseSchema.parse({
VITE_API_URL: import.meta.env.VITE_API_URL,
});Wire API client to parsed env
import { createClient } from "./client";
import { env } from "./env";
export const client = createClient({ baseURL: env.VITE_API_URL });Managing Form Error
Overview
Use this guide to handle form failures in TanStack Form with a clear split:
- Server submit errors are stored in
errorMap.onServerasApiErrorand shown via a pre-bound form-level component. - Local validation errors come from validators (for example Zod) and render via pre-bound
*Fieldcomponents and `FieldShell` (see creating-form-component.md).
Prerequisites
- managing-api-error.md
- creating-form-component.md —
src/ui/Form/layout,FieldShell, and pre-bound*Fieldcomponents
Workflow
1) Define error UI first (expects ApiError)
Build shared error UI that accepts ApiError (or unknown narrowed to ApiError) and renders user-facing copy from error.message.
- Keep error presentation reusable (inline message, toast, banner).
- Keep message ownership in API layer per managing-api-error.md.
2) Handle submit server error (onServer)
In form submit handlers, catch API or mutation failures and write them to onServer:
try {
await mutation.mutateAsync(value);
} catch (error: unknown) {
formApi.setErrorMap({ onServer: error as never });
}At runtime onServer holds ApiError from typed mutations (useMutation<…, ApiError, …>) and the API layer in managing-api-error.md. Use as never because TanStack Form’s setErrorMap typing does not accept ApiError on onServer directly.
Create a pre-bound form component that subscribes to errorMap.onServer, then use it as form.TransientServerError. Place the implementation in src/ui/Form/ (for example TransientServerError.tsx) and register it in formComponents from index.tsx:
import { useCallback } from "react";
import type { ReactElement } from "react";
/**
* Pre-bound form component that subscribes to server-time form errors
* and shows a transient bottom toast when the error is an Error instance.
*/
export function TransientServerError(): ReactElement {
const form = useFormContext();
const retrySubmit = useCallback(async (): Promise<void> => {
form.setErrorMap({ onServer: undefined });
await form.handleSubmit();
}, [form]);
return (
<form.Subscribe selector={(state) => state.errorMap.onServer}>
{(serverError) => (
<TransientErrorToast error={serverError} refetch={retrySubmit} />
)}
</form.Subscribe>
);
}Render it in form composition:
<form.AppForm>
{/* fields */}
<form.TransientServerError />
<form.SubscribeButton label="Submit" />
</form.AppForm>3) Handle local validation errors (Zod)
- Add Zod validators on the form so front-end validation runs automatically.
- Pass the first field meta error (or the mapped submit error) into `FieldShell`’s
errorprop from each pre-bound*Fieldinsrc/ui/Form/— see creating-form-component.md. - Reuse `FormError` inside `FieldShell` so
ApiErrorand Zod errors render consistently.
4) Set field-level API errors on submit
For server-returned field errors, set onSubmit errors with fields mapping:
formApi.setErrorMap({
onSubmit: {
fields: {
myField: apiError,
},
},
});Map the corresponding error into each pre-bound field’s `FieldShell` error prop (same slot as Zod validation). This keeps API-backed field errors inline with local validation.
Conventions
- Keep server-level failures in
onServer. - Keep per-field submit failures in
onSubmit.fields. - Reuse one error UI path (
FormErrorviaFieldShell) soApiErrorand Zod errors render consistently. - Do not redefine
FieldShellor*Fieldhere — extendsrc/ui/Form/per creating-form-component.md.
Managing Project Structure
Overview
Use this guide to organize the Vite + React SPA by responsibility. Keep routing, UI, features, API code, and state separate so each layer stays easy to change.
Guidelines
Structure
- Keep app code in
src/. - Keep Vite entry (
main.tsx) at the project root or undersrc/per your Vite template and import one root stylesheet. - Use kebab-case for feature folders.
| Area | Purpose |
|---|---|
global.css | Project root: Tailwind + shadcn Tailwind imports; @import of theme.css |
src/theme.css | Design tokens and @theme / :root / .dark (see setting-up-theming.md) |
src/routes/ | Route layer: register feature pages and wire navigation components (see creating-route-component.md) |
src/routeTree.gen.ts | Generated route tree (from src/routes/; edit route modules) |
src/ui/ | Presentation-only primitives — see creating-ui-component.md |
src/features/navigation/ | Reusable navigation components (headers, shells, sidebars) and navigation hooks |
src/features/<feature-name>/ | Domain modules — see creating-feature.md |
src/libs/ | Internal library modules — wrapped third-party logic or from-scratch utilities (imported elsewhere via @/libs/...) |
src/api/ | Framework-agnostic HTTP code — see creating-api.md |
tests/ | Playwright E2E tests (see creating-e2e-testing.md) |
Module internals
Use the owning guide for folder-level detail; this table is the map only.
| Area | Detail in |
|---|---|
src/features/<feature-name>/ | creating-feature.md |
src/ui/ | creating-ui-component.md |
src/api/<backend-name>/ | creating-api.md |
Feature env.ts | managing-environment.md |
| Feature hooks and stores | managing-state.md |
src/libs/
- Treat each lib as an internal library: wrapped third-party APIs, adapters, or from-scratch utilities shared across the app.
- Libs may depend on any npm package when the abstraction needs it.
- Do not import from other app folders (
src/api/,src/features/,src/routes/,src/ui/, etc.); depend only on npm packages and other files undersrc/libs/. - Prefer a single file when the module is small:
src/libs/ApiError.ts. - Use a folder with
index.tswhen the module grows:src/libs/date-utils/index.ts. - Import from app code via
@/libs/<name>regardless of file or folder shape.
Registry and src/ui
Primitives are added with [`add-registry-component.cjs`](../scripts/add-registry-component.cjs) (or npx shadcn@latest view for inspection). The script writes under `src/ui/`, rewrites imports for that layout, and normalizes `cn` → `cx` from `class-variance-authority`. Stock shadcn docs assume `@/components/ui` and a `cn` utility; this stack standardizes on `src/ui/` and `cx`.
Root providers
Wire cross-cutting providers once, above the router:
1. `QueryClientProvider` (TanStack Query) — see managing-state.md. 2. `RouterProvider` — from TanStack Router, using the generated route tree (see creating-route-component.md). 3. Theme / document class — if using class-based dark mode (e.g. .dark on <html>), set it from a small root component or layout route effect; keep tokens in `src/theme.css` per setting-up-theming.md.
Typical shape: main.tsx imports `../global.css` (or the correct relative path), creates queryClient, renders QueryClientProvider → RouterProvider.
Dependency flow
- Let route modules compose features,
ui, API hooks, and stores. - Let features import other features through their barrel file.
- Keep
src/ui/free of feature, API, and store imports. - Keep route files focused on routing concerns (layouts, loaders where used); delegate domain logic to features.
- Keep
src/api/independent from React and Zustand. - Keep
src/libs/isolated from other app folders; put shared library code here (for exampleApiError) sosrc/api/,src/features/, andsrc/ui/can import it via@/libs/.... - Keep Zustand stores inside feature hooks (
src/features/<feature-name>/hooks/use<Feature>Store.ts), even when other features consume them.
Imports
- Use
@/*for cross-module imports (map to./src/*). - Use relative imports inside the same module.
- Import features through
index.ts. - Keep this import order:
1. import type 2. react 3. react-dom (when needed) 4. external packages 5. internal @/...
Setup
Configure the path alias
Add @/* → ./src/* in tsconfig.json and keep vite.config.ts resolve.alias aligned if required.
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}Global CSS entry
Import the root stylesheet from main.tsx (or the Vite entry file):
import "../global.css";Ensure index.html references the JS entry; Vite injects CSS from that import.
Examples
Export a feature barrel
export { WorkshopListPage } from "./WorkshopListPage";
export { useWorkshops } from "./hooks/useWorkshops";
export type { Workshop } from "./types";Import from module boundaries
import { WorkshopListPage } from "@/features/workshop-list";
import { Button } from "@/ui/Button";Managing State
Overview
Use this guide to decide where state belongs. Use TanStack Query for server data, Zustand for feature-owned client state, TanStack Router path and search params for URL-owned state, and local React state for UI owned by one component.
Guidelines
Structure
- Put Zustand stores in
src/features/<feature-name>/hooks/use<Feature>Store.ts. - Put query hooks in
src/features/<feature-name>/hooks/. - Keep API calls in
src/api/.
Choose the right state tool
1. Use TanStack Query for data fetched from an API. 2. Use Zustand for client-only state owned by a feature (including stores consumed by multiple features, such as auth). 3. Use route params and validated search params for state that should be shareable via URL, bookmarkable, or restored on refresh—see TanStack Router docs. 4. Use useState or useReducer for local component state.
State rules
- Derive values in render when possible.
- Copy props or query data into local state only when there is a clear reason.
- Store semantic state such as
isOpenorstep; handle values likeopacitywith utilities and CVA unless interaction logic truly needs them in JS. - Use selectors with Zustand to reduce re-renders.
- Name store hooks with the
useXStorepattern and keep the file name aligned.
Setup
Install dependencies
npm install @tanstack/react-query zustandAdd QueryClientProvider at the root
Mount inside the same root tree as the router (typically wrapping RouterProvider or wrapped by it—pick one order and keep it consistent). Example:
import type { ReactNode } from "react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5,
retry: 2,
},
},
});
export function AppProviders({ children }: { children: ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
}Examples
Create a query hook
Module functions own the shared client; feature hooks import only the module function (see creating-api.md).
import { useQuery } from "@tanstack/react-query";
import { getWorkshops } from "@/api/app-api/modules/workshops";
export function useWorkshops() {
return useQuery({
queryKey: ["app-api", "workshops", "list"],
queryFn: getWorkshops,
});
}Create a Zustand store
Example path: src/features/auth/hooks/useAuthStore.ts
import { create } from "zustand";
interface AuthState {
token: string | null;
login: (token: string) => void;
logout: () => void;
}
export const useAuthStore = create<AuthState>((set) => ({
token: null,
login: (token) => set({ token }),
logout: () => set({ token: null }),
}));Prefer derived values
const [raw, setRaw] = useState<string | undefined>(undefined);
const value = raw ?? serverDefault;Stepper and wizard state
For multi-step flows built with Stepperize (see managing-stepper-hook.md):
- Keep active step index and step navigation in the stepper hook (
useXStepper) when the wizard is scoped to one route or feature flow. - Keep field values and validation in TanStack Form (see managing-stepper-form.md); do not mirror form fields in Zustand.
- Use Zustand only when step progress or draft data must survive leaving the route or be shared across features.
- Use route or search params when a step or sub-flow should be bookmarkable or restored on refresh.
Managing Stepper Form
Overview
Use this guide to build multi-step forms with Stepperize + `useAppForm` + Zod. Define per-step schemas on the step objects, read the current step schema from stepper.state.current.data, and wire it into form validators so each step validates only its own fields. Compose step UI with `form.AppField` and pre-bound *`field.** components from @/ui/Form`.
Prerequisites
Guidelines
Schema strategy
- Define a Zod schema for each form step.
- Attach each schema directly on the corresponding step object (for example
schema: PersonalSchema). - Read the active schema from
stepper.state.current.data.schema. - Fallback to
z.object({})when a step has no schema (for example confirmation/done steps).
Hook and provider convention
- Keep step definitions in a dedicated hook file (
use<Feature>Stepper.ts). - Export both:
use<Feature>Stepper<Feature>StepperScoped- Use
<Feature>StepperScoped>when splitting content and navigation into separate descendants.
Form flow
- Build one form instance with `useAppForm` from
@/ui/Form(see creating-form-component.md) and persist values across steps. - Use
validators.onChange(or the chosen validator timing) with the current step schema. - In
onSubmit, callstepper.navigation.next()when not on the last step. - Render per-step fields with
stepper.flow.switch(...)and compose inputs via `form.AppField` + pre-bound *`field.`** components. - Use
stepper.flow.is("done")(or final step ID) for completion state.
Setup
Install dependencies in the app project:
npm install @stepperize/react @tanstack/react-form zodExample
import { useAppForm } from "@/ui/Form";
import { z } from "zod";
import { defineStepper } from "@stepperize/react";
const PersonalSchema = z.object({
name: z.string().min(1, "Name is required"),
email: z.email("Email is invalid"),
});
const AddressSchema = z.object({
street: z.string().min(1, "Street is required"),
city: z.string().min(1, "City is required"),
});
const MultiStepSchema = defineStepper(
{ id: "personal", title: "Personal information", schema: PersonalSchema },
{ id: "address", title: "Address", schema: AddressSchema },
{ id: "done", title: "Done" },
);
export const useCheckoutStepper = MultiStepSchema.useStepper;
export const CheckoutStepperScoped = MultiStepSchema.Scoped;
type FormValues = {
name: string;
email: string;
street: string;
city: string;
};
export function CheckoutStepForm() {
const stepper = useCheckoutStepper();
const stepData = stepper.state.current.data;
const schema =
"schema" in stepData && stepData.schema
? (stepData.schema as z.ZodType<FormValues>)
: z.object({});
const form = useAppForm({
defaultValues: { name: "", email: "", street: "", city: "" },
validators: { onChange: schema },
onSubmit: () => {
if (!stepper.state.isLast) stepper.navigation.next();
},
});
if (stepper.flow.is("done")) return <p>All done!</p>;
return (
<form.AppForm>
{stepper.flow.switch({
personal: () => (
<div>
<form.AppField
name="name"
children={(field) => <field.InputField label="Name" />}
/>
<form.AppField
name="email"
children={(field) => <field.InputField label="Email" />}
/>
</div>
),
address: () => (
<div>
<form.AppField
name="street"
children={(field) => <field.InputField label="Street" />}
/>
<form.AppField
name="city"
children={(field) => <field.InputField label="City" />}
/>
</div>
),
done: () => null,
})}
<div>
<button type="button" onClick={() => stepper.navigation.prev()} disabled={stepper.state.isFirst}>
Back
</button>
<button
type="button"
onClick={() => {
void form.handleSubmit();
}}
>
{stepper.state.isLast ? "Submit" : "Next"}
</button>
</div>
</form.AppForm>
);
}Related
- creating-form-component.md — pre-bound TanStack Form composition in
src/ui/Form/ - managing-stepper-hook.md — base hook/provider pattern for Stepperize
- managing-state.md — decide local/store/server responsibilities around forms
Managing Stepper Hook
Overview
Use this guide to standardize Stepperize base usage in React web features. Create one dedicated hook module per flow (for example useBookingStepper.ts) that exports the typed useStepper hook and Scoped provider from one defineStepper declaration.
This keeps step definitions centralized, makes step IDs type-safe across the feature, and allows both local (hook-owned) and shared (provider-backed) stepper state patterns.
Prerequisites
Guidelines
File placement and naming
- Create one hook file per workflow in
src/features/<feature>/hooks/, nameduse<Feature>Stepper.ts(for exampleuseBookingStepper.ts). - Define steps once with
defineStepper(...)in that file. - Export at minimum:
use<Feature>Stepper(alias of StepperizeuseStepper)<Feature>StepperScoped(alias of StepperizeScoped)
Step shape
- Each step must have a unique
id. - Add display fields such as
titleordescriptionfor rendering labels and headings. - Keep business-specific step metadata on the step object so rendering and validation logic can read from
stepper.state.current.data.
State sharing rules
- Use
use<Feature>Stepper()directly in a component for local stepper state (no provider required). - Use
<Feature>StepperScoped>when multiple descendants must share the same stepper instance. - Do not mix different
defineStepperinstances for the same flow.
Navigation and rendering
- Prefer
stepper.flow.switch(...)for multi-step rendering branches. - Use
stepper.flow.is(id)for simple conditionals. - Use
stepper.navigation.next(),prev(),goTo(id), andreset()for transitions.
Setup
Install Stepperize in the app project:
npm install @stepperize/reactExample
Feature hook: useBookingStepper
import { defineStepper } from "@stepperize/react";
const bookingStepper = defineStepper(
{ id: "details", title: "Booking details" },
{ id: "contact", title: "Contact information" },
{ id: "review", title: "Review booking" },
{ id: "done", title: "Done" },
);
export const useBookingStepper = bookingStepper.useStepper;
export const BookingStepperScoped = bookingStepper.Scoped;Shared-state usage with Scoped
import { BookingStepperScoped, useBookingStepper } from "@/features/booking/hooks/useBookingStepper";
export function BookingFlow() {
return (
<BookingStepperScoped>
<BookingStepContent />
<BookingStepActions />
</BookingStepperScoped>
);
}
function BookingStepContent() {
const stepper = useBookingStepper();
return stepper.flow.switch({
details: () => <p>Choose date and time.</p>,
contact: () => <p>Enter contact details.</p>,
review: () => <p>Review booking.</p>,
done: () => <p>Booking complete.</p>,
});
}
function BookingStepActions() {
const stepper = useBookingStepper();
return stepper.state.isLast ? (
<button type="button" onClick={() => stepper.navigation.reset()}>
Reset
</button>
) : (
<>
<button type="button" onClick={() => stepper.navigation.prev()} disabled={stepper.state.isFirst}>
Back
</button>
<button type="button" onClick={() => stepper.navigation.next()}>
Next
</button>
</>
);
}Local-state usage without provider
import { useBookingStepper } from "@/features/booking/hooks/useBookingStepper";
export function BookingMiniStepper() {
const stepper = useBookingStepper();
return (
<section>
<h2>{stepper.state.current.data.title}</h2>
<button type="button" onClick={() => stepper.navigation.next()} disabled={stepper.state.isLast}>
Continue
</button>
</section>
);
}Related
- managing-state.md — choose where wizard state should live
- creating-feature.md — feature hook/file organization
Managing Wrapper Components
Overview
Use this guide to keep layout trees shallow. Prefer a single wrapper with merged Tailwind classes over stacked div elements that only exist to hold one utility group each.
For utility merging, see styling.md. For how primitives accept className, see creating-ui-component.md.
Guidelines
Prefer one wrapper
- Merge layout, spacing, and visual classes onto one element when they apply to the same box.
- Use `cx` from `class-variance-authority` to combine base styles, variants, and a caller
classNameprop on that single node. - Merge classes onto one node instead of nesting
divelements that only carry separateclassNamestrings.
When extra wrappers are justified
Add another wrapper only when layout or accessibility requires a distinct box, for example:
- Different flex or grid sections where merging would hurt readability.
- Interactive boundaries (
<button>,<a>, focusable regions) that must wrap only part of the subtree. - Scroll containers, sticky headers, or portal targets that need their own layout rules.
- Third-party components that require a specific child structure.
Routes and features
- Apply the same rule in route components and feature components: default to one outer container with merged classes, then split only for the cases above.
Examples
Shallow tree example
// Three divs only to layer classes — prefer merging
<div className="flex min-h-screen flex-col">
<div className="flex-1 bg-background p-4">
<div className="flex flex-col gap-2">{children}</div>
</div>
</div>// Prefer: one wrapper with merged classes
<div className="flex min-h-screen flex-col gap-2 bg-background p-4">{children}</div>Merge variant and override classes on one node
import type { ReactNode } from "react";
import { cx } from "class-variance-authority";
interface CardProps {
children: ReactNode;
className?: string;
}
export function Card({ children, className }: CardProps) {
return (
<div className={cx("rounded-xl border border-border bg-card p-4 text-card-foreground", className)}>
{children}
</div>
);
}Keep a second wrapper when layout requires it
// Row for actions, column for content — layout needs two boxes
<div className="flex flex-col gap-4 p-4">
<div className="flex flex-row items-center justify-between gap-2">
<h2 className="text-lg font-semibold">Title</h2>
{/* trailing actions */}
</div>
<div className="flex flex-col gap-2">{children}</div>
</div>Overriding className on shared components (Web)
Overview
Use this guide when a consumer passes Tailwind utilities through a component className prop and those utilities overlap with classes the base component already applies.
Mark every conflicting consumer utility with Tailwind's important modifier (!) so the override applies reliably. This stack uses Tailwind CSS v4: place ! at the end of the class name (for example text-lg!, sm:p-0!).
Why ! is needed
This stack merges classes with `cx` from class-variance-authority, which concatenates class strings but does not deduplicate Tailwind utilities (there is no tailwind-merge). When base and consumer classes target the same utility category, both remain in the DOM and CSS source order decides the winner—not the consumer's intent. The ! modifier forces the consumer utility to win.
Guidelines
When to use !
- Use this whenever a consumer's
classNameincludes utilities in the same Tailwind category as the base component (typography, spacing, color, layout, etc.). - Inspect the base component's default and variant classes to find overlaps; do not wait until a style fails to appear.
- Two utilities conflict when they target the same category—for example
text-smvstext-lg, orp-4vsp-2.
Which utilities get !
- Add
!at the end of every conflicting consumer utility—not the fullclassNamestring, and not only the first conflict you notice. - Leave non-conflicting utilities without
!.
Variants
Classes like sm:p-0 and hover:bg-primary stack one or more variants (responsive, state, dark:, etc.) before the utility. When a variant-prefixed utility conflicts with the base, still suffix ! at the very end of that token:
sm:p-0!— notsm:!p-0hover:bg-primary!— nothover:!bg-primarymd:hover:bg-primary!when multiple variants apply
Component authors
- Keep base classes minimal when a
classNameoverride is part of the component API. - Prefer dedicated variants (
size,variant, etc.) for standard visual options.
Consumers
- Read the base component's default
classNameand variant output before overriding. - Apply
!at the end of each conflicting token (for exampletext-lg!,p-2!,text-primary!).
Examples
Conflicting font size
<Button className="text-lg!">Save</Button>Conflicting padding
<Card className="p-2!">Summary</Card>Multiple conflicts in one className
<Label className="text-label! font-body-semibold! text-foreground">Name</Label>text-label and font-body-semibold conflict with defaults on Label; text-foreground does not, so it stays without !.
Conflicting variant-prefixed utilities
<Card className="sm:p-0!">Summary</Card>
<Button className="hover:bg-primary!">Save</Button>Prefer variants for repeated overrides
<Button size="lg">Save</Button>Setting Up Axios
Overview
Axios for the browser via a small client factory and env-based base URL and options—one place to grow auth, timeouts, and interceptors.
Steps
Install Axios
npm install axiosCreate a client
import axios from "axios";
import type { AxiosInstance, AxiosResponse } from "axios";
interface ClientConfig {
baseURL: string;
withCredentials?: boolean;
}
export function createClient({
baseURL,
withCredentials = true,
}: ClientConfig): AxiosInstance {
return axios.create({ baseURL, timeout: 30000, withCredentials });
}
export async function responseData<T>(
promise: Promise<AxiosResponse<T>>,
): Promise<T> {
return (await promise).data;
}Add environment variables
- Use `VITE_` for public API base URLs and similar client config only.
- Keep secrets out of
VITE_*; they ship in the client bundle—use server-side config, auth, or proxy patterns instead.
VITE_API_URL=http://localhost:3000Read values through a parsed env module per managing-environment.md. Export the shared client instance from client.ts using that parsed env (see managing-environment.md).
Setting Up Tailwind Theme (semantic tokens)
Overview
After setting-up-theming.md, utilities like bg-background, text-primary, and rounded-lg resolve through the `@theme inline` block and CSS variables defined in `src/theme.css` (content aligned with the shadcn manual Configure styles section, split from root global.css). This file covers conventions for using those tokens in components; edit the variable definitions only in `src/theme.css` (no second copy of the block here).
Prerequisites
- setting-up-theming.md
Guidelines
Prefer semantic utilities
- Use role-based classes tied to CSS variables:
bg-background,text-foreground,border-border,bg-primary,text-primary-foreground,text-muted-foreground,bg-destructive, etc. - Use radius tokens (
rounded-md,rounded-lg, …) that map to--radiuswhen the theme defines them. - For charts or sidebars, use the semantic names your theme exports (
chart-1,sidebar-*, …) if present.
Light and dark
- Dark mode follows the `.dark` class (or the variant from the manual). Use semantic tokens with
dark:variants; reach for separate light/dark hex only when no token covers the case.
Extending the theme
- When adding a new reused color or radius, add a CSS variable in
:root/.dark, wire it through@theme inlineif required by Tailwind v4 setup, then use the generated utility name. - Keep one-off values as arbitrary utilities only until they repeat.
Relation to CVA
- Map CVA variants to semantic utilities (
primary,secondary,destructive,ghost, …) aligned withsrc/uiprimitives so features stay on the same token set.
Examples
import type { ComponentProps } from "react";
import { cx } from "class-variance-authority";
export function Panel({ className, ...props }: ComponentProps<"section">) {
return (
<section
className={cx(
"rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm",
className,
)}
{...props}
/>
);
}If a utility is missing after editing variables, confirm the @theme inline mapping in src/theme.css matches the shadcn manual pattern and that Tailwind content paths include src/.
Setting Up Theming
Overview
Shared design tokens and light/dark variables as the single source for Tailwind, registry UI, and the rest of the app. Use the same two-file pattern as the React Native skill: project-root `global.css` (Tailwind entry + upstream imports) and `src/theme.css` (token wiring). Pull only the shadcn manual **Configure styles** content into theme.css—split so Tailwind’s @import "tailwindcss" lives in global.css only.
Class merging and primitives: use `cva` and `cx` from `class-variance-authority`, with shared components under `src/ui/` (see managing-project-structure.md). Add registry output with `add-registry-component.cjs`; it wraps npx shadcn@latest view and aligns paths and `cn` → `cx` for this layout.
Prerequisites
Steps
1. Install Tailwind with Vite
Follow Installing Tailwind CSS as a Vite plugin: install tailwindcss and @tailwindcss/vite, register the plugin in vite.config.ts.
2. Install registry-related dependencies
From Manual installation — Add dependencies, install the packages the doc lists for styling and components (for example class-variance-authority, lucide-react, tw-animate-css, and any shadcn package your tooling expects). Merge className strings with `import { cx } from "class-variance-authority"` (CVA re-exports clsx as `cx`); omit `tailwind-merge` and the manual’s standalone `cn` utility file for this stack.
3. Create global.css (project root)
Keep `global.css` at the project root as the only CSS entry imported from main.tsx (mirrors keeping global.css at the project root in the React Native skill). It loads Tailwind and shadcn’s Tailwind layer, then pulls in tokens:
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "./src/theme.css";Import it once from the app entry, for example:
import "../global.css";(Adjust the relative path if your entry file lives somewhere other than src/main.tsx.)
4. Create src/theme.css
Keep design tokens and theme wiring in `src/theme.css` only, like setting-up-theming.md does for the Native app.
Open Manual installation — Configure styles and copy the Configure styles block into `src/theme.css`. Leave out the lines that `global.css` already imports (duplicate imports break the split):
@import "tailwindcss";@import "tw-animate-css";@import "shadcn/tailwind.css";
Keep everything else from that section in order—for example:
@custom-variant dark (&:is(.dark *));@theme inline { ... }:root { ... }.dark { ... }@layer base { ... }
`global.css` stays the Tailwind + shadcn import entry; `src/theme.css` holds semantic variables and @theme mapping (two files replace pasting the manual’s styles into a single globals.css).
Optional (cross-platform parity): If you want CSS variables to match the React Native skill’s HSL-style tokens exactly, you can instead define variables in @layer base in src/theme.css the same way as the Native setting-up-theming.md snippet, then map them to Tailwind utilities via @theme inline as needed. Prefer one approach per app and stay consistent.
Dark mode
Use the class-based pattern from the manual (e.g. .dark on <html>). Toggle it from the root layout or a small provider when you add a theme switcher.
Next
- For how semantic token names map to usage in components, see setting-up-tailwind-theme.md.
- For day-to-day utility rules, see styling.md.
- For vendoring registry files into
src/ui/withcxand path fixes, see creating-ui-component.md.
Styling preference
Overview
Prefer the project’s current style guide and design tokens over ad hoc colors, spacing, and typography. Tokens keep routes, features, and components consistent and easier to change.
For utilities and cx / CVA, see styling.md. For wiring tokens in CSS, see setting-up-theming.md and setting-up-tailwind-theme.md.
Guidelines
Style guide first
- When the repo or team documents layout, components, or tokens, follow that guide before inventing new patterns.
- Reuse primitives in
src/ui/and established utility patterns before adding one-off styling.
Design tokens over raw values
- Prefer theme-backed Tailwind classes (for example
bg-primary,text-muted-foreground, spacing scale keys) over arbitrary hex,rgb(), or repeated magic numbers. - Add or extend variables in `src/theme.css` (
@theme/:root/.dark) when a value repeats; reserve arbitrary utilities and inline styles for genuine one-offs. - Prefer semantic token names (intent) over literal names (exact shade) when the project defines them.
When exceptions are reasonable
- Third-party components or a single experimental view may need a targeted exception; keep it local and consider promoting repeated values into tokens later.
Styling
Overview
Use this guide to apply Tailwind utility classes in React via className, with Tailwind CSS v4 and the Vite plugin. Keep styling token-driven; use `cva` and `cx` from `class-variance-authority` for variants and merging className values (no custom cn merge file or `tailwind-merge`).
Prerequisites
- Tailwind CSS — Using Vite
- setting-up-theming.md for
global.css,src/theme.css, and tokens
Guidelines
Structure
- Configure Tailwind as the Vite plugin (
@tailwindcss/vite) per the official guide. - Keep project-root `global.css` as the only entry: it imports Tailwind (v4), shadcn’s Tailwind imports, then `./src/theme.css`. Import
global.cssonce from the app entry (e.g.main.tsx). - Prefer semantic utilities backed by CSS variables from the theme setup (
bg-background,text-foreground, etc.) when the project defines them.
Styling rules
- Use
classNamefor layout and visuals; reserve inlinestylefor dynamic values that utilities cannot express. - Use `cva` for variant-heavy components.
- Use `cx` from `class-variance-authority` to merge base classes, variant output, and a consumer
classNameprop. Registry snippets often say `cn`; this stack standardizes on `cx`—see creating-ui-component.md. - Prefer shared tokens and semantic classes before arbitrary values.
- Use responsive utilities (
sm:,md:,lg:,xl:) as needed. - Use
gapon flex/grid parents instead of margin chains on children.
Avoid hardcoded values
Do not use arbitrary bracket utilities (p-[15px], text-[#333], rounded-[7px]) when a configured scale or token exists. Prefer the closest match on the design system; add a token only when no reasonable match exists.
Spacing and sizing (4px tolerance)
Tailwind’s default scale is 4px-based (1 = 4px). When a spec is off by a few pixels, round to the nearest step instead of an arbitrary value.
| Spec (example) | Prefer | Avoid |
|---|---|---|
| 15px padding | p-4 (16px) | p-[15px] |
| 22px gap | gap-5 (20px) or gap-6 (24px) | gap-[22px] |
| 13px font size | text-sm (14px) | text-[13px] |
- Use scale utilities:
p-4,gap-2,w-64,text-sm,rounded-lg. - Use arbitrary values only when the value is truly one-off and cannot be expressed on the scale (document why in a short comment if non-obvious).
Colors
- Never hardcode colors in utilities (
bg-[#1a1a1a],text-[rgb(...)]) or inlinestylewhen a Tailwind class can apply. - Prefer semantic utilities from the theme (
bg-background,text-foreground,border-border,bg-primary) when defined in setting-up-theming.md. - Otherwise use named palette utilities from the extended theme (
bg-muted,text-destructive), not raw hex or RGB in class names. - With a linked design (Figma, etc.): pick the closest existing token or palette step; if nothing is within ~one step visually, add the color to the theme first (setting-up-theming.md, setting-up-tailwind-theme.md), then use the new utility—do not ship one-off bracket colors.
Overriding className
When a consumer passes utilities that overlap classes on a shared component, mark every conflicting token with ! at the end (for example text-lg!, sm:p-0!). See overriding-classname.md.
Examples
Start with utility classes
<button
type="button"
className="rounded-lg bg-primary px-4 py-2 font-semibold text-primary-foreground"
>
Click me
</button>Use variants with cva
import { cva } from "class-variance-authority";
const pill = cva("rounded-full px-3 py-1", {
variants: {
tone: {
neutral: "bg-muted text-muted-foreground",
success: "bg-green-100 text-green-800 dark:bg-green-950 dark:text-green-200",
},
},
defaultVariants: { tone: "neutral" },
});Merge classes with cx
import type { ComponentProps } from "react";
import { cx } from "class-variance-authority";
export function Card({
className,
...props
}: ComponentProps<"div">) {
return (
<div
className={cx("rounded-xl border border-border bg-card p-4 text-card-foreground", className)}
{...props}
/>
);
}Keep variant helpers in styling code
- Colocate
cvadefinitions with the component or a sibling*.styles.tswhen it helps readability. - Keep component docs focused on structure and usage.
Reusable values
- Arbitrary bracket utilities are a last resort after scale, palette, and semantic tokens.
- When a value repeats—or a design token does not map to the scale—add or extend tokens in the global theme CSS (
@theme/ variables) as described in setting-up-theming.md and setting-up-tailwind-theme.md, then reference the new utility.