| name | frontend |
| description | Use when making architecture-level React decisions — component decomposition, choosing where state should live, selecting a state manager (Context vs Redux vs Zustand) or a data-fetching library (TanStack Query vs SWR), or planning a rendering-performance budget. For advanced hook patterns, use react. |
Frontend Patterns
Conventions and best practices for building maintainable, performant React applications.
When to Activate
- Designing component structure or deciding where state should live
- Choosing between local state, Context, Zustand, or Redux Toolkit
- Implementing data fetching, caching, or server state synchronization
- Building forms with validation and submission handling
- Optimizing rendering performance (re-renders, bundle size, lazy loading)
- Setting up routing with protected routes or nested layouts
- Writing tests for React components and hooks
Component Design
Single Responsibility
function UserDashboard({ userId }: { userId: string }) {
const [user, setUser] = useState(null);
const [orders, setOrders] = useState([]);
useEffect(() => { }, []);
return <div>{/* render user, orders, sidebar, notifications */}</div>;
}
function UserDashboard({ userId }: { userId: string }) {
return (
<DashboardLayout>
<UserProfile userId={userId} />
<OrderList userId={userId} />
<NotificationPanel userId={userId} />
</DashboardLayout>
);
}
Composition over Props Drilling
<Page user={user} onLogout={onLogout} theme={theme} />
<Header user={user} onLogout={onLogout} theme={theme} />
<Nav user={user} onLogout={onLogout} theme={theme} />
<Page>
<Header>
<Nav />
<UserMenu />
</Header>
<main>{children}</main>
</Page>
Container / Presentational Split
function OrderListContainer({ userId }: { userId: string }) {
const { data, isLoading, error } = useOrders(userId);
if (isLoading) return <OrderListSkeleton />;
if (error) return <ErrorBoundary error={error} />;
return <OrderList orders={data} />;
}
function OrderList({ orders }: { orders: Order[] }) {
return (
<ul>
{orders.map(o => <OrderItem key={o.id} order={o} />)}
</ul>
);
}
State Management
Decision Matrix
| State Type | Tool | Examples |
|---|
| UI / ephemeral | useState / useReducer | modal open, input value, toggle |
| Shared UI state | Zustand or Context | theme, sidebar collapsed, selected tab |
| Server state | TanStack Query | API responses, lists, detail pages |
| Complex client state | Redux Toolkit | shopping cart, multi-step wizard, offline queue |
| URL state | Search params | filters, pagination, selected item |
| Form state | React Hook Form | field values, validation, submission |
Zustand (preferred for shared UI state)
import { create } from "zustand";
interface SidebarStore {
isOpen: boolean;
toggle: () => void;
open: () => void;
close: () => void;
}
const useSidebarStore = create<SidebarStore>((set) => ({
isOpen: false,
toggle: () => set((s) => ({ isOpen: !s.isOpen })),
open: () => set({ isOpen: true }),
close: () => set({ isOpen: false }),
}));
import { persist } from "zustand/middleware";
const useThemeStore = create(persist(
(set) => ({ theme: "light", setTheme: (t) => set({ theme: t }) }),
{ name: "theme-storage" },
));
Redux Toolkit (complex client state)
import { createSlice, createAsyncThunk } from "@reduxjs/toolkit";
const fetchOrders = createAsyncThunk("orders/fetch", async (userId: string) => {
const res = await fetch(`/api/users/${userId}/orders`);
return res.json();
});
const ordersSlice = createSlice({
name: "orders",
initialState: { items: [] as Order[], status: "idle" as LoadingStatus },
reducers: {
removeOrder: (state, action) => {
state.items = state.items.filter(o => o.id !== action.payload);
},
},
extraReducers: (builder) => {
builder
.addCase(fetchOrders.pending, (state) => { state.status = "loading"; })
.addCase(fetchOrders.fulfilled, (state, action) => {
state.status = "succeeded";
state.items = action.payload;
})
.addCase(fetchOrders.rejected, (state) => { state.status = "failed"; });
},
});
Data Fetching
TanStack Query (server state)
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
function useOrders(userId: string) {
return useQuery({
queryKey: ["orders", userId],
queryFn: () => fetchOrders(userId),
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
});
}
function useCancelOrder() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (orderId: string) => cancelOrder(orderId),
onMutate: async (orderId) => {
await queryClient.cancelQueries({ queryKey: ["orders"] });
const prev = queryClient.getQueryData(["orders"]);
queryClient.setQueryData(["orders"], (old: Order[]) =>
old.filter(o => o.id !== orderId),
);
return { prev };
},
onError: (_, __, ctx) => {
queryClient.setQueryData(["orders"], ctx?.prev);
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["orders"] }),
});
}
Query Key Conventions
["users"]
["users", userId]
["users", userId, "orders"]
["users", userId, "orders", { status: "active" }]
queryClient.invalidateQueries({ queryKey: ["users", userId] });
Forms
React Hook Form + Zod
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Invalid email"),
password: z.string().min(8, "At least 8 characters"),
role: z.enum(["admin", "user"]),
});
type FormValues = z.infer<typeof schema>;
function CreateUserForm() {
const { register, handleSubmit, formState: { errors, isSubmitting } } =
useForm<FormValues>({ resolver: zodResolver(schema) });
const onSubmit = async (data: FormValues) => {
await createUser(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input {...register("email")} />
{errors.email && <p>{errors.email.message}</p>}
<input type="password" {...register("password")} />
{errors.password && <p>{errors.password.message}</p>}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "Creating…" : "Create User"}
</button>
</form>
);
}
Routing (React Router v6)
Nested Layouts with Protected Routes
import { createBrowserRouter, RouterProvider, Outlet, Navigate } from "react-router-dom";
function RequireAuth() {
const { user } = useAuth();
return user ? <Outlet /> : <Navigate to="/login" replace />;
}
const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{
element: <RequireAuth />,
children: [
{
element: <AppShell />,
children: [
{ path: "/", element: <Dashboard /> },
{ path: "/orders", element: <OrderList /> },
{ path: "/orders/:id", element: <OrderDetail />,
loader: ({ params }) => fetchOrder(params.id!) },
],
},
],
},
]);
URL State for Filters
import { useSearchParams } from "react-router-dom";
function OrderFilters() {
const [params, setParams] = useSearchParams();
const status = params.get("status") ?? "all";
return (
<select value={status} onChange={e =>
setParams(p => { p.set("status", e.target.value); return p; })
}>
<option value="all">All</option>
<option value="active">Active</option>
</select>
);
}
Performance
Memoization — when it helps
const sortedOrders = useMemo(
() => [...orders].sort((a, b) => b.total - a.total),
[orders],
);
const handleCancel = useCallback((id: string) => cancelOrder(id), [cancelOrder]);
const OrderItem = memo(function OrderItem({ order }: { order: Order }) {
return <li>{order.id}</li>;
});
Code Splitting
import { lazy, Suspense } from "react";
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Reports = lazy(() => import("./pages/Reports"));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
);
}
Virtualization for Long Lists
import { useVirtualizer } from "@tanstack/react-virtual";
function VirtualOrderList({ orders }: { orders: Order[] }) {
const parentRef = useRef<HTMLDivElement>(null);
const virtualizer = useVirtualizer({
count: orders.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 64,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(item => (
<div key={item.key} style={{ transform: `translateY(${item.start}px)` }}>
<OrderItem order={orders[item.index]} />
</div>
))}
</div>
</div>
);
}
Testing
Component Tests (React Testing Library)
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("submits form with valid data", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
render(<CreateUserForm onSubmit={onSubmit} />);
await user.type(screen.getByLabelText("Email"), "alice@example.com");
await user.type(screen.getByLabelText("Password"), "secretpass");
await user.click(screen.getByRole("button", { name: "Create User" }));
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith({
email: "alice@example.com",
password: "secretpass",
}));
});
test("shows validation errors", async () => {
const user = userEvent.setup();
render(<CreateUserForm onSubmit={vi.fn()} />);
await user.click(screen.getByRole("button", { name: "Create User" }));
expect(await screen.findByText("Invalid email")).toBeInTheDocument();
});
Testing Hooks
import { renderHook, act } from "@testing-library/react";
test("useCounter increments", () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
});
See also: unit-testing, accessibility
Red Flags
- Data fetching inside
useEffect without a library — manual fetch-in-effect produces race conditions, missing loading/error states, and no deduplication; use TanStack Query or SWR
- Global state for server data — storing server-fetched data in Redux/Zustand duplicates cache logic already solved by a data fetching library; keep server state in the fetching layer
key={index} in lists — using array index as key breaks React reconciliation when items reorder or are inserted; use a stable, unique ID from the data
- Uncontrolled forms for complex validation — uncontrolled inputs with
ref can't drive real-time validation or conditional fields; use React Hook Form with Zod schema validation
useEffect to sync derived state — computing derived values in an effect causes an extra render cycle; compute them inline during render or memoize with useMemo
- Prop drilling more than 2 levels — passing props through 3+ components is a sign the tree needs restructuring or a context/selector; don't reach for global state before considering composition
- No
Suspense boundary around lazy-loaded routes — code-split routes without a fallback show a blank screen during load; wrap every lazy route in <Suspense fallback={<Skeleton />}>
Checklist