React Patterns
The right component type is decided once. Getting it wrong means either a broken app (Server Component with useState) or a bloated client bundle (Client Component that didn't need to be).
Rule 1 — Server vs Client Component Decision Tree
Does the component need any of these?
- useState / useReducer
- useEffect / useLayoutEffect / useRef
- onClick, onChange, onSubmit, or any event handler
- Browser APIs (window, document, localStorage)
- React context (useContext)
- Third-party hooks that use any of the above
YES → Client Component (add "use client" at top of file)
NO → Server Component (default, no directive needed)
When in doubt: try Server Component first. TypeScript or the React compiler will tell you if you're wrong.
Rule 2 — Never useState in a Server Component
Before
// WRONG — will crash at build or runtime
// No "use client" directive, but uses hook
export default function Counter() {
const [count, setCount] = useState(0); // Error: hooks not allowed in Server Components
return <button => setCount(c => c + 1)}>{count}</button>;
}
After
// counter.tsx — Client Component
"use client";
import { useState } from 'react';
export function Counter() {
const [count, setCount] = useState(0);
return <button => setCount(c => c + 1)}>{count}</button>;
}
// page.tsx — Server Component (no directive)
import { Counter } from './counter';
export default function Page() {
// Fetch data here (server-side, no client overhead)
return (
<main>
<h1>Demo</h1>
<Counter /> {/* Client island inside Server shell */}
</main>
);
}
Rule 3 — use client Boundaries — Push Them Down
"use client" marks a boundary where the component and all its imports become client-side JavaScript. Keep that boundary as far down the tree as possible.
Before
"use client"; // Entire feature tree is now client bundle
import { Sidebar } from './sidebar';
import { UserMenu } from './user-menu';
import { StaticLegal } from './legal'; // This has zero interactivity!
export function Layout({ children }) {
const [sidebarOpen, setSidebarOpen] = useState(false);
return (
<div>
<Sidebar open={sidebarOpen} => setSidebarOpen(o => !o)} />
<UserMenu />
<StaticLegal /> {/* Shipped to client for no reason */}
{children}
</div>
);
}
After
// layout.tsx — Server Component
import { StaticLegal } from './legal'; // stays on server
import { InteractiveSidebar } from './interactive-sidebar'; // client island
export function Layout({ children }) {
return (
<div>
<InteractiveSidebar /> {/* "use client" lives here */}
<StaticLegal /> {/* never sent to browser */}
{children}
</div>
);
}
Rule 4 — Error Boundaries with error.tsx
Every route segment that can fail needs an error boundary. In Next.js App Router, error.tsx is the convention.
// app/dashboard/error.tsx
"use client"; // Error boundaries must be Client Components
import { useEffect } from 'react';
interface ErrorProps {
error: Error & { digest?: string };
reset: () => void;
}
export default function DashboardError({ error, reset }: ErrorProps) {
useEffect(() => {
// Log to error reporting service (Sentry, etc.)
console.error('Dashboard error:', error);
}, [error]);
return (
<div role="alert">
<h2>Something went wrong</h2>
<p>{error.message}</p>
<button again</button>
</div>
);
}
Rules:
error.tsxmust be a Client Component ("use client")- Receives
error(the thrown error) andreset(retry the segment) - Never expose raw error messages to users in production — log, show generic message
Rule 5 — Suspense + loading.tsx
Use Suspense to stream content progressively. In App Router, loading.tsx wraps the page in <Suspense> automatically.
// app/dashboard/loading.tsx
export default function DashboardLoading() {
return (
<div aria-label="Loading dashboard...">
<div className="skeleton h-8 w-48 mb-4" />
<div className="skeleton h-64 w-full" />
</div>
);
}
For more granular control:
// app/dashboard/page.tsx
import { Suspense } from 'react';
import { UserStats } from './user-stats';
import { RecentActivity } from './recent-activity';
import { StatsSkeleton, ActivitySkeleton } from './skeletons';
export default function DashboardPage() {
return (
<main>
<Suspense fallback={<StatsSkeleton />}>
<UserStats /> {/* streams in when ready */}
</Suspense>
<Suspense fallback={<ActivitySkeleton />}>
<RecentActivity /> {/* streams independently */}
</Suspense>
</main>
);
}
Rule 6 — Custom Hooks: Extract When Used 2+ Times
If the same stateful or side-effect logic appears in two components, it belongs in a custom hook.
Before
// Component A
function ComponentA() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch('/api/users').then(r => r.json()).then(d => { setData(d); setLoading(false); });
}, []);
// ...
}
// Component B — same logic copy-pasted
function ComponentB() {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
fetch('/api/users').then(r => r.json()).then(d => { setData(d); setLoading(false); });
}, []);
// ...
}
After
// hooks/use-users.ts
function useUsers() {
const [data, setData] = useState<User[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
fetch('/api/users')
.then(r => r.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, []);
return { data, loading, error };
}
// Both components now use the hook — single source of truth
function ComponentA() {
const { data, loading } = useUsers();
// ...
}
Anti-Patterns
| Pattern | Problem | Fix |
|---|---|---|
useState in Server Component |
Build error | Add "use client" or split component |
"use client" on every component |
Bloated bundle | Push boundary down to the interactive leaf |
| No error boundary on async segments | Unhandled promise crashes silently | Add error.tsx per route segment |
| No Suspense on async data fetches | Waterfall loading / layout shift | Wrap in <Suspense> with skeleton fallback |
| Copy-pasted hook logic | State drift between components | Extract to custom hook |
useEffect for data fetching in App Router |
Server Component is better | Fetch in Server Component instead |