Everything a senior-level React/Next.js engineer should deeply understand — from rendering strategies to production system design.
- React Performance & Memoization
- React Concurrent Features
- Next.js Rendering Strategies (SSR, SSG, ISR)
- Server Components & Server Actions
- Optimistic UI
- Data Fetching Patterns
- State Management at Scale
- Caching Architecture
- Production System Design
- Advanced Patterns & Architecture
- Testing Strategy
- Recommended Resources
useMemo caches the result of an expensive computation between re-renders.
const sortedItems = useMemo(() => {
return items.sort((a, b) => a.price - b.price);
}, [items]);When to use:
- Filtering/sorting large lists (thousands of items).
- Deriving complex objects passed as props to memoized children.
- Expensive mathematical or formatting computations.
When NOT to use:
- Simple arithmetic, string concatenation, or trivial object literals — the overhead of memoization itself exceeds the cost.
- As a "default" on every variable. Profile first.
Mental model: useMemo is a cache with a dependency array as key. React may discard the cache at any time (e.g., offscreen components). Never rely on it for correctness — only for performance.
Common mistake — unstable dependencies:
// BAD: `filter` is a new object every render → useMemo re-runs every time
function ProductList({ filter }) {
const results = useMemo(() => expensiveFilter(data, filter), [filter]);
}
// FIX: memoize `filter` at the parent, or destructure primitives
function ProductList({ category, minPrice }) {
const results = useMemo(
() => expensiveFilter(data, category, minPrice),
[category, minPrice]
);
}useCallback caches a function definition between renders. It is syntactic sugar over useMemo(() => fn, deps).
const handleDelete = useCallback((id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
}, []); // stable reference — safe to pass to React.memo childrenPrimary purpose: Prevent re-renders of memoized child components that receive callbacks as props.
// Without useCallback, <ExpensiveList> re-renders on every parent render
// because `onDelete` is a new function reference each time.
const Parent = () => {
const onDelete = useCallback((id) => { /* ... */ }, []);
return <ExpensiveList onDelete={onDelete} />;
};
const ExpensiveList = React.memo(({ onDelete }) => {
// renders 10,000 rows
});The useCallback + React.memo contract:
useCallback alone does nothing useful. It only helps when the consumer of the callback is wrapped in React.memo (or uses the callback in a dependency array of its own hooks). If there is no memoized consumer, you are paying the cost of memoization for zero benefit.
Higher-order component that skips re-rendering when props are shallowly equal.
const Row = React.memo(function Row({ item, onSelect }: Props) {
return <div onClick={() => onSelect(item.id)}>{item.name}</div>;
});Custom comparator:
const Row = React.memo(Component, (prevProps, nextProps) => {
// return true to SKIP re-render, false to re-render
return prevProps.item.id === nextProps.item.id
&& prevProps.item.updatedAt === nextProps.item.updatedAt;
});Often the best "performance optimization" is restructuring your component tree so that state lives closer to where it's used — eliminating unnecessary re-renders without any memoization.
// BEFORE: entire page re-renders on every keystroke
function Page() {
const [query, setQuery] = useState('');
return (
<div>
<SearchBar query={query} onChange={setQuery} />
<HeavyDashboard /> {/* re-renders needlessly */}
</div>
);
}
// AFTER: extract stateful part — HeavyDashboard never re-renders
function Page() {
return (
<div>
<SearchSection /> {/* state is inside */}
<HeavyDashboard />
</div>
);
}// Store latest callback without triggering re-renders
const callbackRef = useRef(onSave);
useEffect(() => { callbackRef.current = onSave; });
// Use in event listeners, intervals, etc.
useEffect(() => {
const id = setInterval(() => callbackRef.current(), 1000);
return () => clearInterval(id);
}, []); // no dependency on onSave — interval never resets- React DevTools Profiler — record renders, find "why did this render?"
- Chrome Performance tab — measure actual JS execution time.
React.Profilercomponent — programmatic render timing in production.- Rule: measure → identify bottleneck → apply targeted fix. Never scatter
useMemo/useCallbackeverywhere preemptively.
Marks a state update as non-urgent. React can interrupt rendering to keep the UI responsive.
const [isPending, startTransition] = useTransition();
function handleSearch(query: string) {
// Urgent: update input immediately
setInputValue(query);
// Non-urgent: can be interrupted
startTransition(() => {
setSearchResults(filterLargeDataset(query));
});
}Production use case: Filtering a 50,000-row table. The input stays snappy while the table update is deferred.
Creates a "lagging" copy of a value. Equivalent to wrapping the consumer in startTransition.
const deferredQuery = useDeferredValue(query);
// deferredQuery may lag behind `query` during heavy renders
const filteredItems = useMemo(
() => items.filter(i => i.name.includes(deferredQuery)),
[deferredQuery, items]
);<Suspense fallback={<Skeleton />}>
<LazyComponent /> {/* code splitting */}
<DataComponent /> {/* data fetching with a Suspense-enabled library */}
</Suspense>Nested Suspense boundaries let you control granularity: show a full-page skeleton or individual component skeletons.
function Comments({ commentsPromise }) {
const comments = use(commentsPromise); // suspends until resolved
return comments.map(c => <Comment key={c.id} {...c} />);
}use() can also read Context — it replaces useContext and works inside conditionals and loops.
| Strategy | Build time | Request time | Revalidation | Use case |
|---|---|---|---|---|
| SSG | HTML generated | Served from CDN | Manual redeploy | Marketing pages, docs |
| ISR | Initial HTML generated | Served stale, revalidated in background | Time-based or on-demand | Blog posts, product pages |
| SSR | — | HTML generated per request | Every request | Personalized dashboards, auth-gated |
| CSR | — | JS renders in browser | Client fetches | SPAs behind auth, real-time UIs |
| Streaming SSR | — | HTML streamed progressively | Every request | Complex pages with mixed priorities |
| PPR (Partial Prerendering) | Static shell generated | Dynamic holes streamed | Hybrid | E-commerce (static layout + dynamic cart) |
In the App Router, any page that doesn't use dynamic functions (cookies(), headers(), searchParams) is statically generated by default.
// app/about/page.tsx — automatically SSG
export default function AboutPage() {
return <div>About us</div>;
}
// Force static even with a fetch
export const dynamic = 'force-static';
// Generate static params for dynamic routes
export async function generateStaticParams() {
const posts = await getPosts();
return posts.map(post => ({ slug: post.slug }));
}// Time-based: revalidate every 60 seconds
export const revalidate = 60;
export default async function ProductPage({ params }) {
const product = await getProduct(params.id);
return <ProductView product={product} />;
}On-demand revalidation (webhook-driven):
// app/api/revalidate/route.ts
import { revalidatePath, revalidateTag } from 'next/cache';
export async function POST(request: Request) {
const { tag, secret } = await request.json();
if (secret !== process.env.REVALIDATION_SECRET) {
return Response.json({ error: 'Unauthorized' }, { status: 401 });
}
revalidateTag(tag); // invalidate all fetches tagged with this
// OR revalidatePath('/products'); // invalidate a specific path
return Response.json({ revalidated: true, now: Date.now() });
}ISR architecture in production:
CMS webhook → /api/revalidate → revalidateTag('products')
↓
Next server marks cached pages stale
↓
Next request triggers background regeneration
↓
New HTML served on subsequent requests
Force SSR by using dynamic functions or the route config:
export const dynamic = 'force-dynamic';
// Or simply use cookies/headers — this opts into SSR automatically
import { cookies } from 'next/headers';
export default async function DashboardPage() {
const session = (await cookies()).get('session');
const data = await getDashboardData(session.value);
return <Dashboard data={data} />;
}Stream HTML progressively — show important content first while slower parts load:
import { Suspense } from 'react';
export default function ProductPage({ params }) {
return (
<div>
{/* Sent immediately */}
<ProductHeader id={params.id} />
{/* Streamed when ready */}
<Suspense fallback={<ReviewsSkeleton />}>
<ProductReviews id={params.id} />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations id={params.id} />
</Suspense>
</div>
);
}Next.js experimental feature: static shell + dynamic holes.
// next.config.js
module.exports = { experimental: { ppr: true } };
// app/page.tsx
export default function StorePage() {
return (
<div>
<StaticNavbar /> {/* pre-rendered at build time */}
<StaticHeroBanner />
<Suspense fallback={<CartSkeleton />}>
<DynamicCartWidget /> {/* streamed at request time */}
</Suspense>
</div>
);
}Why this matters: You get CDN-speed for the shell + personalization for specific components. Best of both worlds.
Server Components run only on the server. They can directly access databases, file systems, and secrets. They send rendered HTML (not JS) to the client — zero bundle size impact.
// app/posts/page.tsx — Server Component by default
import { db } from '@/lib/db';
export default async function PostsPage() {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 20,
});
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.excerpt}</p>
</article>
))}
</div>
);
}
// No useState, no useEffect, no "loading state" — data is already here.Key rules:
- Server Components cannot use hooks (
useState,useEffect, etc.). - Server Components cannot use browser APIs (
window,document). - Client Components cannot import Server Components directly — pass them as
childrenor other props.
The boundary pattern:
// Server Component
import { ClientInteractiveWidget } from './ClientWidget';
export default async function Page() {
const data = await fetchData(); // server-only
return (
<ClientInteractiveWidget initialData={data}>
<ServerRenderedContent /> {/* passed as children — stays on server */}
</ClientInteractiveWidget>
);
}Functions that run on the server but are callable from the client. They replace API routes for mutations.
// app/actions/posts.ts
'use server';
import { revalidatePath } from 'next/cache';
import { db } from '@/lib/db';
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1),
});
export async function createPost(formData: FormData) {
const parsed = CreatePostSchema.safeParse({
title: formData.get('title'),
content: formData.get('content'),
});
if (!parsed.success) {
return { error: parsed.error.flatten().fieldErrors };
}
await db.post.create({ data: parsed.data });
revalidatePath('/posts');
return { success: true };
}Using in a Client Component:
'use client';
import { useActionState } from 'react';
import { createPost } from '@/app/actions/posts';
export function CreatePostForm() {
const [state, formAction, isPending] = useActionState(createPost, null);
return (
<form action={formAction}>
<input name="title" required />
<textarea name="content" required />
{state?.error && <p className="text-red-500">{JSON.stringify(state.error)}</p>}
<button disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
</form>
);
}Security considerations:
- Always validate inputs. Server Actions are publicly accessible endpoints.
- Always check authorization —
cookies(), session tokens. - Use
z.object()(Zod) or similar for runtime validation. - Rate-limit Server Actions in production (middleware or external).
Show the expected result immediately before the server confirms.
'use client';
import { useOptimistic } from 'react';
import { toggleLike } from '@/app/actions/likes';
export function LikeButton({ postId, initialLiked, initialCount }) {
const [optimistic, setOptimistic] = useOptimistic(
{ liked: initialLiked, count: initialCount },
(current, newLiked: boolean) => ({
liked: newLiked,
count: current.count + (newLiked ? 1 : -1),
})
);
async function handleClick() {
const newLiked = !optimistic.liked;
setOptimistic(newLiked); // instant UI update
await toggleLike(postId); // server mutation
// On success: server-revalidated data replaces optimistic state
// On failure: React automatically rolls back
}
return (
<button onClick={handleClick}>
{optimistic.liked ? '❤️' : '🤍'} {optimistic.count}
</button>
);
}'use client';
import { useOptimistic } from 'react';
type Todo = { id: string; text: string; completed: boolean };
export function TodoList({ todos, addTodoAction }) {
const [optimisticTodos, addOptimistic] = useOptimistic<Todo[], string>(
todos,
(state, newText) => [
...state,
{ id: `temp-${Date.now()}`, text: newText, completed: false },
]
);
async function handleSubmit(formData: FormData) {
const text = formData.get('text') as string;
addOptimistic(text); // show immediately with temp ID
await addTodoAction(formData); // server replaces temp with real ID
}
return (
<>
<form action={handleSubmit}>
<input name="text" />
<button>Add</button>
</form>
<ul>
{optimisticTodos.map(todo => (
<li key={todo.id} style={{
opacity: todo.id.startsWith('temp-') ? 0.6 : 1
}}>
{todo.text}
</li>
))}
</ul>
</>
);
}Good candidates: Likes/votes, adding comments, toggling settings, reordering lists, marking items as read.
Bad candidates: Payment processing, inventory reservations (stock could be zero), anything with complex server-side validation you cannot predict client-side.
// SEQUENTIAL — waterfall (slow)
export default async function Page() {
const user = await getUser(); // 200ms
const posts = await getPosts(user.id); // 300ms — waits for user
// Total: ~500ms
}
// PARALLEL — concurrent (fast)
export default async function Page() {
const userPromise = getUser();
const postsPromise = getPosts();
const [user, posts] = await Promise.all([userPromise, postsPromise]);
// Total: ~300ms (max of both)
}Next.js automatically deduplicates fetch() calls with the same URL and options within a single render pass.
// These two fetches in different components result in ONE network request:
// ComponentA.tsx
const data = await fetch('https://api.example.com/data');
// ComponentB.tsx
const data = await fetch('https://api.example.com/data');Cache control:
// Cache indefinitely (default for SSG)
fetch(url, { cache: 'force-cache' });
// Never cache (SSR behavior)
fetch(url, { cache: 'no-store' });
// Revalidate after N seconds (ISR behavior)
fetch(url, { next: { revalidate: 3600 } });
// Tag-based invalidation
fetch(url, { next: { tags: ['products'] } });For client-side data fetching where you need caching, refetching, pagination, and optimistic updates:
'use client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function Comments({ postId }) {
const queryClient = useQueryClient();
const { data, isLoading } = useQuery({
queryKey: ['comments', postId],
queryFn: () => fetchComments(postId),
staleTime: 5 * 60 * 1000, // 5 min before refetch
gcTime: 30 * 60 * 1000, // 30 min cache lifetime
});
const mutation = useMutation({
mutationFn: addComment,
onMutate: async (newComment) => {
// Cancel in-flight queries
await queryClient.cancelQueries({ queryKey: ['comments', postId] });
// Snapshot
const previous = queryClient.getQueryData(['comments', postId]);
// Optimistic update
queryClient.setQueryData(['comments', postId], (old) => [
...old,
{ ...newComment, id: `temp-${Date.now()}` },
]);
return { previous };
},
onError: (err, vars, context) => {
// Rollback
queryClient.setQueryData(['comments', postId], context.previous);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['comments', postId] });
},
});
}| Scenario | Recommendation |
|---|---|
| Form state, toggles, local UI | useState / useReducer |
| Shared UI state (modals, themes) | React Context + useReducer |
| Server-derived state | RSC + Server Actions (or React Query) |
| Global client state (rare) | Zustand, Jotai |
| Complex cross-cutting state | Zustand with slices |
| URL-driven state | nuqs or useSearchParams |
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
interface CartStore {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: () => number;
}
export const useCartStore = create<CartStore>()(
devtools(
persist(
(set, get) => ({
items: [],
addItem: (item) =>
set((state) => ({ items: [...state.items, item] }), false, 'addItem'),
removeItem: (id) =>
set((state) => ({
items: state.items.filter((i) => i.id !== id),
}), false, 'removeItem'),
total: () => get().items.reduce((sum, i) => sum + i.price * i.qty, 0),
}),
{ name: 'cart-storage' }
)
)
);import { useQueryState, parseAsInteger } from 'nuqs';
export function ProductFilters() {
const [category, setCategory] = useQueryState('category');
const [page, setPage] = useQueryState('page', parseAsInteger.withDefault(1));
const [sort, setSort] = useQueryState('sort', { defaultValue: 'popular' });
// URL: /products?category=shoes&page=2&sort=price
}Request → (1) Router Cache (client, in-memory)
→ (2) Full Route Cache (server, filesystem)
→ (3) Data Cache (server, fetch results)
→ (4) React Cache / request memoization (per-request dedup)
| Layer | Location | Duration | Invalidation |
|---|---|---|---|
| Router Cache | Client | Session / 30s–5min auto | router.refresh(), navigation, revalidatePath() |
| Full Route Cache | Server | Until revalidation | revalidatePath(), revalidateTag(), redeploy |
| Data Cache | Server | Until revalidation | revalidateTag(), time-based, cache: 'no-store' |
| Request Memoization | Server | Single request | Automatic |
Cache expensive computations at the data layer:
import { unstable_cache } from 'next/cache';
const getCachedProducts = unstable_cache(
async (category: string) => {
return db.product.findMany({ where: { category } });
},
['products'], // cache key prefix
{ revalidate: 300, tags: ['products'] }
); ┌─────────────────────────┐
│ CDN (Vercel/CF) │ TTL: varies by route
└────────────┬────────────┘
│
┌────────────▼────────────┐
│ Next.js Server Cache │ Full Route + Data Cache
└────────────┬────────────┘
│
┌──────────────────┼──────────────────┐
▼ ▼ ▼
┌────────────┐ ┌────────────────┐ ┌──────────────┐
│ Redis / │ │ Database │ │ External │
│ Memcached │ │ (Postgres) │ │ APIs │
└────────────┘ └────────────────┘ └──────────────┘
Rules of thumb:
- Static pages → CDN with long TTL + on-demand revalidation.
- Personalized pages → no CDN cache, use
Varyheaders or skip cache entirely. - Shared expensive queries → Redis/Memcached with tag-based invalidation.
- User-specific queries → short-lived or no cache.
// app/error.tsx — catches errors in the nearest segment
'use client';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
// Log to monitoring (Sentry, Datadog, etc.)
captureException(error);
}, [error]);
return (
<div>
<h2>Something went wrong</h2>
<button onClick={reset}>Try again</button>
</div>
);
}
// app/global-error.tsx — catches root layout errors
// app/not-found.tsx — custom 404Error boundary granularity: Place error.tsx at each route segment level. A failing <Reviews> section shouldn't crash the entire product page.
Runs before every request at the edge.
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// Auth check
const session = request.cookies.get('session');
if (!session && request.nextUrl.pathname.startsWith('/dashboard')) {
return NextResponse.redirect(new URL('/login', request.url));
}
// A/B testing
const bucket = request.cookies.get('ab-bucket');
if (!bucket) {
const response = NextResponse.next();
response.cookies.set('ab-bucket', Math.random() > 0.5 ? 'A' : 'B');
return response;
}
// Geolocation-based routing
const country = request.geo?.country || 'US';
if (country === 'DE' && !request.nextUrl.pathname.startsWith('/de')) {
return NextResponse.redirect(new URL('/de' + request.nextUrl.pathname, request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};Core Web Vitals targets:
- LCP (Largest Contentful Paint) < 2.5s
- INP (Interaction to Next Paint) < 200ms
- CLS (Cumulative Layout Shift) < 0.1
Monitoring stack:
- Real User Monitoring (RUM): Vercel Analytics, Datadog RUM, or custom
web-vitalsreporting. - Synthetic monitoring: Lighthouse CI in your pipeline.
- Error tracking: Sentry with source maps.
- Logging: structured JSON logs → aggregator (Datadog, Grafana).
import Image from 'next/image';
// Automatic WebP/AVIF, lazy loading, srcset generation
<Image
src="/hero.jpg"
alt="Hero"
width={1200}
height={600}
priority // above the fold — disable lazy loading
sizes="(max-width: 768px) 100vw, 50vw"
placeholder="blur"
blurDataURL={base64BlurHash}
/>
// Font optimization — no layout shift
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'], display: 'swap' });// next.config.js
const securityHeaders = [
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'Permissions-Policy', value: 'camera=(), microphone=()' },
{
key: 'Content-Security-Policy',
value: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'"
},
];
module.exports = {
async headers() {
return [{ source: '/(.*)', headers: securityHeaders }];
},
};# Install
npm install @next/bundle-analyzer
# next.config.js
const withBundleAnalyzer = require('@next/bundle-analyzer')({
enabled: process.env.ANALYZE === 'true',
});
module.exports = withBundleAnalyzer({ /* config */ });
# Run
ANALYZE=true npm run buildDynamic imports for heavy libraries:
import dynamic from 'next/dynamic';
const HeavyChart = dynamic(() => import('@/components/Chart'), {
loading: () => <ChartSkeleton />,
ssr: false, // only load client-side
});
// Or manual lazy loading
const { PDFViewer } = await import('react-pdf');src/
├── app/ # Next.js routing
│ ├── (auth)/ # Route group — shared layout, no URL segment
│ │ ├── login/
│ │ └── register/
│ ├── (dashboard)/
│ │ ├── layout.tsx # Dashboard shell
│ │ ├── overview/
│ │ └── settings/
│ └── api/
├── features/ # Domain-driven modules
│ ├── auth/
│ │ ├── components/
│ │ ├── actions/
│ │ ├── hooks/
│ │ ├── lib/
│ │ └── types.ts
│ ├── products/
│ └── cart/
├── shared/ # Cross-cutting concerns
│ ├── components/ # Design system primitives
│ ├── hooks/
│ ├── lib/ # Utilities, API clients
│ └── types/
└── infrastructure/ # DB, auth, monitoring config
├── db/
├── auth/
└── monitoring/
app/
├── @modal/ # Parallel route — renders alongside main content
│ └── (.)photo/[id]/ # Intercepting route — catches /photo/[id] navigations
│ └── page.tsx # Shows as a modal on client navigation
├── photo/[id]/
│ └── page.tsx # Full page on direct URL access or refresh
└── layout.tsx # Renders {children} + {modal}
// app/layout.tsx
export default function Layout({ children, modal }) {
return (
<>
{children}
{modal}
</>
);
}// Headless compound component — consumer controls rendering
const Tabs = ({ children, defaultValue }) => {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
{children}
</TabsContext.Provider>
);
};
Tabs.List = ({ children }) => <div role="tablist">{children}</div>;
Tabs.Trigger = ({ value, children }) => {
const { active, setActive } = useTabsContext();
return (
<button role="tab" aria-selected={active === value} onClick={() => setActive(value)}>
{children}
</button>
);
};
Tabs.Content = ({ value, children }) => {
const { active } = useTabsContext();
return active === value ? <div role="tabpanel">{children}</div> : null;
};
// Usage
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Trigger value="overview">Overview</Tabs.Trigger>
<Tabs.Trigger value="analytics">Analytics</Tabs.Trigger>
</Tabs.List>
<Tabs.Content value="overview"><Overview /></Tabs.Content>
<Tabs.Content value="analytics"><Analytics /></Tabs.Content>
</Tabs>// Custom hook encapsulating complex logic
function useInfiniteScroll<T>({
fetchFn,
pageSize = 20,
}: {
fetchFn: (cursor: string | null) => Promise<{ items: T[]; nextCursor: string | null }>;
pageSize?: number;
}) {
const [items, setItems] = useState<T[]>([]);
const [cursor, setCursor] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerRef = useRef<IntersectionObserver>();
const sentinelRef = useCallback((node: HTMLElement | null) => {
if (observerRef.current) observerRef.current.disconnect();
if (!node || !hasMore) return;
observerRef.current = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) loadMore();
});
observerRef.current.observe(node);
}, [hasMore, cursor]);
async function loadMore() {
if (isLoading) return;
setIsLoading(true);
const { items: newItems, nextCursor } = await fetchFn(cursor);
setItems(prev => [...prev, ...newItems]);
setCursor(nextCursor);
setHasMore(!!nextCursor);
setIsLoading(false);
}
return { items, isLoading, hasMore, sentinelRef };
} ┌───────────┐
│ E2E │ Playwright — critical user flows
│ (few) │ Login → checkout → confirmation
├───────────┤
│Integration│ Testing Library — page-level
│ (some) │ Component + hooks + API mocks
├───────────┤
│ Unit │ Vitest — pure logic
│ (many) │ Utils, hooks, reducers, validators
└───────────┘
// __tests__/PostsPage.test.tsx
import { render, screen } from '@testing-library/react';
import PostsPage from '@/app/posts/page';
// Mock the data layer, not the component
vi.mock('@/lib/db', () => ({
db: {
post: {
findMany: vi.fn().mockResolvedValue([
{ id: '1', title: 'Test Post', excerpt: 'Content' },
]),
},
},
}));
test('renders posts from database', async () => {
const Page = await PostsPage(); // Server Components are async functions
render(Page);
expect(screen.getByText('Test Post')).toBeInTheDocument();
});import { createPost } from '@/app/actions/posts';
test('validates input', async () => {
const formData = new FormData();
formData.set('title', ''); // invalid
formData.set('content', 'x');
const result = await createPost(formData);
expect(result.error.title).toBeDefined();
});- React docs — react.dev (especially the "Escape Hatches" and "Learn" sections)
- Next.js docs — nextjs.org/docs (App Router section, Caching deep-dive)
- Vercel blog — engineering articles on PPR, RSC internals
- Dan Abramov's "A Complete Guide to useEffect" — overreacted.io
- Josh Comeau's blog — joshwcomeau.com (visual explanations of React rendering, Server Components)
- Kent C. Dodds's blog — kentcdodds.com (testing philosophy, component patterns)
- TkDodo's blog — tkdodo.eu (authoritative TanStack Query content)
- Lee Robinson's YouTube — Next.js patterns, App Router migrations
- Patterns.dev — patterns.dev (rendering patterns, performance patterns)
- Web.dev — web.dev/learn (Core Web Vitals, performance)
- "Bulletproof React" — github.com/alan2207/bulletproof-react (project structure reference)
- Theo Browne (t3.gg) — YouTube for Next.js opinions and architecture takes
- Jack Herrington — YouTube for advanced React patterns and performance
- "React — The Full Course" by ui.dev — deep mental model of React internals
- Fluent React by Tejas Kumar (O'Reilly) — React internals, Fiber, reconciliation
- Learning Patterns by Lydia Hallie & Addy Osmani — design/rendering patterns for the web