Advanced Patterns
Introduction to Advanced React Patterns in Next.js
Advanced React patterns help you write more maintainable, reusable, and performant code. In the context of Next.js, these patterns need to be adapted for Server Components and Client Components, creating unique challenges and opportunities.
Composition Patterns
Composition is the foundation of React development. In Next.js, composition becomes even more powerful when you understand how to mix Server and Client Components effectively.
Component Composition Basics
// components/Card.tsx (Server Component)
import { ReactNode } from 'react';
interface CardProps {
header?: ReactNode;
children: ReactNode;
footer?: ReactNode;
}
export function Card({ header, children, footer }: CardProps) {
return (
<div className="card">
{header && <div className="card-header">{header}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// app/products/[id]/page.tsx
import { Card } from '@/components/Card';
import { AddToCartButton } from '@/components/AddToCartButton';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await getProduct(params.id);
return (
<Card
header={
<div>
<h1>{product.name}</h1>
<span>${product.price}</span>
</div>
}
footer={<AddToCartButton productId={product.id} />}
>
<p>{product.description}</p>
<img src={product.image} alt={product.name} />
</Card>
);
}
Key Principle: Pass Server Components as props to Client Components using the children pattern. This allows Server Components to remain on the server while being composed within Client Components.
Compound Components Pattern
// components/Accordion/Accordion.tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
interface AccordionContextType {
activeId: string | null;
setActiveId: (id: string | null) => void;
}
const AccordionContext = createContext<AccordionContextType | null>(null);
export function Accordion({ children }: { children: ReactNode }) {
const [activeId, setActiveId] = useState<string | null>(null);
return (
<AccordionContext.Provider value={{ activeId, setActiveId }}>
<div className="accordion">{children}</div>
</AccordionContext.Provider>
);
}
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) {
throw new Error('Accordion compound components must be used within Accordion');
}
return context;
}
export function AccordionItem({
id,
children
}: {
id: string;
children: ReactNode;
}) {
return <div className="accordion-item">{children}</div>;
}
export function AccordionHeader({
id,
children
}: {
id: string;
children: ReactNode;
}) {
const { activeId, setActiveId } = useAccordion();
const isActive = activeId === id;
return (
<button
className={`accordion-header ${isActive ? 'active' : ''}`}
onClick={() => setActiveId(isActive ? null : id)}
>
{children}
<span>{isActive ? '−' : '+'}</span>
</button>
);
}
export function AccordionPanel({
id,
children
}: {
id: string;
children: ReactNode;
}) {
const { activeId } = useAccordion();
const isActive = activeId === id;
return isActive ? (
<div className="accordion-panel">{children}</div>
) : null;
}
// Usage
import {
Accordion,
AccordionItem,
AccordionHeader,
AccordionPanel
} from '@/components/Accordion';
export default function FAQPage() {
return (
<Accordion>
<AccordionItem id="1">
<AccordionHeader id="1">What is Next.js?</AccordionHeader>
<AccordionPanel id="1">
Next.js is a React framework for production...
</AccordionPanel>
</AccordionItem>
<AccordionItem id="2">
<AccordionHeader id="2">How does SSR work?</AccordionHeader>
<AccordionPanel id="2">
Server-side rendering generates HTML on the server...
</AccordionPanel>
</AccordionItem>
</Accordion>
);
}
Tip: Compound components provide implicit prop sharing through context, making the API cleaner and more flexible than prop drilling.
Higher-Order Components (HOC) in Next.js
While HOCs are less common in modern React with hooks, they still have valid use cases in Next.js for cross-cutting concerns.
Authentication HOC
// lib/withAuth.tsx
import { redirect } from 'next/navigation';
import { getServerSession } from 'next-auth';
import { authOptions } from '@/app/api/auth/[...nextauth]/route';
export function withAuth<P extends object>(
Component: React.ComponentType<P>
) {
return async function AuthenticatedComponent(props: P) {
const session = await getServerSession(authOptions);
if (!session) {
redirect('/login');
}
return <Component {...props} session={session} />;
};
}
// app/dashboard/page.tsx
import { withAuth } from '@/lib/withAuth';
async function DashboardPage({ session }) {
return (
<div>
<h1>Welcome, {session.user.name}!</h1>
<p>This is your dashboard</p>
</div>
);
}
export default withAuth(DashboardPage);
Data Fetching HOC
// lib/withData.tsx
export function withData<T, P extends object>(
Component: React.ComponentType<P & { data: T }>,
fetcher: () => Promise<T>
) {
return async function DataComponent(props: P) {
const data = await fetcher();
return <Component {...props} data={data} />;
};
}
// Usage
import { withData } from '@/lib/withData';
function UserList({ data }: { data: User[] }) {
return (
<ul>
{data.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export default withData(UserList, async () => {
const res = await fetch('https://api.example.com/users');
return res.json();
});
Warning: HOCs must be used carefully with Client Components. They work best with Server Components or when combined with proper memoization.
Render Props Pattern
The render props pattern provides a way to share code between components using a prop whose value is a function.
Mouse Tracker Example
// components/MouseTracker.tsx
'use client';
import { useState, useEffect, ReactNode } from 'react';
interface MousePosition {
x: number;
y: number;
}
interface MouseTrackerProps {
render: (position: MousePosition) => ReactNode;
}
export function MouseTracker({ render }: MouseTrackerProps) {
const [position, setPosition] = useState<MousePosition>({ x: 0, y: 0 });
useEffect(() => {
const handleMouseMove = (e: MouseEvent) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return <>{render(position)}</>;
}
// Usage
import { MouseTracker } from '@/components/MouseTracker';
export default function InteractivePage() {
return (
<div>
<h1>Move your mouse around</h1>
<MouseTracker
render={({ x, y }) => (
<div style={{ position: 'fixed', left: x, top: y }}>
📍 {x}, {y}
</div>
)}
/>
</div>
);
}
Data Fetching with Render Props
// components/DataFetcher.tsx
'use client';
import { useState, useEffect, ReactNode } from 'react';
interface DataFetcherProps<T> {
url: string;
children: (data: {
data: T | null;
loading: boolean;
error: Error | null;
}) => ReactNode;
}
export function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return <>{children({ data, loading, error })}</>;
}
// Usage
import { DataFetcher } from '@/components/DataFetcher';
export default function PostsPage() {
return (
<DataFetcher<Post[]> url="/api/posts">
{({ data, loading, error }) => {
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!data) return null;
return (
<ul>
{data.map(post => (
<li key={post.id}>{post.title}</li>
))}
</ul>
);
}}
</DataFetcher>
);
}
Modern Alternative: Custom hooks are often preferred over render props in modern React. However, render props remain useful for components that need to control rendering precisely.
Custom Hooks Pattern
Custom hooks are the modern approach to sharing stateful logic between components.
useLocalStorage Hook
// hooks/useLocalStorage.ts
'use client';
import { useState, useEffect } from 'react';
export function useLocalStorage<T>(
key: string,
initialValue: T
): [T, (value: T) => void] {
const [storedValue, setStoredValue] = useState<T>(() => {
if (typeof window === 'undefined') return initialValue;
try {
const item = window.localStorage.getItem(key);
return item ? JSON.parse(item) : initialValue;
} catch (error) {
console.error(error);
return initialValue;
}
});
const setValue = (value: T) => {
try {
setStoredValue(value);
if (typeof window !== 'undefined') {
window.localStorage.setItem(key, JSON.stringify(value));
}
} catch (error) {
console.error(error);
}
};
return [storedValue, setValue];
}
// Usage
'use client';
import { useLocalStorage } from '@/hooks/useLocalStorage';
export default function ThemeToggle() {
const [theme, setTheme] = useLocalStorage('theme', 'light');
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Current theme: {theme}
</button>
);
}
useDebounce Hook
// hooks/useDebounce.ts
'use client';
import { useState, useEffect } from 'react';
export function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
}
// Usage
'use client';
import { useState } from 'react';
import { useDebounce } from '@/hooks/useDebounce';
export default function SearchBar() {
const [searchTerm, setSearchTerm] = useState('');
const debouncedSearchTerm = useDebounce(searchTerm, 500);
useEffect(() => {
if (debouncedSearchTerm) {
// Perform search
console.log('Searching for:', debouncedSearchTerm);
}
}, [debouncedSearchTerm]);
return (
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search..."
/>
);
}
Slot Pattern for Layout Composition
// components/PageLayout.tsx
import { ReactNode } from 'react';
interface PageLayoutProps {
hero?: ReactNode;
sidebar?: ReactNode;
children: ReactNode;
footer?: ReactNode;
}
export function PageLayout({
hero,
sidebar,
children,
footer
}: PageLayoutProps) {
return (
<div className="page-layout">
{hero && <div className="hero-section">{hero}</div>}
<div className="main-container">
{sidebar && <aside className="sidebar">{sidebar}</aside>}
<main className="content">{children}</main>
</div>
{footer && <footer className="footer">{footer}</footer>}
</div>
);
}
// app/products/page.tsx
import { PageLayout } from '@/components/PageLayout';
import { ProductHero } from '@/components/ProductHero';
import { FilterSidebar } from '@/components/FilterSidebar';
export default function ProductsPage() {
return (
<PageLayout
hero={<ProductHero />}
sidebar={<FilterSidebar />}
footer={<div>© 2024 Store</div>}
>
<h1>Our Products</h1>
{/* Product grid */}
</PageLayout>
);
}
Tip: The slot pattern provides maximum flexibility for layout composition while keeping components independent and reusable.
Provider Pattern for Global State
// providers/CartProvider.tsx
'use client';
import { createContext, useContext, useState, ReactNode } from 'react';
interface CartItem {
id: string;
name: string;
price: number;
quantity: number;
}
interface CartContextType {
items: CartItem[];
addItem: (item: CartItem) => void;
removeItem: (id: string) => void;
total: number;
}
const CartContext = createContext<CartContextType | null>(null);
export function CartProvider({ children }: { children: ReactNode }) {
const [items, setItems] = useState<CartItem[]>([]);
const addItem = (item: CartItem) => {
setItems(prev => {
const existing = prev.find(i => i.id === item.id);
if (existing) {
return prev.map(i =>
i.id === item.id
? { ...i, quantity: i.quantity + item.quantity }
: i
);
}
return [...prev, item];
});
};
const removeItem = (id: string) => {
setItems(prev => prev.filter(item => item.id !== id));
};
const total = items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
return (
<CartContext.Provider value={{ items, addItem, removeItem, total }}>
{children}
</CartContext.Provider>
);
}
export function useCart() {
const context = useContext(CartContext);
if (!context) {
throw new Error('useCart must be used within CartProvider');
}
return context;
}
// app/layout.tsx
import { CartProvider } from '@/providers/CartProvider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<CartProvider>
{children}
</CartProvider>
</body>
</html>
);
}
Exercise: Build a Form Builder with Advanced Patterns
Create a flexible form builder that:
- Uses compound components for Form, Field, Label, Input, Error
- Implements a custom
useFormValidationhook - Provides a context-based validation system
- Supports field-level and form-level validation
- Includes debounced async validation
- Works with both Server and Client Components
Bonus: Add support for nested forms and field arrays.
Performance Patterns
Memoization with React.memo
// components/ExpensiveComponent.tsx
'use client';
import { memo } from 'react';
interface ExpensiveComponentProps {
data: any[];
onItemClick: (id: string) => void;
}
export const ExpensiveComponent = memo(function ExpensiveComponent({
data,
onItemClick
}: ExpensiveComponentProps) {
console.log('ExpensiveComponent rendered');
return (
<div>
{data.map(item => (
<div key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</div>
))}
</div>
);
});
Best Practice: Combine advanced patterns thoughtfully. Don't over-engineer solutions—choose the simplest pattern that solves your problem effectively.