# React Principles — Cookbook (Full)

> Production-grade React patterns and principles. A curated curriculum for modern React development covering folder structure, TypeScript, components, state, forms, services, and more.

This is the **full** version of the cookbook — every published recipe with principle, rules, pattern code, and framework-specific implementations (Next.js and Vite). For a lighter version without code, see https://reactprinciples.dev/llms.txt.

Stack: Next.js 16, React 19, TypeScript 5, Tailwind CSS v4, TanStack Query v5, Zustand v5, React Hook Form + Zod.

Use this file as:
- RAG context for a custom AI assistant
- Long-form context to drop into Claude / Cursor / Copilot for principle-aware help
- Reference for fine-tuning or evaluation

---


## Foundations

### Folder Structure

> A feature-based folder structure so you always know where a file goes — and why it belongs there.

**Principle:** A good folder structure answers one question instantly: 'where does this file go?' Feature-based organization groups everything related to a feature together — its components, hooks, and data — so you spend time building, not searching. When a feature grows or gets deleted, everything moves together. This works best for apps with multiple distinct features and more than one developer — think e-commerce with products, cart, checkout, and auth all living side by side. For a small app with 2–3 pages, this structure is like organizing a studio apartment with a full filing cabinet system. Useful later, overkill now.

**Tip:** One rule to decide where a file goes: if only one feature uses it, put it in that feature. If two or more features need it, move it to shared/. If it's infrastructure (API client, query config), put it in lib/.

**Conventions:**
- **Feature-based grouping** — Everything related to a feature lives in src/features/[name]/ — its components, hooks, and stores together. The stores/ directory is only needed when the feature has shared UI state that multiple components within that feature need — like a multi-step form or a selected item. If all data comes from an API, skip the store.
- **Co-location** — Files live next to the code they describe — a component's types go in the same file, a feature's types go in that feature folder. A shared/ types folder is fine only for types used by two or more features. The decision is based on scope, not file type.
- **No cross-feature imports** — By convention, features avoid importing directly from each other. Code needed by multiple features moves to src/shared/. Cross-feature imports are acceptable when composing product surfaces — for example, a layout feature pulling in a ThemeToggle from another feature — but should not be the default.
- **Public API via index.ts** — By convention, each feature exposes its public API through an index.ts barrel file. Other parts of the codebase import from the feature, not from its internals. This keeps refactoring contained — if a file moves inside the feature, nothing outside breaks. If you want to enforce this automatically, ESLint's no-restricted-imports rule can prevent direct internal imports.

**Implementation — Next.js**

The four core directories apply to any React app: features/ for domain logic, shared/ for cross-feature code, lib/ for infrastructure, and ui/ for design system primitives. Next.js adds one more: app/ for file-based routing — keep it thin, no business logic here. See the starter template at github.com/sindev08/react-principles-nextjs.

File: `src/ — react-principles-nextjs starter`

```
src/
├── app/                  # Next.js App Router — routing and layouts ONLY
│   ├── layout.tsx        # Root layout (fonts, providers, metadata)
│   ├── page.tsx          # Home page
│   ├── providers.tsx     # Client-side context providers (QueryClient, etc.)
│   ├── globals.css       # Global styles and Tailwind imports
│   └── users/
│       ├── page.tsx      # Users list page (composition: PageLayout + UserList)
│       └── [id]/
│           └── page.tsx  # Dynamic route — add routes here, never business logic
│
├── features/             # Feature modules (vertical slices)
│   └── users/            # Each feature owns its own components, hooks, stores
│       ├── components/   # UI specific to this feature
│       ├── hooks/        # Data fetching and logic hooks
│       ├── stores/       # Zustand stores scoped to this feature
│       └── index.ts      # Barrel export — public API of the feature
│
├── shared/               # Cross-feature shared code
│   ├── components/       # Reusable components (PageLayout, Navbar, Sidebar, etc.)
│   ├── hooks/            # Reusable hooks (useDebounce, useLocalStorage, etc.)
│   ├── stores/           # App-wide stores (theme, sidebar, etc.)
│   ├── types/            # Shared TypeScript types
│   └── utils/            # Utility functions (cn, formatters, validators)
│
├── ui/                   # Design system primitives (Button, Card, Dialog, etc.)
│
├── lib/                  # Infrastructure code
│   ├── api-client.ts     # Fetch-based API client factory
│   ├── api.ts            # Pre-configured API instance (DummyJSON)
│   ├── endpoints.ts      # Centralized endpoint definitions
│   ├── query-client.ts   # TanStack Query client factory
│   ├── query-keys.ts     # Type-safe query key factory
│   └── services/         # Per-resource API functions (users, products, etc.)
│
└── test/
    └── setup.ts          # Vitest setup (Testing Library matchers)
```

**Implementation — Vite**

Same four core directories. Vite uses React Router instead of file-based routing, so add a routes/ directory for route definitions. Everything else is identical.

File: `src/ — Vite structure`

```
src/
├── routes/               # React Router — routing only
│   ├── index.tsx         # Route definitions
│   └── layouts/
│       └── RootLayout.tsx
│
├── features/             # Feature modules (vertical slices)
│   └── users/
│       ├── components/   # UI specific to this feature
│       ├── hooks/        # Data fetching and logic hooks
│       ├── stores/       # Zustand stores scoped to this feature
│       └── index.ts      # Barrel export — public API of the feature
│
├── shared/               # Cross-feature shared code
│   ├── components/       # Reusable components (PageLayout, Navbar, Sidebar, etc.)
│   ├── hooks/            # Reusable hooks (useDebounce, useLocalStorage, etc.)
│   ├── stores/           # App-wide stores (theme, sidebar, etc.)
│   ├── types/            # Shared TypeScript types
│   └── utils/            # Utility functions (cn, formatters, validators)
│
├── ui/                   # Design system primitives (Button, Card, Dialog, etc.)
│
├── lib/                  # Infrastructure code
│   ├── api-client.ts     # Fetch-based API client factory
│   ├── api.ts            # Pre-configured API instance
│   ├── endpoints.ts      # Centralized endpoint definitions
│   ├── query-client.ts   # TanStack Query client factory
│   └── query-keys.ts     # Type-safe query key factory
│
└── test/
    └── setup.ts          # Vitest setup (Testing Library matchers)
```

Read more: https://reactprinciples.dev/cookbook/folder-structure

### TypeScript for React

> How to type component props, event handlers, and hooks correctly. The contracts that prevent silent bugs.

**Principle:** Bugs caught at compile time cost nothing to fix. Bugs caught in production cost everything. TypeScript for React is not about learning the full TypeScript language — it is about writing the right contracts between your components so that mistakes are caught before the code even runs.

**Tip:** Start by typing your component props. If you can describe what a component accepts and returns, the rest of the types follow naturally.

**Rules:**
- **interface for component props** — Use interface to define component props. It is extendable and reads clearly as a contract.
- **type for unions and utilities** — Use type for union types, utility types, and function signatures — things that are not directly 'objects with fields'.
- **Never use any** — any disables type checking completely. Use unknown and narrow it with type guards instead.
- **strict: true in tsconfig** — Strict mode enables the full set of type checks. Without it, TypeScript catches only the most obvious errors.

**Pattern** — `components/UserCard.tsx — typed component`

```
import type { ReactNode } from 'react';

// ✅ interface for component props
interface UserCardProps {
  name: string;
  email: string;
  role: 'admin' | 'editor' | 'viewer';  // union type
  isActive: boolean;
  onEdit: (id: string) => void;          // typed event handler
  children?: ReactNode;
}

// ✅ typed event handler
function handleClick(event: React.MouseEvent<HTMLButtonElement>) {
  event.preventDefault();
}

// ✅ typed useState
const [count, setCount] = useState<number>(0);

// ❌ never do this
const fetchUser = async (): Promise<any> => { ... }

// ✅ use unknown and narrow
const fetchUser = async (): Promise<unknown> => { ... }
```

**Implementation — Next.js**

Next.js page components receive typed params and searchParams. Always type these explicitly. URL params are always strings — convert to the expected type before use.

File: `app/users/[id]/page.tsx`

```tsx
// ✅ Typed Next.js page props
interface PageProps {
  params: Promise<{ id: string }>;
  searchParams: Promise<{ tab?: string }>;
}

export default async function UserPage({ params }: PageProps) {
  const { id } = await params;

  // URL params are always strings — convert to number before passing to the hook
  return <UserDetail id={Number(id)} />;
}

// ✅ Typed Server Action
async function updateUser(
  id: string,
  data: UpdateUserInput
): Promise<{ success: boolean }> {
  'use server';
  // ...
}
```

**Implementation — Vite**

In Vite + React Router, type your route params using useParams with a generic.

File: `features/users/components/UserDetail.tsx`

```tsx
import { useParams } from 'react-router-dom';

// ✅ Typed route params
function UserDetail() {
  const { id } = useParams<{ id: string }>();

  // id is string | undefined — handle both cases
  if (!id) return null;

  return <div>{id}</div>;
}

// ✅ Typed custom hook return
function useUser(id: string): {
  user: User | null;
  isLoading: boolean;
  error: Error | null;
} {
  // ...
}
```

Read more: https://reactprinciples.dev/cookbook/typescript-for-react

### Component Anatomy

> The consistent internal structure every component follows — imports, types, constants, function, export.

**Principle:** When every component follows the same structure, you stop thinking about where things go inside a file and start thinking about what the component actually does. Consistent anatomy means anyone on the team can open any file and immediately know where to look — props are always at the top, constants are always before the function, exports are always at the bottom.

**Tip:** The hardest part of component anatomy is constants vs. props. Rule of thumb: if it never changes based on what's passed in, it is a constant. If it could change from outside, it is a prop.

**Rules:**
- **Imports first** — Order: React → external libraries → internal aliases (@/) → relative imports (./). This makes dependencies visible at a glance.
- **Types and interfaces second** — Define all types used in this file immediately after imports. Props interface always comes first.
- **Constants third** — Component-scoped constants (static data, config, labels) come before the function. Never define constants inside the function body.
- **Component function fourth** — The function itself comes after everything it depends on. Keep it focused — if it grows past 200 lines, split it.
- **Named export last** — Always use named exports, never default exports. Named exports make refactoring and search easier.

**Pattern** — `components/RecipeCard.tsx — anatomy template`

```
// 1. IMPORTS — React → external → internal → relative
import { useState } from 'react';
import Link from 'next/link';
import { cn } from '@/shared/utils/cn';
import { useSavedStore } from '../stores/useSavedStore';

// 2. TYPES
interface RecipeCardProps {
  slug: string;
  title: string;
  description: string;
  category: string;
}

// 3. CONSTANTS — static, never changes based on props
const MAX_DESCRIPTION_LENGTH = 120;
const CARD_BASE_CLASS = 'rounded-xl border bg-white shadow-sm';

// 4. COMPONENT FUNCTION
export function RecipeCard({ slug, title, description, category }: RecipeCardProps) {
  const [isHovered, setIsHovered] = useState(false);
  const { isSaved } = useSavedStore();

  const truncated = description.slice(0, MAX_DESCRIPTION_LENGTH);

  return (
    <div className={cn(CARD_BASE_CLASS, isHovered && 'shadow-lg')}>
      {/* ... */}
    </div>
  );
}

// 5. EXPORT — named, at the bottom
// (already exported above with "export function")
```

**Implementation — Next.js**

In Next.js, mark client components explicitly with 'use client' — it goes above all imports as the very first line. See the annotated Button component in the starter template: github.com/sindev08/react-principles-nextjs → src/ui/Button.tsx

File: `ui/Button.tsx — from react-principles-nextjs starter`

```
// 'use client' goes ABOVE imports if the component is a Client Component
// (Button doesn't need it — no hooks or browser APIs)

// 1. IMPORTS — React → external → internal (@/) → relative (./)
import { type ButtonHTMLAttributes } from 'react';
import { cn } from '@/shared/utils';

// 2. TYPES — props interface first, then supporting types
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
  variant?: ButtonVariant;
  size?: ButtonSize;
}
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';

// 3. CONSTANTS — static, never changes based on props
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
  primary: 'bg-zinc-900 text-white hover:bg-zinc-800 ...',
  secondary: 'bg-zinc-100 text-zinc-900 hover:bg-zinc-200 ...',
  // ...
};
const SIZE_CLASSES: Record<ButtonSize, string> = {
  sm: 'h-8 px-3 text-xs',
  md: 'h-10 px-4 text-sm',
  lg: 'h-12 px-6 text-base',
};

// 4. COMPONENT — named export, never default
export function Button({ variant = 'primary', size = 'md', className, children, ...props }: ButtonProps) {
  return (
    <button className={cn(BASE_CLASSES, VARIANT_CLASSES[variant], SIZE_CLASSES[size], className)} {...props}>
      {children}
    </button>
  );
}
```

**Implementation — Vite**

In Vite + React, all components are client-side by default. No 'use client' needed — just follow the anatomy.

File: `features/cookbook/components/RecipeCard.tsx`

```tsx
// 1. IMPORTS
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { cn } from '@/shared/utils/cn';

// 2. TYPES
interface RecipeCardProps {
  slug: string;
  title: string;
}

// 3. CONSTANTS
const BASE_PATH = '/cookbook';

// 4. COMPONENT
export function RecipeCard({ slug, title }: RecipeCardProps) {
  const [saved, setSaved] = useState(false);

  return (
    <Link to={`${BASE_PATH}/${slug}`}>
      <span>{title}</span>
    </Link>
  );
}
```

Read more: https://reactprinciples.dev/cookbook/component-anatomy

### useEffect & Render Cycle

> When effects run, why the dependency array exists, and how to clean up after yourself.

**Principle:** useEffect is not a lifecycle method — it is a synchronization tool. It answers one question: 'what side effects need to stay in sync with this data?' Every time the dependency array changes, React re-runs the effect to keep things synchronized. When you understand this mental model, dependency arrays stop feeling like magic rules and start making sense.

**Tip:** If you find yourself writing useEffect to fetch data, stop. That is what React Query is for. useEffect is for synchronizing with things outside React — browser APIs, subscriptions, timers.

**Rules:**
- **Always declare dependencies honestly** — Every value from the component scope used inside the effect belongs in the dependency array. If you add eslint-disable to hide a missing dependency, you have a bug.
- **Return a cleanup function when needed** — If your effect creates a subscription, timer, or event listener — clean it up in the return function. Otherwise you get memory leaks and stale handlers.
- **Empty array means once on mount** — [] runs the effect once after the first render. Only use this when the effect truly has no dependencies — not as a shortcut to avoid thinking about deps.
- **Do not use useEffect for data fetching** — Fetching data inside useEffect causes race conditions, no loading state management, and no caching. Use React Query instead.

**Pattern** — `hooks/useWindowSize.ts — effect with cleanup`

```
import { useEffect, useState } from 'react';

interface WindowSize {
  width: number;
  height: number;
}

export function useWindowSize(): WindowSize {
  const [size, setSize] = useState<WindowSize>({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    // The effect: subscribe to resize events
    function handleResize() {
      setSize({
        width: window.innerWidth,
        height: window.innerHeight,
      });
    }

    window.addEventListener('resize', handleResize);

    // The cleanup: unsubscribe when component unmounts
    // or before the effect re-runs
    return () => {
      window.removeEventListener('resize', handleResize);
    };
  }, []); // No deps — window never changes

  return size;
}
```

**Implementation — Next.js**

In Next.js, window is not available on the server. Use 'use client' and guard browser API access. See all three hooks in the starter template: github.com/sindev08/react-principles-nextjs → src/shared/hooks/

File: `shared/hooks/useDebounce.ts — from react-principles-nextjs starter`

```
'use client';

import { useEffect, useState } from 'react';

// Effect pattern: setTimeout with cleanup
export function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState<T>(value);

  useEffect(() => {
    // Set a timer to update after the delay
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    // Cleanup: clear the timer if value changes before it fires
    // This is what makes it a "debounce" — rapid changes reset the timer
    return () => {
      clearTimeout(timer);
    };
  }, [value, delay]); // ✅ Honest dependencies

  return debouncedValue;
}

// Also in the starter:
// useMediaQuery — useSyncExternalStore with matchMedia (event listener pattern)
// useLocalStorage — cross-tab sync via storage event (cleanup pattern)
```

**Implementation — Vite**

In Vite, all code runs in the browser — no SSR concerns. The pattern is straightforward.

File: `shared/hooks/useWindowSize.ts`

```ts
import { useEffect, useState } from 'react';

export function useWindowSize() {
  const [size, setSize] = useState({
    width: window.innerWidth,
    height: window.innerHeight,
  });

  useEffect(() => {
    function handleResize() {
      setSize({ width: window.innerWidth, height: window.innerHeight });
    }

    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  return size;
}
```

Read more: https://reactprinciples.dev/cookbook/useeffect-render-cycle

### Component Composition

> How components combine and communicate — children props, slot patterns, and why composition beats deep prop drilling.

**Principle:** Prop drilling happens when you pass data through multiple components that do not use it — just to get it to a component deep in the tree. Composition solves this differently: instead of passing data down, you pass components down. The parent controls what gets rendered, and children receive exactly what they need directly.

**Tip:** When you find yourself adding a prop to a component just to pass it further down, stop. That is the signal to use composition instead.

**Rules:**
- **Use children for flexible content** — The children prop lets a parent inject content into a component without the component needing to know what it is.
- **Use named slots for multiple injection points** — When you need more than one place to inject content (header + footer + body), use named props instead of children.
- **Prefer composition over configuration** — A component that accepts children is more flexible than one with 10 props controlling its internals. Compose behavior, do not configure it.
- **Keep components focused** — Each component does one thing. Composition is how you build complex UIs from simple, focused pieces.

**Pattern** — `components/Card.tsx — slot composition pattern`

```
// ❌ Prop drilling — Card needs to know about title, footer, etc.
<Card
  title="Recipe"
  subtitle="Foundations"
  footer={<Button>View</Button>}
  headerIcon="layers"
/>

// ✅ Composition — Card just provides structure
<Card>
  <Card.Header>
    <span>Foundations</span>
    <h2>Recipe</h2>
  </Card.Header>
  <Card.Body>
    Content goes here
  </Card.Body>
  <Card.Footer>
    <Button>View</Button>
  </Card.Footer>
</Card>

// The Card implementation
interface CardProps { children: React.ReactNode }
interface CardHeaderProps { children: React.ReactNode }

function Card({ children }: CardProps) {
  return <div className="rounded-xl border bg-white">{children}</div>;
}

function CardHeader({ children }: CardHeaderProps) {
  return <div className="p-4 border-b">{children}</div>;
}

Card.Header = CardHeader;
Card.Body = ({ children }: CardProps) => <div className="p-4">{children}</div>;
Card.Footer = ({ children }: CardProps) => <div className="p-4 border-t">{children}</div>;
```

**Implementation — Next.js**

In Next.js, composition works the same way. Server Components can pass Client Components as children — this is how you keep server/client boundaries clean. See the starter template: github.com/sindev08/react-principles-nextjs → src/shared/components/PageLayout.tsx and src/ui/Card.tsx

File: `shared/components/PageLayout.tsx — from react-principles-nextjs starter`

```
// Named slots pattern — multiple injection points via named props
interface PageLayoutProps {
  header?: React.ReactNode;   // slot for navbar/header
  sidebar?: React.ReactNode;  // slot for sidebar navigation
  children: React.ReactNode;  // main content area
}

export function PageLayout({ header, sidebar, children }: PageLayoutProps) {
  return (
    <div className="flex min-h-screen flex-col">
      {header && <header className="border-b">{header}</header>}
      <div className="flex flex-1">
        {sidebar && <aside className="w-64 shrink-0 border-r">{sidebar}</aside>}
        <main className="flex-1 p-6">{children}</main>
      </div>
    </div>
  );
}

// Usage in a Server Component page:
export default async function UsersPage() {
  return (
    <PageLayout
      header={<Navbar />}
      sidebar={<Sidebar items={menuItems} />}
    >
      {/* Client Component as children — clean server/client boundary */}
      <UserList />
    </PageLayout>
  );
}
```

**Implementation — Vite**

In Vite, all components are client-side. Composition is the primary tool for managing component complexity without prop drilling.

File: `features/cookbook/components/RecipeLayout.tsx`

```tsx
interface RecipeLayoutProps {
  header: React.ReactNode;
  sidebar: React.ReactNode;
  children: React.ReactNode;
}

export function RecipeLayout({ header, sidebar, children }: RecipeLayoutProps) {
  return (
    <div className="min-h-screen">
      <header className="border-b">{header}</header>
      <div className="flex max-w-7xl mx-auto">
        <aside className="w-64 shrink-0">{sidebar}</aside>
        <main className="flex-1 px-8">{children}</main>
      </div>
    </div>
  );
}

// Usage in a route component
export function RecipePage() {
  const { slug } = useParams<{ slug: string }>();
  const { data: detail } = useRecipeDetail(slug!);

  if (!detail) return null;

  return (
    <RecipeLayout
      header={<RecipeHeader title={detail.title} />}
      sidebar={<RecipeToc sections={detail.sections} />}
    >
      <RecipeContent detail={detail} />
    </RecipeLayout>
  );
}
```

Read more: https://reactprinciples.dev/cookbook/component-composition

### Custom Hooks

> The boundary between logic and rendering. When to extract a hook, what the rules are, and how to avoid the most common mistake.

**Principle:** A custom hook is not just a function that starts with 'use' — it is a boundary between logic and rendering. The component handles what the user sees. The hook handles how data gets there. When you separate these two concerns, components become easier to read, logic becomes easier to test, and both become easier to change independently.

**Tip:** If you would write a unit test for the logic, it belongs in a hook. If you would write a component test for it, it belongs in the JSX.

**Rules:**
- **Name starts with 'use'** — This is not just a convention — React uses it to enforce the rules of hooks. A function starting with 'use' is treated as a hook.
- **Extract when logic repeats** — If the same stateful logic appears in two components, extract it to a hook. Do not copy-paste hooks between components.
- **Extract when logic is complex** — If a component has more than one useEffect, multiple useState calls, or complex derived state — that logic belongs in a hook.
- **Hooks are not global state** — Each component that calls a hook gets its own isolated instance. Hooks do not share state between components unless backed by a store or context.

**Pattern** — `hooks/useDebounce.ts — logic extracted from component`

```
// ❌ Before — logic mixed into component
function SearchInput() {
  const [query, setQuery] = useState('');
  const [debouncedQuery, setDebouncedQuery] = useState('');

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedQuery(query);
    }, 300);
    return () => clearTimeout(timer);
  }, [query]);

  // Component is doing too much
  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}

// ✅ After — logic extracted to a hook
function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

// Component is now focused on rendering only
function SearchInput() {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
```

**Implementation — Next.js**

In Next.js, hooks that use browser APIs need 'use client'. Feature-specific hooks live in the feature folder, shared hooks in shared/. See the starter: github.com/sindev08/react-principles-nextjs → src/features/users/hooks/

File: `features/users/hooks/useUsers.ts — from react-principles-nextjs starter`

```
'use client';

import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { usersService } from '@/lib/services/users';

// Feature hook — encapsulates query key, service call, and caching
// Components just call useUsers() and get typed data back
export function useUsers(params?: { limit?: number; skip?: number }) {
  return useQuery({
    queryKey: queryKeys.users.list(params ?? {}),
    queryFn: () => usersService.getAll(params),
  });
}

// Also in the starter:
// features/users/hooks/useCreateUser.ts — mutation with cache invalidation
// shared/hooks/useDebounce.ts — generic debounce (logic extraction)
// shared/hooks/useMediaQuery.ts — browser API sync (useSyncExternalStore)
// shared/hooks/useLocalStorage.ts — storage sync with cross-tab support
```

**Implementation — Vite**

In Vite, hooks work the same way with no SSR considerations. Co-locate feature-specific hooks inside the feature folder.

File: `shared/hooks/useDebounce.ts`

```ts
import { useEffect, useState } from 'react';

export function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState<T>(value);

  useEffect(() => {
    const timer = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(timer);
  }, [value, delay]);

  return debounced;
}

// Usage in a component
// features/cookbook/components/SearchInput.tsx
import { useState, useEffect } from 'react';
import { useDebounce } from '@/shared/hooks/useDebounce';

export function SearchInput({ onSearch }: { onSearch: (q: string) => void }) {
  const [query, setQuery] = useState('');
  const debouncedQuery = useDebounce(query, 300);

  useEffect(() => {
    onSearch(debouncedQuery);
  }, [debouncedQuery, onSearch]);

  return (
    <input
      value={query}
      onChange={e => setQuery(e.target.value)}
      placeholder="Search recipes..."
    />
  );
}
```

Read more: https://reactprinciples.dev/cookbook/custom-hooks

### Services Layer

> How to organize all backend communication in one place — so when an API changes, you fix it in one file, not twenty.

**Principle:** When you fetch data directly inside a component, the component becomes responsible for knowing the URL, the HTTP method, the request format, and the error handling. That is four responsibilities too many. A services layer centralizes all backend communication — components just call a function and get data back. When the API changes, you fix it in one file, not twenty.

**Tip:** A service function should read like plain English: getUserById(id), createOrder(data), deletePost(id). If it needs more than one argument object, consider splitting it into two functions.

**Rules:**
- **Services only talk to the API** — A service function takes inputs, calls the API, and returns data. It does not touch state, does not render anything, and does not know about React.
- **One file per resource** — Group service functions by the API resource they belong to: users.ts, orders.ts, recipes.ts. Not by HTTP method.
- **Services live in lib/** — The services layer belongs in src/lib/ alongside the API client and query keys — not inside a feature folder.
- **Hooks consume services, components consume hooks** — Components never call service functions directly. The chain is: service → custom hook → component.

**Pattern** — `lib/services/users.ts — service layer pattern`

```
import { api } from '@/lib/api';
import { ENDPOINTS } from '@/lib/endpoints';
import type { User, UsersResponse, CreateUserInput, UpdateUserInput } from '@/shared/types/user';

// ✅ Service functions — pure API communication (no React, no state)
export const usersService = {
  getAll: (params?: { limit?: number; skip?: number }): Promise<UsersResponse> =>
    api.get<UsersResponse>(ENDPOINTS.users.list, { params }),

  getById: (id: number): Promise<User> =>
    api.get<User>(ENDPOINTS.users.detail(id)),

  create: (data: CreateUserInput): Promise<User> =>
    api.post<User>(ENDPOINTS.users.create, data),

  update: (id: number, data: UpdateUserInput): Promise<User> =>
    api.put<User>(ENDPOINTS.users.update(id), data),

  delete: (id: number): Promise<User> =>
    api.delete<User>(ENDPOINTS.users.delete(id)),
};
```

**Implementation — Next.js**

In Next.js App Router, service functions can be called directly in Server Components. For Client Components, wrap them in React Query hooks. See the full chain in the starter: github.com/sindev08/react-principles-nextjs → src/lib/

File: `lib/services/users.ts — from react-principles-nextjs starter`

```
// lib/api-client.ts — fetch-based factory (NOT axios)
import { createApiClient } from './api-client';
export const api = createApiClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL ?? 'https://dummyjson.com',
});

// lib/services/users.ts — pure API communication
import { api } from '@/lib/api';
import { ENDPOINTS } from '@/lib/endpoints';
import type { User, UsersResponse } from '@/shared/types/user';

export const usersService = {
  getAll: (params?: { limit?: number; skip?: number }): Promise<UsersResponse> =>
    api.get<UsersResponse>(ENDPOINTS.users.list, { params }),
  getById: (id: number): Promise<User> =>
    api.get<User>(ENDPOINTS.users.detail(id)),
  search: (q: string): Promise<UsersResponse> =>
    api.get<UsersResponse>(ENDPOINTS.users.search, { params: { q } }),
};

// features/users/hooks/useUsers.ts — hook wraps service with React Query
'use client';
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';

export function useUsers(params?: { limit?: number; skip?: number }) {
  return useQuery({
    queryKey: queryKeys.users.list(params ?? {}),
    queryFn: () => usersService.getAll(params),
  });
}
```

**Implementation — Vite**

In Vite, the pattern is identical. The services layer is framework-agnostic — the same createApiClient factory works in both Next.js and Vite projects.

File: `lib/services/users.ts + hooks usage`

```
// lib/api.ts — same createApiClient factory, different env var
import { createApiClient } from './api-client';
export const api = createApiClient({
  baseUrl: import.meta.env.VITE_API_URL ?? 'https://dummyjson.com',
});

// lib/services/users.ts — identical to Next.js version
import { api } from '@/lib/api';
import { ENDPOINTS } from '@/lib/endpoints';
import type { User, UsersResponse } from '@/shared/types/user';

export const usersService = {
  getAll: (params?: { limit?: number; skip?: number }): Promise<UsersResponse> =>
    api.get<UsersResponse>(ENDPOINTS.users.list, { params }),
  getById: (id: number): Promise<User> =>
    api.get<User>(ENDPOINTS.users.detail(id)),
};

// features/users/hooks/useUsers.ts — hook wraps service
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';

export function useUsers(params?: { limit?: number; skip?: number }) {
  return useQuery({
    queryKey: queryKeys.users.list(params ?? {}),
    queryFn: () => usersService.getAll(params),
    staleTime: 5 * 60 * 1000,
  });
}
```

Read more: https://reactprinciples.dev/cookbook/services-layer

### State Taxonomy

> Three categories of state — local, shared, and server — and exactly which tool handles each one.

**Principle:** Not all state is the same. Before reaching for any state management library, ask one question: where does this data come from? Local state lives inside one component. Shared state is UI state needed by multiple components. Server state comes from an API and has its own lifecycle — loading, error, stale, and needs refreshing. Each category has a different tool, and mixing them up causes bugs that are hard to trace.

**Tip:** When you find yourself putting API data into Zustand, stop. Server state belongs in React Query. When you find yourself using React Query for a toggle or a modal, stop. UI state belongs in useState or Zustand.

**Rules:**
- **Local state: useState** — If only one component needs it, keep it local. A form input value, a toggle, a hover state — these are all local state.
- **Shared state: Zustand** — If multiple components need the same UI state — sidebar open/closed, active theme, search dialog open — use Zustand. This is not server data.
- **Server state: React Query** — If it comes from an API, it is server state. React Query handles caching, background refetching, loading states, and error states automatically.
- **Never put server state in Zustand** — Storing API data in Zustand means you manage caching, staleness, and loading manually. React Query already does this — use the right tool.

**Pattern** — `The three categories — decision guide`

```
// ─── LOCAL STATE ──────────────────────────────────────────────
// One component needs it. No sharing needed.
const [isOpen, setIsOpen] = useState(false);
const [inputValue, setInputValue] = useState('');
const [hovering, setHovering] = useState(false);

// ─── SHARED STATE (Zustand) ───────────────────────────────────
// Multiple components need the same UI state.
// This is NOT data from an API.
const { sidebarOpen, toggleSidebar } = useAppStore();
const { theme, setTheme } = useAppStore();
const { open: searchOpen } = useSearchStore();

// ─── SERVER STATE (React Query) ───────────────────────────────
// Comes from an API. Has loading, error, and cache lifecycle.
const { data: users, isLoading, error } = useUsers();
const { data: user } = useUser(id);

// ❌ WRONG — API data in Zustand
const useUserStore = create((set) => ({
  users: [],
  fetchUsers: async () => {
    const data = await usersService.getAll(); // ← belongs in React Query
    set({ users: data });
  },
}));

// ✅ RIGHT — API data in React Query, UI state in Zustand
const { data: users } = useUsers();             // React Query
const { activeFilter } = useFilterStore();      // Zustand
```

**Implementation — Next.js**

In Next.js App Router, all three state categories are demonstrated in the starter template. See: github.com/sindev08/react-principles-nextjs → src/shared/stores/ and src/features/users/hooks/

File: `State taxonomy — from react-principles-nextjs starter`

```
// ─── SHARED STATE (Zustand) — src/shared/stores/ ─────────────
// useAppStore: theme + sidebar (app-wide UI)
'use client';
export const useAppStore = create<AppState>((set) => ({
  theme: 'light',
  sidebarOpen: true,
  toggleTheme: () => set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));

// useFilterStore: search + role + status filters with reset
// useSearchStore: search dialog open/closed

// ─── SERVER STATE (React Query) — src/features/users/hooks/ ──
// useUsers: paginated user list from DummyJSON
'use client';
export function useUsers(params?: { limit?: number; skip?: number }) {
  return useQuery({
    queryKey: queryKeys.users.list(params ?? {}),
    queryFn: () => usersService.getAll(params),  // service → hook → component
  });
}
// useUser(id): single user detail via usersService.getById
// useCreateUser: mutation via usersService.create + cache invalidation
```

**Implementation — Vite**

In Vite, all rendering is client-side. Server state always goes through React Query, shared state through Zustand, and local state through useState.

File: `Taxonomy in Vite + React`

```
// ─── LOCAL STATE ──────────────────────────────────────────────
function RecipeCard() {
  const [bookmarked, setBookmarked] = useState(false); // local only
  return (
    <button onClick={() => setBookmarked(b => !b)}>
      {bookmarked ? 'Saved' : 'Save'}
    </button>
  );
}

// ─── SERVER STATE (React Query) ───────────────────────────────
function RecipeList() {
  const { data: recipes, isLoading } = useQuery({
    queryKey: ['recipes'],
    queryFn: recipesService.getAll,
    staleTime: 5 * 60 * 1000,
  });

  if (isLoading) return <Spinner />;
  return <div>{recipes?.map(r => <RecipeCard key={r.id} {...r} />)}</div>;
}

// ─── SHARED STATE (Zustand) ───────────────────────────────────
function Navbar() {
  const { theme, toggleTheme } = useAppStore(); // shared UI state
  return <button onClick={toggleTheme}>{theme}</button>;
}
```

Read more: https://reactprinciples.dev/cookbook/state-taxonomy


## Patterns

### Server State with React Query

> Fetch, cache, and synchronize server data using TanStack Query v5. Covers pagination, search, background refetching, and loading states.

**Principle:** Server state is async, shared, and can become stale. TanStack Query owns the entire lifecycle — fetching, caching, deduplication, and background revalidation. Components declare what data they need via custom hooks and stay completely free of fetch logic.

**Tip:** Never mirror server data into useState. If it came from an API, it belongs in React Query's cache. Local state is only for UI — modals, toggles, input values.

**Rules:**
- **Hooks own the fetching** — Query hooks go in hooks/queries/. Components only call the hook and render the result.
- **Hierarchical query keys** — Structure keys as arrays: ["users", "list", { search, page }] for granular cache invalidation.
- **Always set staleTime** — Default staleTime is 0 — every render refetches. Be explicit: 5 min for lists, 10 min for details.
- **Handle all states** — Always render isLoading, isError, and empty states. Never assume data exists on first render.

**Pattern** — `hooks/queries/useUsers.ts`

```ts
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { usersService, type GetUsersParams } from '@/lib/services/users';

export function useUsers(params: GetUsersParams = {}) {
  return useQuery({
    queryKey: queryKeys.users.list(params),
    queryFn: () => usersService.getAll(params),
    staleTime: 1000 * 60 * 5,       // 5 minutes
    placeholderData: (prev) => prev, // no layout shift on page change
  });
}
```

**Implementation — Next.js**

In Next.js App Router, prefetch data in a Server Component and hydrate the client cache via HydrationBoundary. The user sees real data on first paint — no loading spinner.

File: `app/users/page.tsx`

```tsx
import { HydrationBoundary, dehydrate } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/get-query-client';
import { queryKeys } from '@/lib/query-keys';
import { usersService } from '@/lib/services/users';

export default async function UsersPage() {
  const qc = getQueryClient();
  await qc.prefetchQuery({
    queryKey: queryKeys.users.list({}),
    queryFn: () => usersService.getAll({}),
  });
  return (
    <HydrationBoundary state={dehydrate(qc)}>
      <UserList />
    </HydrationBoundary>
  );
}
```

**Implementation — Vite**

In Vite, QueryClientProvider wraps the app. Call the hook directly inside your component — React Query handles loading and error states automatically.

File: `pages/UsersPage.tsx`

```tsx
import { useUsers } from '@/hooks/queries/useUsers';
import { LoadingState } from '@/components/common/LoadingState';

export function UsersPage() {
  const { data, isLoading, isError } = useUsers({ limit: 10, skip: 0 });

  if (isLoading) return <LoadingState rows={5} />;
  if (isError) return <p>Failed to load users.</p>;

  return <UserList users={data.users} total={data.total} skip={data.skip} limit={data.limit} />;
}
```

Read more: https://reactprinciples.dev/cookbook/server-state

### Client State with Zustand

> Manage global UI state across multiple Zustand stores. Covers selectors, actions, computed selectors, reset, and the 'use client' boundary in Next.js.

**Principle:** Client state — UI toggles, filter state, user preferences — belongs in Zustand, not React Query. Each store owns one domain. Components read a slice of state via selectors and call actions. No prop drilling, no context boilerplate.

**Tip:** One store per feature domain. Never put server state (API data) in Zustand — if it comes from an endpoint, it belongs in React Query.

**Rules:**
- **One store per domain** — useAppStore for app-wide settings, useFilterStore for filters, useSearchStore for search UI. Never mix concerns in a single store.
- **Actions inside the store** — Mutations happen in store actions, not in component event handlers. Keeps logic close to state.
- **Selectors over full-state** — Pass selector functions: useAppStore(s => s.theme) not useAppStore(). For multiple values, use useShallow from zustand/shallow.
- **Reset is first-class** — Always define a reset() action for stores that can be cleared. Useful for logout, navigation, and testing.
- **'use client' on the store file** — Zustand hooks call React internals (useState, useSyncExternalStore). Put 'use client' on the store file itself — never on barrel exports — so Server Components can still import types.

**Pattern** — `stores/useFilterStore.ts · useAppStore.ts · useSearchStore.ts`

```ts
'use client';

import { create } from 'zustand';
import type { UserRole, UserStatus } from '@/shared/types/common';

// ─── useFilterStore (feature-scoped filters) ─────────────────────────────────

interface FilterState {
  search: string;
  role: UserRole | null;
  status: UserStatus | null;
  setSearch: (search: string) => void;
  setRole: (role: UserRole | null) => void;
  setStatus: (status: UserStatus | null) => void;
  reset: () => void;
}

const initialFilterState = {
  search: '',
  role: null as UserRole | null,
  status: null as UserStatus | null,
};

export const useFilterStore = create<FilterState>((set) => ({
  ...initialFilterState,
  setSearch: (search) => set({ search }),
  setRole: (role) => set({ role }),
  setStatus: (status) => set({ status }),
  reset: () => set(initialFilterState),
}));

export const useHasActiveFilters = () =>
  useFilterStore(
    (s) => s.search !== '' || s.role !== null || s.status !== null,
  );

// ─── useAppStore (app-wide settings) ─────────────────────────────────────────

type Theme = 'light' | 'dark';

interface AppState {
  theme: Theme;
  sidebarOpen: boolean;
  setTheme: (theme: Theme) => void;
  toggleTheme: () => void;
  setSidebarOpen: (open: boolean) => void;
  toggleSidebar: () => void;
}

export const useAppStore = create<AppState>((set) => ({
  theme: 'dark',
  sidebarOpen: true,
  setTheme: (theme) => set({ theme }),
  toggleTheme: () =>
    set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
  setSidebarOpen: (sidebarOpen) => set({ sidebarOpen }),
  toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
}));

// ─── useSearchStore (search dialog UI) ───────────────────────────────────────

interface SearchState {
  open: boolean;
  setOpen: (open: boolean) => void;
  toggle: () => void;
}

export const useSearchStore = create<SearchState>()((set) => ({
  open: false,
  setOpen: (open) => set({ open }),
  toggle: () => set((s) => ({ open: !s.open })),
}));
```

**Implementation — Next.js**

Zustand stores are client-side only. In Next.js, use them inside Client Components marked with 'use client'. No HydrationBoundary needed — client state is not serialized. Use useShallow when reading multiple values to avoid unnecessary re-renders.

File: `components/UserFilters.tsx`

```tsx
'use client';

import { useShallow } from 'zustand/shallow';
import { useFilterStore, useHasActiveFilters } from '@/shared/stores/useFilterStore';
import { Input } from '@/ui/Input';
import { NativeSelect } from '@/ui/NativeSelect';
import type { UserRole } from '@/shared/types/common';

export function UserFilters() {
  const { search, role, setSearch, setRole, reset } = useFilterStore(
    useShallow((s) => ({
      search: s.search,
      role: s.role,
      setSearch: s.setSearch,
      setRole: s.setRole,
      reset: s.reset,
    })),
  );
  const hasFilters = useHasActiveFilters();

  return (
    <div className="flex items-end gap-3">
      <Input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search users..."
      />
      <NativeSelect
        value={role ?? ''}
        onChange={(e) =>
          setRole((e.target.value || null) as UserRole | null)
        }
      >
        <option value="">All roles</option>
        <option value="admin">Admin</option>
        <option value="editor">Editor</option>
        <option value="viewer">Viewer</option>
      </NativeSelect>
      {hasFilters && (
        <button onClick={reset}>Reset</button>
      )}
    </div>
  );
}
```

**Implementation — Vite**

In Vite, Zustand works identically — no special setup required. Import the store hook directly in any component. useShallow is still recommended for multi-value subscriptions.

File: `components/UserFilters.tsx`

```tsx
import { useShallow } from 'zustand/shallow';
import { useFilterStore, useHasActiveFilters } from '@/shared/stores/useFilterStore';
import { Input } from '@/ui/Input';
import { NativeSelect } from '@/ui/NativeSelect';
import type { UserRole } from '@/shared/types/common';

export function UserFilters() {
  const { search, role, setSearch, setRole, reset } = useFilterStore(
    useShallow((s) => ({
      search: s.search,
      role: s.role,
      setSearch: s.setSearch,
      setRole: s.setRole,
      reset: s.reset,
    })),
  );
  const hasFilters = useHasActiveFilters();

  return (
    <div className="flex items-end gap-3">
      <Input
        value={search}
        onChange={(e) => setSearch(e.target.value)}
        placeholder="Search users..."
      />
      <NativeSelect
        value={role ?? ''}
        onChange={(e) =>
          setRole((e.target.value || null) as UserRole | null)
        }
      >
        <option value="">All roles</option>
        <option value="admin">Admin</option>
        <option value="editor">Editor</option>
        <option value="viewer">Viewer</option>
      </NativeSelect>
      {hasFilters && (
        <button onClick={reset}>Reset</button>
      )}
    </div>
  );
}
```

Read more: https://reactprinciples.dev/cookbook/client-state

### Form Validation with Zod

> Schema-first form validation with React Hook Form and Zod. Type-safe, declarative error messages, and zero boilerplate for create and edit flows.

**Principle:** The Zod schema is the single source of truth — it defines the shape, types, and error messages. React Hook Form handles registration, submission, and field state. Components never write validation logic; they display what the schema declares.

**Tip:** Write the schema before a single input. Share schemas across forms with .pick(), .extend(), or .omit(). Keep all error messages inside the schema, not in JSX.

**Rules:**
- **Schema before form** — Define the Zod schema first. Never add validation inline with register options or manual if-statements.
- **Omit server-generated fields** — Use .omit({ id: true, createdAt: true }) for create forms. The schema reflects what the user provides.
- **handleSubmit owns errors** — Wrap mutation calls in handleSubmit. Validation errors surface automatically without try/catch in the component.
- **Reset after success** — Call reset() after a successful mutation to clear all field values and dirty state.
- **Share schemas between create and edit** — Define a base schema, then derive create and edit variants with .omit() or .partial(). Single source of truth for all validation rules.

**Pattern** — `shared/utils/validators.ts`

```ts
import { z } from 'zod';

// Base schema matching the User interface
const userSchema = z.object({
  id:        z.string().min(1, 'ID is required'),
  name:      z.string().min(1, 'Name is required'),
  email:     z.string().email('Enter a valid email address'),
  role:      z.enum(['viewer', 'editor', 'admin']),
  status:    z.enum(['active', 'inactive']),
  createdAt: z.string().datetime({ message: 'Invalid ISO date string' }),
});

// Create: omit server-generated fields
export const createUserSchema = userSchema.omit({ id: true, createdAt: true });

// Edit: partial of create schema — all fields optional
export const editUserSchema = createUserSchema.partial();

export type CreateUserValues = z.infer<typeof createUserSchema>;
export type EditUserValues   = z.infer<typeof editUserSchema>;
```

**Implementation — Next.js**

In Next.js App Router, pair the form with a React Query mutation. The form is a Client Component ('use client'). On success, invalidate the users list so the table refreshes automatically. Reset the form to clear dirty state.

File: `features/examples/components/UserForm.tsx`

```tsx
'use client';

import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { type z } from 'zod';
import { userSchema } from '@/shared/utils/validators';
import { useCreateUser } from '@/features/examples/hooks/useCreateUser';

const createUserFormSchema = userSchema.omit({ id: true, createdAt: true });
type CreateUserFormValues = z.infer<typeof createUserFormSchema>;

export function UserForm() {
  const { register, handleSubmit, reset,
    formState: { errors, isSubmitting } } = useForm<CreateUserFormValues>({
    resolver: zodResolver(createUserFormSchema),
    defaultValues: { name: '', email: '', role: 'viewer', status: 'active' },
  });

  const createMutation = useCreateUser();

  const onSubmit = async (data: CreateUserFormValues) => {
    await createMutation.mutateAsync(data);
    reset();
  };

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} placeholder="Full name" />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register('email')} placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}

      <select {...register('role')}>
        <option value="viewer">Viewer</option>
        <option value="editor">Editor</option>
        <option value="admin">Admin</option>
      </select>

      <select {...register('status')}>
        <option value="active">Active</option>
        <option value="inactive">Inactive</option>
      </select>

      <button type="submit" disabled={isSubmitting}>Create User</button>
    </form>
  );
}
```

**Implementation — Vite**

In Vite, the pattern is identical — React Query mutation with cache invalidation. The only difference is routing: use react-router instead of Next.js file-based routing.

File: `features/examples/components/UserEditForm.tsx`

```tsx
import { useEffect } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { type z } from 'zod';
import { userSchema } from '@/shared/utils/validators';
import { useUser } from '@/features/examples/hooks/useUser';
import { useUpdateUser } from '@/features/examples/hooks/useUpdateUser';

const editUserFormSchema = userSchema.omit({ id: true, createdAt: true });
type EditUserFormValues = z.infer<typeof editUserFormSchema>;

export function UserEditForm({ id }: { id: string }) {
  const { data: user } = useUser(id);
  const updateMutation = useUpdateUser(id);

  const { register, handleSubmit, reset,
    formState: { errors, isSubmitting } } = useForm<EditUserFormValues>({
    resolver: zodResolver(editUserFormSchema),
  });

  // Pre-populate form when user data loads
  useEffect(() => {
    if (user) {
      reset({
        name:   user.name,
        email:  user.email,
        role:   user.role,
        status: user.status,
      });
    }
  }, [user, reset]);

  const onSubmit = async (data: EditUserFormValues) => {
    await updateMutation.mutateAsync(data);
  };

  if (!user) return <p>Loading...</p>;

  return (
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('name')} placeholder="Full name" />
      {errors.name && <p>{errors.name.message}</p>}

      <input {...register('email')} placeholder="Email" />
      {errors.email && <p>{errors.email.message}</p>}

      <select {...register('role')}>
        <option value="viewer">Viewer</option>
        <option value="editor">Editor</option>
        <option value="admin">Admin</option>
      </select>

      <select {...register('status')}>
        <option value="active">Active</option>
        <option value="inactive">Inactive</option>
      </select>

      <button type="submit" disabled={isSubmitting}>Save Changes</button>
    </form>
  );
}
```

Read more: https://reactprinciples.dev/cookbook/form-validation

### Data Tables with TanStack Table

> Headless, sortable, filterable, and paginated tables using TanStack Table v8. Full styling control with no component library lock-in.

**Principle:** TanStack Table is a headless engine — it computes row models, manages sorting, filtering, and pagination state, but renders nothing. You own the markup. This separation means complete styling control without fighting a component library.

**Tip:** Wrap column definitions in useMemo with an empty dependency array. Column definitions are stable references — recreating them on every render causes unnecessary row model recalculations.

**Rules:**
- **Columns are stable** — Wrap column definitions in useMemo(() => [...], []). Redefining them each render triggers unnecessary re-sorts and re-filters.
- **Own the render loop** — Use flexRender() for both headers and cells. Never manually extract cell values — let the column definition handle rendering.
- **Server-side for large data** — Client-side filtering and sorting works up to ~1,000 rows. Beyond that, move pagination and filtering to the server.
- **Global vs column filters** — Use globalFilter for quick full-text search. Use column-level filters for advanced filtering UI with per-field controls.

**Pattern** — `components/UserTable.tsx`

```tsx
import { useMemo, useState } from 'react';
import {
  useReactTable, getCoreRowModel, getSortedRowModel,
  getFilteredRowModel, getPaginationRowModel,
  flexRender, type ColumnDef, type SortingState,
} from '@tanstack/react-table';
import type { User } from '@/shared/types/user';

const columns: ColumnDef<User>[] = [
  {
    id: 'name',
    header: 'Name',
    // accessorFn combines two fields into one sortable, filterable column
    accessorFn: (row) => `${row.firstName} ${row.lastName}`,
  },
  { accessorKey: 'email',  header: 'Email' },
  { accessorKey: 'age',    header: 'Age' },
  { accessorKey: 'gender', header: 'Gender' },
];

export function UserTable({ data }: { data: User[] }) {
  const [sorting, setSorting] = useState<SortingState>([]);
  const [globalFilter, setGlobalFilter] = useState('');
  const cols = useMemo(() => columns, []);

  const table = useReactTable({
    data, columns: cols,
    state: { sorting, globalFilter },
    onSortingChange: setSorting,
    onGlobalFilterChange: setGlobalFilter,
    getCoreRowModel: getCoreRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
  });

  return (
    <div>
      <input
        value={globalFilter}
        onChange={(e) => setGlobalFilter(e.target.value)}
        placeholder="Filter all columns..."
      />
      <table>
        <thead>
          {table.getHeaderGroups().map((headerGroup) => (
            <tr key={headerGroup.id}>
              {headerGroup.headers.map((header) => (
                <th
                  key={header.id}
                  onClick={header.column.getToggleSortingHandler()}
                >
                  {flexRender(header.column.columnDef.header, header.getContext())}
                  {{ asc: ' ↑', desc: ' ↓' }[header.column.getIsSorted() as string] ?? null}
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id}>
                  {flexRender(cell.column.columnDef.cell, cell.getContext())}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      <div>
        <button onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
          Previous
        </button>
        <span>
          Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()}
        </span>
        <button onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
          Next
        </button>
      </div>
    </div>
  );
}
```

**Implementation — Next.js**

In Next.js, prefetch user data in a Server Component and hydrate it via HydrationBoundary. The table renders immediately with cached data while staying reactive to updates.

File: `app/users/page.tsx`

```tsx
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient } from '@/lib/query-client';
import { queryKeys } from '@/lib/query-keys';
import { usersService } from '@/lib/services/users';
import { UserTable } from '@/features/users';

export default async function UsersPage() {
  const queryClient = getQueryClient();
  await queryClient.prefetchQuery({
    queryKey: queryKeys.users.all,
    queryFn: () => usersService.getAll({ limit: 100 }),
  });

  return (
    <HydrationBoundary state={dehydrate(queryClient)}>
      <UserTable />
    </HydrationBoundary>
  );
}
```

**Implementation — Vite**

In Vite, fetch data via a React Query hook and pass it to the table. DummyJSON returns users under data.users. For datasets under 1,000 rows, all filtering and sorting can stay client-side.

File: `pages/UsersPage.tsx`

```tsx
import { useUsers, UserTable } from '@/features/users';

export function UsersPage() {
  const { data, isLoading } = useUsers({ limit: 100 });

  if (isLoading) return <TableSkeleton />;

  return <UserTable data={data?.users ?? []} />;
}
```

Read more: https://reactprinciples.dev/cookbook/data-tables


## API Integration

### API Integration

> A custom fetch-based API client factory with typed methods, centralized error handling, and optional auth — no Axios needed.

**Principle:** All API calls flow through a single typed client created by createApiClient(). The factory configures the base URL, auth headers, and error handling once. Services wrap the client with domain-specific methods. React Query hooks wrap services for caching. Components never call fetch() directly.

**Tip:** The native Fetch API covers most use cases without a library. createApiClient adds type safety, automatic JSON serialization, query parameter handling, and centralized error handling — the exact gaps fetch leaves open — without pulling in Axios as a dependency.

**Rules:**
- **Single API Client Instance** — Create one instance via createApiClient() in lib/api.ts and import it everywhere. All requests share the same base URL, headers, and error handler.
- **Type All Responses** — Pass a generic type to every api.get<T>() call. TypeScript interfaces define the contract — if the backend changes shape, the compiler catches it.
- **Centralized Error Handling** — Pass an onError callback to createApiClient(). It fires on every failed request — connect it to a toast or error reporting service. Components never parse error responses.
- **Service → Hook → Component** — Services handle HTTP calls. Hooks wrap services with React Query. Components consume hooks. Each layer has one job — when the API changes, only the service file changes.

**Pattern** — `lib/api-client.ts`

```ts
import type { ApiError } from '@/shared/types/api';

interface ApiClientConfig {
  baseUrl: string;
  defaultHeaders?: Record<string, string>;
  onError?: (error: ApiError) => void;
  getAuthToken?: () => string | null;
}

interface RequestOptions extends Omit<RequestInit, 'body'> {
  params?: Record<string, string | number | boolean | undefined>;
  body?: unknown;
}

export function createApiClient(config: ApiClientConfig) {
  const { baseUrl, defaultHeaders = {}, onError, getAuthToken } = config;

  async function request<T>(method: string, path: string, options: RequestOptions = {}): Promise<T> {
    const { params, body, headers: reqHeaders, ...fetchOptions } = options;
    const url = new URL(path, baseUrl);
    if (params) {
      for (const [k, v] of Object.entries(params)) {
        if (v !== undefined) url.searchParams.set(k, String(v));
      }
    }

    const headers: Record<string, string> = {
      'Content-Type': 'application/json',
      ...defaultHeaders,
      ...(reqHeaders as Record<string, string> | undefined),
    };
    const token = getAuthToken?.();
    if (token) headers['Authorization'] = `Bearer ${token}`;

    const res = await fetch(url, {
      method, headers,
      body: body !== undefined ? JSON.stringify(body) : undefined,
      ...fetchOptions,
    });

    if (!res.ok) {
      const err: ApiError = await res.json().catch(() => ({
        message: res.statusText, statusCode: res.status,
      }));
      onError?.(err);
      throw err;
    }

    return (await res.json()) as T;
  }

  return {
    get<T>(path: string, opts?: RequestOptions) { return request<T>('GET', path, opts); },
    post<T>(path: string, body?: unknown, opts?: RequestOptions) { return request<T>('POST', path, { ...opts, body }); },
    put<T>(path: string, body?: unknown, opts?: RequestOptions) { return request<T>('PUT', path, { ...opts, body }); },
    patch<T>(path: string, body?: unknown, opts?: RequestOptions) { return request<T>('PATCH', path, { ...opts, body }); },
    delete<T>(path: string, opts?: RequestOptions) { return request<T>('DELETE', path, opts); },
  };
}

export type ApiClient = ReturnType<typeof createApiClient>;
```

**Implementation — Next.js**

Create a singleton instance in lib/api.ts, then build a service layer with typed methods. React Query hooks wrap the service for caching. The chain: createApiClient → usersService → useUsers → UserList.

File: `lib/api.ts + lib/services/users.ts`

```ts
// lib/api.ts — singleton instance
import { createApiClient } from './api-client';

export const api = createApiClient({
  baseUrl: process.env.NEXT_PUBLIC_API_URL ?? 'https://dummyjson.com',
  onError: (err) => console.error(`[API] ${err.statusCode}: ${err.message}`),
});

// lib/services/users.ts — typed service layer
import { api } from '@/lib/api';
import { ENDPOINTS } from '@/lib/endpoints';
import type { User, UsersResponse, CreateUserInput, UpdateUserInput } from '@/shared/types/user';

export const usersService = {
  getAll: (params?: { limit?: number; skip?: number }): Promise<UsersResponse> =>
    api.get<UsersResponse>(ENDPOINTS.users.list, { params }),
  getById: (id: number): Promise<User> =>
    api.get<User>(ENDPOINTS.users.detail(id)),
  create: (data: CreateUserInput): Promise<User> =>
    api.post<User>(ENDPOINTS.users.create, data),
  update: (id: number, data: UpdateUserInput): Promise<User> =>
    api.put<User>(ENDPOINTS.users.update(id), data),
  delete: (id: number): Promise<User> =>
    api.delete<User>(ENDPOINTS.users.delete(id)),
  search: (q: string): Promise<UsersResponse> =>
    api.get<UsersResponse>(ENDPOINTS.users.search, { params: { q } }),
};

// features/users/hooks/useUsers.ts — React Query hook
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { usersService } from '@/lib/services/users';

export function useUsers(params?: { limit?: number; skip?: number }) {
  return useQuery({
    queryKey: queryKeys.users.list(params ?? {}),
    queryFn: () => usersService.getAll(params),
  });
}
```

**Implementation — Vite**

The pattern is identical in Vite — the API client and services are framework-agnostic. Only the environment variable syntax changes.

File: `lib/api.ts + hooks/useUsers.ts`

```ts
// lib/api.ts — same factory, Vite env syntax
import { createApiClient } from './api-client';

export const api = createApiClient({
  baseUrl: import.meta.env.VITE_API_URL ?? 'https://dummyjson.com',
  onError: (err) => console.error(`[API] ${err.statusCode}: ${err.message}`),
});

// lib/services/users.ts — same pattern as Next.js (showing key methods)
import { api } from '@/lib/api';
import { ENDPOINTS } from '@/lib/endpoints';
import type { User, UsersResponse, CreateUserInput, UpdateUserInput } from '@/shared/types/user';

export const usersService = {
  getAll: (params?: { limit?: number; skip?: number }): Promise<UsersResponse> =>
    api.get<UsersResponse>(ENDPOINTS.users.list, { params }),
  getById: (id: number): Promise<User> =>
    api.get<User>(ENDPOINTS.users.detail(id)),
  create: (data: CreateUserInput): Promise<User> =>
    api.post<User>(ENDPOINTS.users.create, data),
  update: (id: number, data: UpdateUserInput): Promise<User> =>
    api.put<User>(ENDPOINTS.users.update(id), data),
  delete: (id: number): Promise<User> =>
    api.delete<User>(ENDPOINTS.users.delete(id)),
};

// features/users/hooks/useUsers.ts — React Query hook
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/query-keys';
import { usersService } from '@/lib/services/users';

export function useUsers(params?: { limit?: number; skip?: number }) {
  return useQuery({
    queryKey: queryKeys.users.list(params ?? {}),
    queryFn: () => usersService.getAll(params),
  });
}
```

Read more: https://reactprinciples.dev/cookbook/api-integration

---

## More

- Compact cookbook (no code): https://reactprinciples.dev/llms.txt
- Full cookbook (with code): https://reactprinciples.dev/llms-full.txt
- Interactive web version: https://reactprinciples.dev/cookbook
- UI Kit components: https://reactprinciples.dev/docs
- AI skills (Claude/Cursor/Copilot): https://github.com/sindev08/react-principles-skills
