skills/franciscosanchezn/easyfactu-es/react-typescript-patterns

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 @apply sparingly
  • 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.lazy and dynamic imports
  • Use virtualization for long lists (TanStack Virtual)
  • Monitor Core Web Vitals (LCP, FID, CLS)
  • Apply React.memo, useMemo, and useCallback judiciously (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, prefer unknown with 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
First Seen
14 days ago
Installed on
mcpjam1
claude-code1
junie1
windsurf1
zencoder1
crush1