react-typescript-patterns
SKILL.md
React TypeScript Patterns
Build type-safe, maintainable React applications with TypeScript strict mode, Zod validation, and modern state management patterns.
When to Use This Skill
- Creating React components with TypeScript props
- Building custom hooks for data fetching, forms, or auth
- Implementing state management (server state, client state)
- Designing Zod validation schemas with type inference
- Structuring React + TypeScript projects
- Building reusable component libraries
Project Structure
src/
├── main.tsx # Entry point
├── App.tsx # Root component with providers
├── components/ # Reusable UI components
│ ├── ui/ # Design system primitives
│ │ ├── Button.tsx
│ │ ├── Input.tsx
│ │ ├── Card.tsx
│ │ └── index.ts
│ └── features/ # Feature-specific components
│ ├── InvoiceCard.tsx
│ └── CustomerForm.tsx
├── hooks/ # Custom hooks
│ ├── useAuth.ts
│ ├── useInvoices.ts
│ └── useTenant.ts
├── lib/ # Utilities and API client
│ ├── api.ts # API client (fetch wrapper)
│ ├── supabase.ts # Supabase client
│ ├── utils.ts # General utilities
│ └── cn.ts # className utility
├── schemas/ # Zod validation schemas
│ ├── invoice.ts
│ ├── customer.ts
│ └── auth.ts
├── stores/ # Zustand stores
│ ├── authStore.ts
│ └── uiStore.ts
├── types/ # Shared TypeScript types
│ ├── api.ts # API response types
│ └── domain.ts # Domain model types
├── pages/ # Page components / routes
│ ├── Dashboard.tsx
│ ├── InvoiceList.tsx
│ └── InvoiceDetail.tsx
└── styles/
└── globals.css # Tailwind base styles
TypeScript Configuration
Strict Mode (Required)
{
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"exactOptionalPropertyTypes": true,
"noPropertyAccessFromIndexSignature": true
}
}
Component Patterns
Functional Component with Props
interface InvoiceCardProps {
invoice: Invoice;
onSelect?: (id: string) => void;
variant?: "compact" | "detailed";
className?: string;
}
export function InvoiceCard({
invoice,
onSelect,
variant = "compact",
className,
}: InvoiceCardProps) {
return (
<div
className={cn(
"rounded-lg border p-4 shadow-sm transition-colors",
variant === "detailed" && "p-6",
onSelect && "cursor-pointer hover:border-primary",
className,
)}
onClick={() => onSelect?.(invoice.id)}
onKeyDown={(e) => e.key === "Enter" && onSelect?.(invoice.id)}
role={onSelect ? "button" : undefined}
tabIndex={onSelect ? 0 : undefined}
>
<h3 className="text-lg font-semibold">{invoice.number}</h3>
<p className="text-sm text-muted-foreground">
{formatCurrency(invoice.amount, invoice.currency)}
</p>
{variant === "detailed" && (
<p className="mt-2 text-sm">{invoice.customerName}</p>
)}
</div>
);
}
Generic List Component
interface ListProps<T> {
items: T[];
renderItem: (item: T, index: number) => React.ReactNode;
keyExtractor: (item: T) => string;
emptyMessage?: string;
className?: string;
}
export function List<T>({
items,
renderItem,
keyExtractor,
emptyMessage = "No items found",
className,
}: ListProps<T>) {
if (items.length === 0) {
return (
<p className="py-8 text-center text-muted-foreground">{emptyMessage}</p>
);
}
return (
<ul className={cn("space-y-2", className)}>
{items.map((item, index) => (
<li key={keyExtractor(item)}>{renderItem(item, index)}</li>
))}
</ul>
);
}
Discriminated Union for Component State
type AsyncState<T> =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: T }
| { status: "error"; error: Error };
function InvoiceDetail({ id }: { id: string }) {
const state = useInvoice(id);
switch (state.status) {
case "idle":
return null;
case "loading":
return <Skeleton className="h-48 w-full" />;
case "error":
return <ErrorAlert error={state.error} />;
case "success":
return <InvoiceCard invoice={state.data} variant="detailed" />;
}
}
Compound Component Pattern
interface TabsContextType {
activeTab: string;
setActiveTab: (tab: string) => void;
}
const TabsContext = React.createContext<TabsContextType | null>(null);
function useTabs() {
const ctx = React.useContext(TabsContext);
if (!ctx) throw new Error("useTabs must be used within <Tabs>");
return ctx;
}
function Tabs({
defaultTab,
children,
}: {
defaultTab: string;
children: React.ReactNode;
}) {
const [activeTab, setActiveTab] = React.useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
{children}
</TabsContext.Provider>
);
}
function TabList({ children }: { children: React.ReactNode }) {
return <div role="tablist" className="flex gap-2 border-b">{children}</div>;
}
function Tab({ value, children }: { value: string; children: React.ReactNode }) {
const { activeTab, setActiveTab } = useTabs();
return (
<button
role="tab"
aria-selected={activeTab === value}
className={cn("px-4 py-2", activeTab === value && "border-b-2 border-primary")}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
function TabPanel({ value, children }: { value: string; children: React.ReactNode }) {
const { activeTab } = useTabs();
if (activeTab !== value) return null;
return <div role="tabpanel">{children}</div>;
}
// Attach sub-components
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage:
// <Tabs defaultTab="invoices">
// <Tabs.List>
// <Tabs.Tab value="invoices">Invoices</Tabs.Tab>
// <Tabs.Tab value="customers">Customers</Tabs.Tab>
// </Tabs.List>
// <Tabs.Panel value="invoices"><InvoiceList /></Tabs.Panel>
// <Tabs.Panel value="customers"><CustomerList /></Tabs.Panel>
// </Tabs>
Error Boundary
import { Component, type ErrorInfo, type ReactNode } from "react";
interface ErrorBoundaryProps {
fallback: ReactNode | ((error: Error) => ReactNode);
children: ReactNode;
onError?: (error: Error, errorInfo: ErrorInfo) => void;
}
interface ErrorBoundaryState {
error: Error | null;
}
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null };
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.error) {
const { fallback } = this.props;
return typeof fallback === "function"
? fallback(this.state.error)
: fallback;
}
return this.props.children;
}
}
Zod Schema Patterns
Schema with Type Inference
import { z } from "zod";
// Define the schema
export const InvoiceSchema = z.object({
id: z.string().uuid(),
invoiceNumber: z.string().min(1).max(50),
customerId: z.string().uuid(),
amount: z.number().positive(),
currency: z.enum(["EUR", "USD"]).default("EUR"),
status: z.enum(["draft", "sent", "paid", "overdue", "cancelled"]),
lineItems: z.array(
z.object({
description: z.string().min(1),
quantity: z.number().int().positive(),
unitPrice: z.number().positive(),
vatRate: z.number().min(0).max(100),
}),
).min(1),
issuedAt: z.string().datetime(),
createdAt: z.string().datetime(),
});
// Infer TypeScript type from schema
export type Invoice = z.infer<typeof InvoiceSchema>;
// Create/Update schemas derived from base
export const InvoiceCreateSchema = InvoiceSchema.omit({
id: true,
createdAt: true,
issuedAt: true,
});
export type InvoiceCreate = z.infer<typeof InvoiceCreateSchema>;
export const InvoiceUpdateSchema = InvoiceCreateSchema.partial();
export type InvoiceUpdate = z.infer<typeof InvoiceUpdateSchema>;
API Response Validation
// Validate API responses at runtime
export function parseApiResponse<T>(schema: z.ZodType<T>, data: unknown): T {
const result = schema.safeParse(data);
if (!result.success) {
console.error("API response validation failed:", result.error.issues);
throw new Error(`Invalid API response: ${result.error.message}`);
}
return result.data;
}
// Paginated response schema factory
export function paginatedSchema<T extends z.ZodTypeAny>(itemSchema: T) {
return z.object({
data: z.array(itemSchema),
pagination: z.object({
page: z.number(),
pageSize: z.number(),
totalItems: z.number(),
totalPages: z.number(),
hasNext: z.boolean(),
hasPrev: z.boolean(),
}),
});
}
export const InvoiceListSchema = paginatedSchema(InvoiceSchema);
export type InvoiceList = z.infer<typeof InvoiceListSchema>;
Custom Hooks
Data Fetching Hook (TanStack Query)
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { api } from "@/lib/api";
import { InvoiceListSchema, InvoiceSchema } from "@/schemas/invoice";
import type { Invoice, InvoiceCreate, InvoiceList } from "@/schemas/invoice";
// Query keys factory
export const invoiceKeys = {
all: ["invoices"] as const,
lists: () => [...invoiceKeys.all, "list"] as const,
list: (filters: Record<string, unknown>) =>
[...invoiceKeys.lists(), filters] as const,
details: () => [...invoiceKeys.all, "detail"] as const,
detail: (id: string) => [...invoiceKeys.details(), id] as const,
};
export function useInvoices(filters: { page?: number; status?: string } = {}) {
return useQuery({
queryKey: invoiceKeys.list(filters),
queryFn: async (): Promise<InvoiceList> => {
const data = await api.get("/v1/invoices", { params: filters });
return InvoiceListSchema.parse(data);
},
});
}
export function useInvoice(id: string) {
return useQuery({
queryKey: invoiceKeys.detail(id),
queryFn: async (): Promise<Invoice> => {
const data = await api.get(`/v1/invoices/${id}`);
return InvoiceSchema.parse(data);
},
enabled: !!id,
});
}
export function useCreateInvoice() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: InvoiceCreate) => api.post("/v1/invoices", data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: invoiceKeys.lists() });
},
});
}
Form Hook (React Hook Form + Zod)
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { InvoiceCreateSchema, type InvoiceCreate } from "@/schemas/invoice";
export function useInvoiceForm(defaultValues?: Partial<InvoiceCreate>) {
return useForm<InvoiceCreate>({
resolver: zodResolver(InvoiceCreateSchema),
defaultValues: {
currency: "EUR",
lineItems: [{ description: "", quantity: 1, unitPrice: 0, vatRate: 21 }],
...defaultValues,
},
});
}
// Usage in component:
function CreateInvoicePage() {
const form = useInvoiceForm();
const createInvoice = useCreateInvoice();
const onSubmit = form.handleSubmit((data) => {
createInvoice.mutate(data);
});
return (
<form onSubmit={onSubmit}>
<Input {...form.register("invoiceNumber")} error={form.formState.errors.invoiceNumber?.message} />
{/* ... */}
</form>
);
}
Auth Hook
import { createContext, useContext, useEffect, useState, type ReactNode } from "react";
import { supabase } from "@/lib/supabase";
import type { User, Session } from "@supabase/supabase-js";
interface AuthContextType {
user: User | null;
session: Session | null;
isLoading: boolean;
signIn: (email: string, password: string) => Promise<void>;
signOut: () => Promise<void>;
}
const AuthContext = createContext<AuthContextType | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [session, setSession] = useState<Session | null>(null);
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
// Get initial session
supabase.auth.getSession().then(({ data: { session } }) => {
setSession(session);
setUser(session?.user ?? null);
setIsLoading(false);
});
// Listen for auth changes
const { data: { subscription } } = supabase.auth.onAuthStateChange(
(_event, session) => {
setSession(session);
setUser(session?.user ?? null);
},
);
return () => subscription.unsubscribe();
}, []);
const signIn = async (email: string, password: string) => {
const { error } = await supabase.auth.signInWithPassword({ email, password });
if (error) throw error;
};
const signOut = async () => {
await supabase.auth.signOut();
};
return (
<AuthContext.Provider value={{ user, session, isLoading, signIn, signOut }}>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const ctx = useContext(AuthContext);
if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
return ctx;
}
State Management
Zustand Store (Client State)
import { create } from "zustand";
import { persist } from "zustand/middleware";
interface UIStore {
sidebarOpen: boolean;
theme: "light" | "dark" | "system";
toggleSidebar: () => void;
setTheme: (theme: "light" | "dark" | "system") => void;
}
export const useUIStore = create<UIStore>()(
persist(
(set) => ({
sidebarOpen: true,
theme: "system",
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
setTheme: (theme) => set({ theme }),
}),
{ name: "ui-store" },
),
);
TanStack Query Provider Setup
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
retry: 1,
refetchOnWindowFocus: false,
},
},
});
export function App() {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<RouterProvider router={router} />
</AuthProvider>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Utility Patterns
className Utility (cn)
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
API Client
const BASE_URL = import.meta.env.VITE_API_URL ?? "http://localhost:8000";
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
private async request<T>(path: string, options: RequestInit = {}): Promise<T> {
const { data: { session } } = await supabase.auth.getSession();
const response = await fetch(`${this.baseUrl}${path}`, {
...options,
headers: {
"Content-Type": "application/json",
...(session && { Authorization: `Bearer ${session.access_token}` }),
...options.headers,
},
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new ApiError(response.status, error.detail ?? "Request failed");
}
return response.json() as Promise<T>;
}
get<T>(path: string, opts?: { params?: Record<string, unknown> }): Promise<T> {
const url = opts?.params
? `${path}?${new URLSearchParams(
Object.entries(opts.params)
.filter(([, v]) => v != null)
.map(([k, v]) => [k, String(v)]),
)}`
: path;
return this.request<T>(url);
}
post<T>(path: string, body: unknown): Promise<T> {
return this.request<T>(path, {
method: "POST",
body: JSON.stringify(body),
});
}
patch<T>(path: string, body: unknown): Promise<T> {
return this.request<T>(path, {
method: "PATCH",
body: JSON.stringify(body),
});
}
delete(path: string): Promise<void> {
return this.request(path, { method: "DELETE" });
}
}
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = "ApiError";
}
}
export const api = new ApiClient(BASE_URL);
TypeScript Utility Type Patterns
Pick / Omit for API Payloads
// Full domain type
interface Invoice {
id: string;
invoiceNumber: string;
customerId: string;
amount: number;
currency: string;
status: InvoiceStatus;
createdAt: string;
updatedAt: string;
}
// Derived types for different operations
type InvoiceCreate = Omit<Invoice, "id" | "createdAt" | "updatedAt">;
type InvoiceSummary = Pick<Invoice, "id" | "invoiceNumber" | "amount" | "status">;
type InvoiceUpdate = Partial<Omit<Invoice, "id" | "createdAt">>;
Type Guards
function isInvoice(value: unknown): value is Invoice {
return (
typeof value === "object" &&
value !== null &&
"id" in value &&
"invoiceNumber" in value
);
}
// Discriminated union guard
type ApiResponse =
| { type: "success"; data: Invoice }
| { type: "error"; message: string };
function isSuccess(response: ApiResponse): response is Extract<ApiResponse, { type: "success" }> {
return response.type === "success";
}
Tailwind CSS Patterns
Styling Principles
- Follow utility-first approach consistently
- Extract reusable styles into component classes via
@applysparingly - Use design tokens through
tailwind.config.ts - Implement responsive design with mobile-first breakpoints (
sm:,md:,lg:) - Keep dark mode support from the start (
dark:variant) - Use
cn()utility (clsx + tailwind-merge) for conditional classes
Responsive Component Example
<div className={cn(
"flex flex-col gap-4 p-4",
"sm:flex-row sm:gap-6 sm:p-6",
"lg:gap-8 lg:p-8",
"dark:bg-gray-900 dark:text-gray-100",
className,
)}>
{children}
</div>
Performance Patterns
- Implement code splitting with
React.lazyand dynamic imports - Use virtualization for long lists (TanStack Virtual)
- Monitor Core Web Vitals (LCP, FID, CLS)
- Apply
React.memo,useMemo, anduseCallbackjudiciously (only when profiling shows need) - Optimize images with proper lazy loading
pnpm Workspace Integration
Package.json Structure
{
"name": "@fsn/easyfactu-web",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fsn/ui": "workspace:*"
}
}
Shared Package Import
import { Button, Card, Input } from "@fsn/ui";
import type { ApiResponse } from "@fsn/types";
Quality Commands
# Development
pnpm dev # Start dev server
pnpm build # Production build
# Quality checks
pnpm lint # ESLint
pnpm typecheck # TypeScript type checking
pnpm test # Run tests (Vitest)
# From monorepo root
pnpm -r lint # Lint all TS packages
pnpm -r typecheck # Typecheck all TS packages
pnpm --filter @fsn/ui build # Build specific package
Guidelines
- Use strict TypeScript — never use
any, preferunknownwith narrowing - Define Zod schemas as the source of truth, infer types with
z.infer - Keep components small and focused (single responsibility)
- Use TanStack Query for all server state, Zustand for client state
- Apply React Hook Form + Zod for all form handling
- Use
cn()utility for conditional Tailwind classes - Create query key factories for consistent cache management
- Implement Error Boundaries around feature sections
- Use Suspense boundaries for loading states
- Follow accessibility best practices (ARIA, semantic HTML, keyboard nav)
- Use mobile-first responsive design with Tailwind breakpoints
- Use pnpm workspace imports for shared packages (
@fsn/*)
Weekly Installs
1
Repository
franciscosanche…factu-esFirst Seen
14 days ago
Security Audits
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1