← Home

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 needed

Traditional 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 needed

Key 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 coordinated

Traditional 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 freely

Key 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 optional

Traditional 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 nightmare

Composition 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 lifting

Key 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:

const { state } = useCounter() // βœ… Re-renders when state changes
const { actions } = useCounter() // βœ… Never re-renders (actions stable)
const { state, meta } = useCounter() // βœ… Re-renders on state OR meta

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.

// Server Component (default)
export default async function Page() {
const data = await getUsers() // Direct data access
return <Counter.Provider data={data} />
}
// Client Component (interactive)
'use client'
export function Counter.Provider() {
const [count, setCount] = useState(0)
return <Context.Provider />
}

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:

Server Page β†’ Fetches Data
export default async function CounterPage() {
const initial = await getInitialCount() // Server-only
return <Counter.Provider initial={initial} />
}
Client Provider β†’ Manages State
'use client'
export function CounterProvider({ initial }) {
const [count, setCount] = useState(initial)
return <Context.Provider />
}
Consumer Components β†’ Use Context
export function CounterDisplay() {
const { count } = useCounter() // Inherits client boundary
return <div>{count}</div>
}

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 Component (page.tsx)
↓ fetches data (await getUsers())
↓ passes as props
Client Provider ("use client")
↓ useState(initialData)
↓ exposes via context
LEGO Blocks (consume context)

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

// app/page.tsx
const users = await getUsers() // Direct lib/data access
return <Counter.Provider initialCount={count} />

❌ Wrong: Server Components calling own API

// app/page.tsx
// DON'T: Unnecessary HTTP overhead
const res = await fetch('http://localhost:3000/api/users')

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