Getting Started
Components
Search for a command to run...
Design consistency standards for React applications. These standards apply to all applications and should be adopted incrementally.
See Also: Code Examples - Comprehensive code examples for all patterns described in this document.
ESLint Setup: For linting configuration, see the @strongtie/eslint Setup Guide.
| Layer | Technology | Version | Notes |
|---|---|---|---|
| Build | Vite | 7+ | Fast dev/build |
| Framework | React | 19+ | With React Compiler |
| Routing | React Router | 7+ | Standard routing |
| UI | shadcn/ui + Tailwind CSS | 4+ | Design system |
| Data Fetching | TanStack Query | 5+ | Server state |
| State (Simple) | React Context | - | Session/UI state |
| State (Complex) | Zustand | 4+ | Cross-component/computed |
| Forms (Simple) | Native useState | - | 1-3 fields, no validation |
| Forms (Complex) | React Hook Form + Zod | 7+ / 3+ | 4+ fields or validation |
| Testing | Vitest | - | Jest-compatible API |
| Linting | @strongtie/eslint | 1.0+ | Ultracite + custom rules |
| Formatting | Prettier | - | Via Ultracite |
| CSS Linting | Stylelint | - | Via Ultracite |
React Compiler eliminates the need for manual memoization:
useMemo(), useCallback(), React.memo()React.lazy() for code splitting
/src/
main.tsx # App entry, providers
router.tsx # Route configuration
auth-config.ts # Auth configuration
globals.css # Global Tailwind styles
/pages/ # Route handlers (thin wrappers)
/layouts/ # Layout components
/components/ # Feature & shared components
/ui/ # shadcn/ui primitives (DO NOT EDIT)
/hooks/ # Custom React hooks
query-keys.ts # Centralized query key factory
/lib/ # Utilities, clients
/api/ # API service modules
error-messages.ts # Error code → user message mapping
/schemas/ # Zod validation schemas
/stores/ # Zustand stores
/data/ # Types, constants
| Type | Convention | Example |
|---|---|---|
| Files | kebab-case.tsx | project-card.tsx |
| Components | PascalCase (named export) | export function ProjectCard() |
| Hooks | use- prefix | use-projects.ts → useProjects() |
| Types | PascalCase | type ProjectStatus |
| Schemas | -schema.ts suffix | project-schema.ts |
| Stores | -store.ts suffix | project-store.ts |
| API Modules | -api.ts suffix | projects-api.ts |
| Constants | SCREAMING_SNAKE_CASE | AUTH_DISABLED |
@/ path aliases../../)import type {} for type-only imports| Scenario | Solution |
|---|---|
| Server data (API responses) | TanStack Query |
| Component-local UI (modal, input) | useState |
| Session-level shared (auth, tenant, theme) | React Context |
| Complex computed state | Zustand |
| Cross-component state (unrelated) | Zustand |
| Persisted state | Zustand with persist |
Create a Zustand store when:
Avoid these patterns: - Storing API response in useState - Duplicating
TanStack Query data in local state - Using useMemo/useCallback (React
Compiler handles this) - Creating Context for single-component state
All server state must use TanStack Query.
Centralize all query keys in hooks/query-keys.ts:
export const queryKeys = {
projects: {
all: ["projects"] as const,
lists: () => [...queryKeys.projects.all, "list"] as const,
list: (filters: ProjectFilters) =>
[...queryKeys.projects.lists(), filters] as const,
details: () => [...queryKeys.projects.all, "detail"] as const,
detail: (id: string) => [...queryKeys.projects.details(), id] as const,
},
}Never use useQuery directly in components. Create custom hooks:
export function useProjects() {
const { selectedTenant } = useTenantContext()
const isAuthReady = useAuthReady()
return useQuery({
queryKey: queryKeys.projects.list({ tenantId: selectedTenant?.id }),
queryFn: projectsApi.getAll,
enabled: isAuthReady,
})
}Implement for all user-initiated mutations:
export function useCreateProject() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: projectsApi.create,
onMutate: async (newProject) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: queryKeys.projects.lists() })
// Snapshot previous value
const previous = queryClient.getQueryData(queryKeys.projects.list({}))
// Optimistically add to cache
queryClient.setQueryData(queryKeys.projects.list({}), (old) => [
...(old ?? []),
{ ...newProject, id: `temp-${Date.now()}` },
])
return { previous }
},
onError: (_err, _vars, context) => {
// Rollback on error
queryClient.setQueryData(queryKeys.projects.list({}), context?.previous)
},
onSettled: () => {
// Refetch to ensure consistency
queryClient.invalidateQueries({ queryKey: queryKeys.projects.lists() })
},
})
}const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
})| Condition | Solution |
|---|---|
| 1-3 fields, no validation | useState |
| 4+ fields OR validation rules | React Hook Form + Zod |
Define schemas in /schemas/:
// schemas/project-schema.ts
import { z } from "zod"
export const createProjectSchema = z.object({
name: z.string().min(1, "Name is required").max(100, "Name too long"),
type: z.enum(["internal", "external", "research"], {
required_error: "Please select a project type",
}),
description: z.string().max(500).optional(),
})
export type CreateProjectInput = z.infer<typeof createProjectSchema>import {
createProjectSchema,
type CreateProjectInput,
} from "@/schemas/project-schema"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
function CreateProjectForm() {
const form = useForm<CreateProjectInput>({
resolver: zodResolver(createProjectSchema),
defaultValues: {
name: "",
type: undefined,
description: "",
},
})
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Project Name</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
)
}| Layer | Responsibility |
|---|---|
| API Response | Return error code + technical message |
| API Client | Throw ApiError with code and status |
| Error Boundary | Catch unhandled React errors |
| Query Error State | Handle expected API failures |
| UI Component | Map error codes to user messages |
APIs return error codes. UI maps to user-friendly messages:
// lib/error-messages.ts
const ERROR_MESSAGES: Record<string, string> = {
TENANT_REQUIRED: "Please select a tenant first.",
NOT_FOUND: "The requested item could not be found.",
UNAUTHORIZED: "Your session has expired. Please sign in again.",
FORBIDDEN: "You do not have permission to perform this action.",
VALIDATION_ERROR: "Please check your input and try again.",
NETWORK_ERROR: "Unable to connect. Please check your connection.",
DEFAULT: "Something went wrong. Please try again.",
}
export function getUserMessage(error: ApiError): string {
return ERROR_MESSAGES[error.code] ?? ERROR_MESSAGES.DEFAULT
}| Status | Title | Message | Action |
|---|---|---|---|
| 503 | Service Unavailable | We are currently having technical difficulties. Please try again soon. | Refresh |
| 500 | Server Error | Something went wrong on our end. Try again or contact support. | Refresh |
| 400 | Bad Request | We couldn't process your request. Check your input and try again. | Refresh |
| 404 | Not Found | We couldn't find the content you're looking for. | None |
| 401 | Unauthorized | Your session has expired. Please log in again. | Log in |
| 403 | Permission Required | Please contact your administrator | Go Back |
| Connection Error | Connection Error | Unable to connect. Check your network connection and try again. | Refresh |
Error Boundary: For React component crashes (unrecoverable)
Inline Error: For API failures within a component
function ErrorMessage({ title, message, onRetry }: ErrorMessageProps) {
return (
<div className="flex flex-col items-center py-8">
<AlertCircle className="text-destructive h-12 w-12" />
<h3 className="mt-4 font-semibold">{title}</h3>
<p className="text-muted-foreground mt-2 text-sm">{message}</p>
{onRetry && (
<Button variant="outline" onClick={onRetry} className="mt-4">
Try Again
</Button>
)}
</div>
)
}| Type | Use For |
|---|---|
| Success | Confirming completed actions |
| Error | API failures, permission errors |
| Warning | Destructive action confirmations |
Use Skeleton when:
Use Spinner when:
Mirror the actual content structure:
function ProjectCardSkeleton() {
return (
<Card>
<CardHeader>
<Skeleton className="h-6 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</CardHeader>
<CardContent>
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
)
}
function ProjectsLoadingSkeleton() {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<ProjectCardSkeleton key={i} />
))}
</div>
)
}Always provide a call-to-action:
function EmptyState({
icon: Icon,
title,
description,
action,
}: EmptyStateProps) {
return (
<div className="flex flex-col items-center justify-center py-12">
<Icon className="text-muted-foreground h-12 w-12" />
<h3 className="mt-4 text-lg font-semibold">{title}</h3>
<p className="text-muted-foreground mt-2 text-center text-sm">
{description}
</p>
{action && <div className="mt-4">{action}</div>}
</div>
)
}Every async component must handle all states:
function ProjectsList() {
const { data, isLoading, error, refetch } = useProjects()
if (isLoading) {
return <ProjectsLoadingSkeleton />
}
if (error) {
return (
<ErrorMessage
title="Failed to load projects"
message={getUserMessage(error)}
onRetry={refetch}
/>
)
}
if (!data?.length) {
return (
<EmptyState
icon={FolderOpen}
title="No projects yet"
description="Create your first project to get started."
action={<Button>Create Project</Button>}
/>
)
}
return <ProjectGrid projects={data} />
}All applications must meet WCAG 2.1 Level AA.
Icon-only buttons MUST have accessible names:
// Option 1: aria-label (preferred)
<Button variant="ghost" size="icon" aria-label="Delete project">
<Trash2 className="h-4 w-4" />
</Button>
// Option 2: sr-only span
<Button variant="ghost" size="icon">
<Trash2 className="h-4 w-4" />
<span className="sr-only">Delete project</span>
</Button>Label Guidelines:
<label> elementsaria-describedby)aria-invalid="true" for fields with errorsalt=""aria-describedby| Metric | Limit |
|---|---|
| Initial JS (gzipped) | < 200KB |
| Initial CSS (gzipped) | < 50KB |
| Lazy-loaded chunks | < 100KB each |
| Metric | Target |
|---|---|
| First Contentful Paint | < 1.5s |
| Largest Contentful Paint | < 2.5s |
| Time to Interactive | < 3.5s |
| Cumulative Layout Shift | < 0.1 |
Production builds use manual chunk splitting (see vite.config.ts):
| Chunk | Contents | Strategy |
|---|---|---|
vendor-react | React, React DOM, React Router, Scheduler | Always loaded |
vendor-charts | Recharts, D3, Victory | Lazy-load |
vendor-editor | Plate.js, Slate, Emoji Mart | Lazy-load |
vendor-forms | React Hook Form, Zod | Load on form pages |
vendor-query | TanStack Query | Always loaded |
vendor-radix | Radix UI primitives | Always loaded |
vendor-utils | date-fns, clsx, tailwind-merge, CVA, Lucide | Always loaded |
vendor-auth | oidc-client-ts, react-oidc-context | Always loaded |
React.lazy() (charts, editors, maps)const RichTextEditor = React.lazy(() => import("@/components/rich-text-editor"))
function CommentForm() {
return (
<Suspense fallback={<Skeleton className="h-32" />}>
<RichTextEditor />
</Suspense>
)
}Avoid these patterns: - import * as for large modules - Barrel files
(index.ts re-exports) in large directories - Unoptimized images (use WebP,
proper sizing) - Inline SVGs > 4KB (use sprite or component)
srcset)For AI-assisted development, we provide custom skills that encode these design standards:
See the Skills documentation for installation and usage.
readonly modifiercn() for classes@/ import aliasesquery-keys.ts/hooks/enabled flag for conditional fetchingonMutateonErroronSettledYes, on a case-by-case basis. While Tailwind is designed to handle the vast majority of styling needs, the official Tailwind documentation explicitly states that custom CSS is acceptable when needed.
When custom CSS may be appropriate:
@media print) with specific requirementsRequirements:
var(--spacing), var(--radius), var(--primary), etc.) to maintain theme compatibility and future theme supportExample:
/* Print-specific layout - Tailwind's print utilities don't cover page breaks */
@media print {
.page-break {
break-before: page;
margin-top: var(--spacing-8);
}
}Important: This is not blanket permission to write custom CSS freely. Each case should be evaluated individually to ensure Tailwind utilities or plugins cannot accomplish the same result. The goal is to maintain token adherence and avoid incurring tech debt that complicates future theme support.