Skip to content
react-frontend-patterns logo

React Frontend Patterns

react-frontend-patterns

Use when architecting or implementing React frontends with Zustand, TanStack Query, or component hierarchies. Symptoms - unclear where state lives, stores causing re-renders, no API caching strategy, inconsistent error handling.

SKILL.md

Full skill instructions

React Frontend Patterns

Overview

State flows down, events flow up. Colocate state with consumers. Fetch at route level.

Patterns for React 19 + TypeScript + Zustand + TanStack Query + Tailwind v4. Use in Phase 1 (architecture decisions) and Phase 3 (implementation).

Quick Reference

PatternRule
Zustand storesOne per feature domain (useCartStore, useUserStore)
SelectorsAlways: useStore((s) => s.field) — never bare useStore()
Multiple fieldsuseShallow: useStore(useShallow((s) => ({ a: s.a, b: s.b })))
Derived stateCompute in selector, never store computed values
Server dataTanStack Query — never Zustand for API data
Query keys[domain, action, params]: ['products', 'list', { category }]
MutationsuseMutation with onSuccess invalidation
Error boundariesPage-level + feature-level
Loading statesComponent-level via isPending
Test location__tests__/ subfolder

State Colocation

Where does this state live?
│
├── From server? ──────────► TanStack Query cache
│                            (products, user profile, orders)
│
├── Global UI? ────────────► Zustand store
│                            (theme, sidebar, notifications)
│
├── In URL? ───────────────► Router params/​search
│                            (filters, pagination, selected tab)
│
├── Form input? ───────────► react-hook-form
│                            (field values, validation)
│
└── Local to component? ───► useState
                             (modal open, hover, dropdown)

Rule: Start with the most local option. Lift only when needed.

Zustand Patterns

One store per feature domain. Actions own async logic. Reset for tests.

The Selector Rule (Non-Negotiable):

// ✅ Always use selectors
const items = useCartStore((s) => s.items)
const addItem = useCartStore((s) => s.addItem)

// ✅ Multiple fields with useShallow
const { items, total } = useCartStore(
  useShallow((s) => ({ items: s.items, total: s.total }))
)

// ❌ NEVER bare — causes re-render on ANY state change
const store = useCartStore()
const { items, total } = useCartStore()  // Also bad!

Create selector hooks to enforce this pattern:

// In store file — export selector hooks
export const useCartItems = () => useCartStore((s) => s.items)
export const useCartTotal = () => useCartStore((s) => s.items.reduce(...))
export const useCartActions = () => useCartStore(
  useShallow((s) => ({ addItem: s.addItem, removeItem: s.removeItem }))
)

// In components — impossible to use bare store
const items = useCartItems()  // ✅ Always correct

Complete store template: See references/​zustand-store-template.ts

TanStack Query Patterns

Queries for reads. Mutations for writes. Custom hooks wrap both.

// Query hook
export function useProducts(category: string) {
  return useQuery({
    queryKey: ['products', 'list', { category }],
    queryFn: () => api.products.list(category),
  })
}

// Mutation hook
export function useCreateProduct() {
  const queryClient = useQueryClient()
  return useMutation({
    mutationFn: api.products.create,
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['products'] })
    },
  })
}

Complete patterns: See references/​tanstack-query-patterns.tsx

Error & Loading Strategy

LayerError HandlingLoading
PageError boundary with fallback UIPage skeleton
FeatureError boundary isolates widgetComponent skeleton
MutationToast notification via onErrorButton spinner
FormInline validation messagesSubmit disabled
// Page-level boundary
<ErrorBoundary fallback={<PageError />}>
  <ProductsPage />
</​ErrorBoundary>

// Feature-level boundary
<ErrorBoundary fallback={<WidgetError />}>
  <RecommendationsWidget />
</​ErrorBoundary>

Component Hierarchy

src/
├── components/
│   ├── ui/                    # shadcn/​ui primitives
│   ├── [feature]/             # Feature components
│   │   ├── FeatureCard.tsx
│   │   └── __tests__/
│   │       └── FeatureCard.test.tsx
│   └── layout/                # Shell, nav, sidebar
├── stores/                    # Zustand stores
│   └── useCartStore.ts
├── hooks/                     # TanStack Query hooks
│   └── useProducts.ts
└── pages/                     # Route components
    └── ProductsPage.tsx

Common Mistakes

MistakeFix
Bare useStore()Always use selector: useStore((s) => s.field)
Server data in ZustandUse TanStack Query for API data
Storing derived stateCompute in selector: (s) => s.items.length
Missing query key paramsInclude all variables: ['products', { category, page }]
No error boundaryAdd page-level + feature-level boundaries
Mocking Zustand in testsUse real stores, reset in beforeEach

Red Flags — STOP and Fix

If you catch yourself doing any of these, stop and apply the correct pattern:

  • const { x, y, z } = useStore() — Bare destructuring. Use selectors or useShallow.
  • const store = useStore() — Bare store. Always select specific fields.
  • useState for data from API — Use TanStack Query.
  • Storing itemCount when you have items — Derive in selector: (s) => s.items.length
  • vi.mock on a Zustand store — Use real store, reset in beforeEach.
  • Fetching in useEffect — Use TanStack Query's useQuery.
  • "Just this once, time is tight" — Patterns take 10 seconds more. Do it right.

Provider Setup

See references/​provider-setup.tsx for the complete app provider stack.

E2E Testing Support

MANDATORY: All interactive elements MUST have data-testid attributes.

Naming convention: {component}-{element}-{action?}

Examples:

<button data-testid="recipe-form-submit">Save</​button>
<input data-testid="recipe-form-title" />
<div data-testid="recipe-card-{id}">...</​div>

For lists/​collections, include the ID:

data-testid="recipe-card-123"
data-testid="shopping-item-456"

Interactive elements requiring data-testid:

  • All buttons, links, and clickable elements
  • Form inputs (text, select, checkbox, radio)
  • Cards and list items that can be interacted with
  • Modals, dialogs, and their controls
  • Navigation items
  • Toast notifications and alerts