Pattern Comparison
Understanding how composition patterns simplify component architecture
The Tripartite Structure
All our patterns organize context value into three distinct parts. This structure enables clear organization, selective consumption, and advanced optimizations.
Traditional (Flat Structure)
const value = {
count,
setCount,
inputRef,
isValid,
submit,
formRef,
maxLength,
min,
max,
// Everything mixed together!
// Hard to know what changes
// Hard to optimize
}Tripartite (Organized Structure)
const value = {
state: {
// Mutable data
count: 0,
isValid: true
},
actions: {
// Functions (compiler-stable)
setCount: () => {},
submit: () => {}
},
meta: {
// Constants & refs (stable)
inputRef,
formRef,
maxLength,
min,
max
}
}Why This Structure Matters:
- π― Clear organization β Know exactly where everything belongs
- π― Selective consumption β Components take only what they need
- π― Optimization β Enables advanced patterns like split contexts
- π― Self-documenting β Structure reveals intent at a glance
Counter
The Problem
Traditional approach forces all logic into a single component. If you want to reuse just the buttons or just the display, you can'tβthey're tightly coupled. Every piece of state must be passed as props.
The Solution
Composition splits functionality into independent blocks. Each block connects directly to shared state through context. No props needed. Use only what you need.
How It Works
1. Provider creates React Context
ββ Holds state: { count: 0 }
2. Display component
ββ useContext(CounterContext)
ββ Reads: count
ββ Renders: <div>{count}</div>
3. Increment button
ββ useContext(CounterContext)
ββ Calls: setCount(count + 1)
4. All blocks see same state
ββ Context automatically syncs
ββ No props drilling neededTraditional Approach
function Counter() {
const [count, setCount] = useState(0);
// Everything in one place
// Hard to split up
// Can't reuse parts
return (
<div>
<div>Count: {count}</div>
<button onClick={() =>
setCount(count - 1)
}>
-
</button>
<button onClick={() =>
setCount(count + 1)
}>
+
</button>
<button onClick={() =>
setCount(0)
}>
Reset
</button>
</div>
);
}Composition Pattern
<Counter.Provider>
<Counter.Display />
<Counter.Decrement />
<Counter.Increment />
<Counter.Reset />
</Counter.Provider>
// Each block is separate file
// Mix and match freely
// Reuse anywhere
// Want only display + increment?
<Counter.Provider>
<Counter.Display />
<Counter.Increment />
</Counter.Provider>
// Context handles everything
// No props neededKey Insight: Context eliminates prop drilling. Every block connects directly to shared state. Add, remove, or rearrange blocks freely.
Settings Dialog
The Problem
Traditional dialogs require 10+ props: isOpen, onClose, title, tabs, content, onSave, etc. Every piece must be threaded through. Adding new features means updating the props interface.
The Solution
Composition treats dialog parts as LEGO blocks. The Provider manages state. Each block (Trigger, Header, Content, Footer) connects independently via context.
How It Works
1. Provider creates context
ββ State: { isOpen: false, setIsOpen, values, etc }
2. Trigger component
ββ useContext(SettingsContext)
ββ onClick: setIsOpen(true)
3. Dialog component
ββ useContext(SettingsContext)
ββ Reads: isOpen
ββ Renders when isOpen === true
4. SaveButton component
ββ useContext(SettingsContext)
ββ Calls: handleSave(), setIsOpen(false)
5. All blocks share same context
ββ No props between them
ββ Independent yet coordinatedTraditional Approach
function SettingsDialog({
isOpen,
onClose,
title,
description,
tabs,
activeTab,
onTabChange,
onSave,
onReset,
values,
onChange,
// ... 10+ more props
}) {
// Props drilling nightmare
// Hard to extend
// Tightly coupled
return (
<Dialog
open={isOpen}
onOpenChange={onClose}
>
<DialogContent>
<DialogTitle>
{title}
</DialogTitle>
<Tabs
value={activeTab}
onValueChange={onTabChange}
>
{tabs.map(tab => ...)}
</Tabs>
<Button onClick={onSave}>
Save
</Button>
</DialogContent>
</Dialog>
);
}Composition Pattern
<Settings.Provider>
<Settings.Dialog>
<Settings.Trigger>
<Button>Settings</Button>
</Settings.Trigger>
<Settings.Header
title="Settings"
description="Preferences"
/>
<Settings.Content>
{/* Your form */}
</Settings.Content>
<Settings.Footer>
<Settings.ResetButton />
<Settings.SaveButton />
</Settings.Footer>
</Settings.Dialog>
</Settings.Provider>
// No props between blocks
// Context manages everything
// Easy to customize
// Add/remove blocks freelyKey Insight: Complex UIs become simple. No props interface to maintain. Each block is independent yet coordinated through context.
Data Table
The Problem
Traditional tables need 20+ props for features: sorting, filtering, pagination, selection. You get all features or none. Can't pick and choose. Configuration objects become massive.
The Solution
Composition makes every feature optional. Want just sorting? Use only SortButton. Need pagination? Add Pagination component. Context handles the complex state management.
How It Works
1. Provider creates context
ββ State: {
data: [...],
sortField: null,
sortOrder: 'asc',
filterValue: '',
currentPage: 1,
selectedRows: Set()
}
2. Search component
ββ useContext(DataTableContext)
ββ Updates: filterValue
ββ Provider auto-filters data
3. SortButton component
ββ useContext(DataTableContext)
ββ Updates: sortField, sortOrder
ββ Provider auto-sorts data
4. Pagination component
ββ useContext(DataTableContext)
ββ Reads: currentPage, totalPages
ββ Updates: currentPage
5. Content component
ββ useContext(DataTableContext)
ββ Reads: paginatedData (computed)
ββ Renders rows automatically
All blocks independent
State managed centrally
Features are optionalTraditional Approach
<DataTable
data={users}
columns={[
{ key: 'name', sortable: true },
{ key: 'email', sortable: true },
]}
sortable={true}
filterable={true}
searchable={true}
paginated={true}
pageSize={10}
selectable={true}
onSort={handleSort}
onFilter={handleFilter}
onSearch={handleSearch}
onPageChange={handlePage}
onSelect={handleSelect}
sortField="name"
sortOrder="asc"
searchValue=""
filterValue=""
currentPage={1}
selectedRows={[]}
// ... 20+ props
/>
// All features bundled
// Can't pick and choose
// Configuration nightmareComposition Pattern
<DataTable.Provider data={users}>
{/* Use only what you need */}
<DataTable.Search />
<DataTable.Table>
<DataTable.Header>
<DataTable.SortButton field="name">
Name
</DataTable.SortButton>
<DataTable.SortButton field="email">
Email
</DataTable.SortButton>
</DataTable.Header>
<DataTable.Body>
<DataTable.Content>
{(row) => (
<>
<TableCell>{row.name}</TableCell>
<TableCell>{row.email}</TableCell>
</>
)}
</DataTable.Content>
</DataTable.Body>
</DataTable.Table>
<DataTable.Pagination />
</DataTable.Provider>
// Pick exactly what you need
// No props between blocks
// Context does the heavy liftingKey Insight: Features become optional building blocks. Context manages complex state. Use Provider.state for computed values (filtered, sorted, paginated data).
Why This Works
React Context + Hooks
Context provides a way to pass data through the component tree without having to pass props manually at every level.
const MyContext = createContext();
function Provider({ children }) {
const [state, setState] = useState(initialState);
return (
<MyContext.Provider value={{ state, setState }}>
{children}
</MyContext.Provider>
);
}
function AnyChildComponent() {
const { state, setState } = useContext(MyContext);
// Access state directly, no props needed
}No Props Drilling
Traditional: Parent β Child β GrandChild β GreatGrandChild (passing props through every level)
Composition: Any component can access context directly, no matter how deep.
Automatic Optimization
React 19's compiler automatically optimizes context subscriptions. Components only re-render when their specific context values change. No manual memo() needed.
Better Code Organization
Each block lives in its own file. Easy to find, test, and maintain. Components stay small and focused. No massive configuration objects.
React Compiler Optimization
React 19's compiler automatically optimizes your code. No more manual memoization!
Traditional (Manual Optimization)
// Manual memoization everywhere
const increment = useCallback(() => {
setCount(c => c + 1)
}, [])
const value = useMemo(() => ({
state: { count },
actions: { increment }
}), [count, increment])
const Component = memo(() => {
// Component logic
})With Compiler (Automatic)
// Compiler handles optimization
const increment = () => {
setCount(c => c + 1) // Stable!
}
const value = {
state: { count },
actions: { increment } // Optimized!
}
const Component = () => {
// Compiler memoizes automatically
}Selective Re-rendering
Components only re-render based on what they destructure from context:
See it in action: Visit /re-render-demo for live visualization of selective re-rendering!
Advanced: Split Contexts
The Problem with Single Context
Even with React Compiler, when everything is in one context object, the object reference changes on every render, causing ALL consumers to re-render:
<Context.Provider value={{ state, actions, meta }}>
{/* New object on every render β all consumers re-render */}
</Context.Provider>The Solution: Separate Contexts
<StateContext.Provider value={count}> {/* Changes */}
<ActionsContext.Provider value={actions}> {/* Stable! */}
<MetaContext.Provider value={meta}> {/* Stable! */}
{children}
</MetaContext.Provider>
</ActionsContext.Provider>
</StateContext.Provider>
// Components subscribe selectively
const { reset } = useCounterActions() // Only ActionsContext
// β NEVER re-renders when count changes!Single Context
- β Simpler to implement
- β Good for 95% of cases
- β ALL consumers re-render
- β Compiler makes re-renders cheap
Split Contexts
- β True selective re-rendering
- β Components only re-render when needed
- β More complex to implement
- β Use for expensive components
Compare them live: Visit /re-render-demo to see side-by-side comparison with render count badges!
Server vs Client Components
The Architecture
Next.js 15 uses React Server Components by default. This means components render on the server unless you add 'use client'. Understanding when to use each is critical for performance.
When to Use Server Components
- β’ Fetching data from database or API
- β’ Accessing backend resources securely
- β’ Keeping sensitive information on server (API keys, tokens)
- β’ Large dependencies that don't need client-side execution
- β’ Static content rendering
When to Use Client Components
- β’ Adding interactivity (onClick, onChange, onSubmit)
- β’ Using state and effects (useState, useReducer, useEffect)
- β’ Using browser APIs (localStorage, window, document)
- β’ Using custom hooks that depend on state or effects
- β’ Using Context for state management
Composition Pattern with RSC
The composition pattern works perfectly with React Server Components. Pages fetch data on the server, then pass it to client components for interactivity:
RSC Boundary Rules
Understanding what can and can't cross the Server/Client boundary is critical:
β Can Pass
- β’ Primitive values (string, number, boolean)
- β’ Plain objects and arrays
- β’ Serializable data
- β’ React elements
β Cannot Pass
- β’ Functions (including render props!)
- β’ Class instances
- β’ Event handlers from server
- β’ Symbols
β Problem: Render Props
// app/page.tsx (Server Component)
export default async function Page() {
const users = await getUsers()
return (
<DataTable.Provider data={users}>
<DataTable.Content>
{(row) => <TableCell>{row.name}</TableCell>} // β Function!
</DataTable.Content>
</DataTable.Provider>
)
}β Solution: Client Wrapper
// app/page.tsx (Server Component)
export default async function Page() {
const users = await getUsers()
return <DataTableClient users={users} /> // β
Data only
}
// app/data-table-client.tsx (Client Component)
"use client"
export function DataTableClient({ users }) {
return (
<DataTable.Provider data={users}>
<DataTable.Content>
{(row) => <TableCell>{row.name}</TableCell>} // β
In client!
</DataTable.Content>
</DataTable.Provider>
)
}Standard Pattern
Server Actions Integration
Composition patterns work seamlessly with Server Actions. Two main approaches:
Method 1: Form Action (Recommended)
// Provider exposes formAction
const value = {
state,
actions: {
submit,
formAction // From useActionState
},
meta
}
// Component uses form
<form action={actions.formAction}>
<input name="content" value={state.input} />
<button type="submit">Send</button>
</form>Method 2: Custom Handler
// Provider exposes custom submit
const value = {
state,
actions: {
submit: async () => {
await onSubmit(state.input)
}
},
meta
}
// Component uses onClick
<button onClick={actions.submit}>Send</button>Hybrid Approach
// Support both patterns
if (actions.formAction) {
return (
<form action={actions.formAction}>
<button type="submit">Send</button>
</form>
)
}
return <button onClick={actions.submit}>Send</button>Data Fetching Patterns
β Correct: Server Components fetch directly
β Wrong: Server Components calling own API
Note: Server Components can directly import and call functions from lib/data or lib/db. This is faster and more efficient than creating API routes for internal data access.
Performance Benefits
- β’ Zero JavaScript for server components (see /counter: 0 B)
- β’ Faster initial page load β HTML rendered on server
- β’ Better SEO β Content available immediately
- β’ Automatic code splitting β Only client components in bundle
- β’ Streaming β Progressive rendering with Suspense