Published on

useContext with Composition Pattern

Authors

The Tale of the Prop Drilling Nightmare

Marcus was building an e-commerce application. It started simple enough—a product page with a few components. But as features piled up, he found himself in what developers call "prop drilling hell."

function ProductPage({ user, theme, cart, notifications }) {
  return (
    <div>
      <Header user={user} theme={theme} cart={cart} notifications={notifications} />
      <ProductGrid user={user} theme={theme} cart={cart} />
      <Footer theme={theme} />
    </div>
  )
}

function Header({ user, theme, cart, notifications }) {
  return (
    <header>
      <Logo theme={theme} />
      <Navigation user={user} theme={theme} />
      <CartIcon cart={cart} theme={theme} />
      <NotificationBell notifications={notifications} theme={theme} />
      <UserMenu user={user} theme={theme} />
    </header>
  )
}

function Navigation({ user, theme }) {
  return (
    <nav>
      <NavLink theme={theme}>Home</NavLink>
      <NavLink theme={theme}>Products</NavLink>
      {user.isAdmin && <NavLink theme={theme}>Admin</NavLink>}
    </nav>
  )
}

// And it goes deeper and deeper...

Marcus counted the props being passed through components that didn't even use them—just relay stations passing data down. "There has to be a better way," he muttered.

The Discovery: Context + Composition

A colleague introduced Marcus to the magic combination: useContext with Composition Pattern.

"Think of Context as a radio broadcast," she explained. "Instead of passing messages hand-to-hand through every person in a line, you broadcast it. Anyone who needs it can tune in."

Understanding the Pattern

The Three Key Pieces

  1. Create the Context - The radio station
  2. Provide the Context - Start broadcasting
  3. Consume the Context - Tune in anywhere

Let's see how Marcus transformed his application.

Story 1: The Theme System

Before: Props Everywhere

function App() {
  const [theme, setTheme] = useState('light')

  return (
    <div>
      <Header theme={theme} setTheme={setTheme} />
      <Main theme={theme} />
      <Sidebar theme={theme} />
      <Footer theme={theme} />
    </div>
  )
}

function Header({ theme, setTheme }) {
  return (
    <header className={theme}>
      <Logo theme={theme} />
      <ThemeToggle theme={theme} setTheme={setTheme} />
    </header>
  )
}

// Every component needs these props!

After: Context + Composition

// 1. Create the context
const ThemeContext = createContext()

// 2. Create a provider component
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light')

  const toggleTheme = () => {
    setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))
  }

  const value = {
    theme,
    setTheme,
    toggleTheme,
  }

  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}

// 3. Create a custom hook for easy consumption
function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }
  return context
}

// Now the app is clean
function App() {
  return (
    <ThemeProvider>
      <Header />
      <Main />
      <Sidebar />
      <Footer />
    </ThemeProvider>
  )
}

// Components just grab what they need
function ThemeToggle() {
  const { theme, toggleTheme } = useTheme()

  return <button onClick={toggleTheme}>{theme === 'light' ? '🌙' : '☀️'}</button>
}

function Logo() {
  const { theme } = useTheme()

  return <img src={theme === 'light' ? '/logo-light.svg' : '/logo-dark.svg'} alt="Logo" />
}

Marcus smiled. No more passing theme through five levels of components that don't care about it!

Story 2: The Shopping Cart Challenge

The Problem

Marcus's shopping cart data needed to be accessible everywhere:

  • Cart icon in the header (show count)
  • Product cards (add to cart button)
  • Cart sidebar (show items)
  • Checkout page (complete purchase)

The Solution: Composition with Context

// 1. Create the cart context and provider
const CartContext = createContext()

function CartProvider({ children }) {
  const [items, setItems] = useState([])

  const addItem = (product) => {
    setItems((prev) => {
      const existing = prev.find((item) => item.id === product.id)
      if (existing) {
        return prev.map((item) =>
          item.id === product.id ? { ...item, quantity: item.quantity + 1 } : item
        )
      }
      return [...prev, { ...product, quantity: 1 }]
    })
  }

  const removeItem = (productId) => {
    setItems((prev) => prev.filter((item) => item.id !== productId))
  }

  const updateQuantity = (productId, quantity) => {
    setItems((prev) => prev.map((item) => (item.id === productId ? { ...item, quantity } : item)))
  }

  const clearCart = () => setItems([])

  const total = items.reduce((sum, item) => sum + item.price * item.quantity, 0)

  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)

  const value = {
    items,
    addItem,
    removeItem,
    updateQuantity,
    clearCart,
    total,
    itemCount,
  }

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}

function useCart() {
  const context = useContext(CartContext)
  if (!context) {
    throw new Error('useCart must be used within CartProvider')
  }
  return context
}

// 2. Wrap the app with the provider
function App() {
  return (
    <CartProvider>
      <Layout>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route path="/product/:id" element={<ProductPage />} />
          <Route path="/cart" element={<CartPage />} />
          <Route path="/checkout" element={<CheckoutPage />} />
        </Routes>
      </Layout>
    </CartProvider>
  )
}

// 3. Use anywhere without prop drilling!
function CartIcon() {
  const { itemCount } = useCart()

  return (
    <div className="cart-icon">
      🛒
      {itemCount > 0 && <span className="badge">{itemCount}</span>}
    </div>
  )
}

function ProductCard({ product }) {
  const { addItem } = useCart()

  return (
    <div className="product-card">
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>
      <button onClick={() => addItem(product)}>Add to Cart</button>
    </div>
  )
}

function CartSidebar() {
  const { items, removeItem, total } = useCart()

  return (
    <aside className="cart-sidebar">
      <h2>Your Cart</h2>
      {items.map((item) => (
        <div key={item.id} className="cart-item">
          <span>
            {item.name} x {item.quantity}
          </span>
          <span>${item.price * item.quantity}</span>
          <button onClick={() => removeItem(item.id)}>Remove</button>
        </div>
      ))}
      <div className="cart-total">
        <strong>Total: ${total.toFixed(2)}</strong>
      </div>
    </aside>
  )
}

Story 3: Composing Multiple Contexts

As Marcus's app grew, he needed multiple contexts working together. Here's where composition really shined.

The Multi-Context Architecture

// Different contexts for different concerns
function App() {
  return (
    <ThemeProvider>
      <AuthProvider>
        <CartProvider>
          <NotificationProvider>
            <FeatureFlagProvider>
              <AppContent />
            </FeatureFlagProvider>
          </NotificationProvider>
        </CartProvider>
      </AuthProvider>
    </ThemeProvider>
  )
}

"That's a lot of nesting!" Marcus worried.

His colleague showed him a composition helper:

// Compose multiple providers cleanly
function AppProviders({ children }) {
  return (
    <ThemeProvider>
      <AuthProvider>
        <CartProvider>
          <NotificationProvider>
            <FeatureFlagProvider>{children}</FeatureFlagProvider>
          </NotificationProvider>
        </CartProvider>
      </AuthProvider>
    </ThemeProvider>
  )
}

// Now the app is clean again
function App() {
  return (
    <AppProviders>
      <AppContent />
    </AppProviders>
  )
}

Using Multiple Contexts Together

// Components can use multiple contexts
function ProductCard({ product }) {
  const { theme } = useTheme()
  const { user } = useAuth()
  const { addItem } = useCart()
  const { showNotification } = useNotification()
  const { isEnabled } = useFeatureFlags()

  const handleAddToCart = () => {
    addItem(product)
    showNotification({
      type: 'success',
      message: `${product.name} added to cart!`,
    })
  }

  const showQuickBuy = isEnabled('quick-buy')

  return (
    <div className={`product-card theme-${theme}`}>
      <img src={product.image} alt={product.name} />
      <h3>{product.name}</h3>
      <p>${product.price}</p>

      {user ? (
        <>
          <button onClick={handleAddToCart}>Add to Cart</button>
          {showQuickBuy && <button onClick={handleQuickCheckout}>Quick Buy</button>}
        </>
      ) : (
        <button onClick={showLoginModal}>Login to Purchase</button>
      )}
    </div>
  )
}

Complex Scenario: Building a Real-Time Collaboration System

Marcus's next challenge: build a collaborative document editor where multiple users can edit together.

The Requirements

  • Real-time updates from other users
  • Show who's currently viewing
  • Cursor positions of other users
  • Presence indicators
  • Conflict resolution

The Context Architecture

// 1. Collaboration Context
const CollaborationContext = createContext()

function CollaborationProvider({ documentId, children }) {
  const [activeUsers, setActiveUsers] = useState([])
  const [cursors, setCursors] = useState({})
  const [changes, setChanges] = useState([])
  const socketRef = useRef(null)

  useEffect(() => {
    // Connect to WebSocket
    socketRef.current = new WebSocket(`ws://api.example.com/collab/${documentId}`)

    socketRef.current.onmessage = (event) => {
      const data = JSON.parse(event.data)

      switch (data.type) {
        case 'user-joined':
          setActiveUsers((prev) => [...prev, data.user])
          break
        case 'user-left':
          setActiveUsers((prev) => prev.filter((u) => u.id !== data.userId))
          break
        case 'cursor-move':
          setCursors((prev) => ({
            ...prev,
            [data.userId]: data.position,
          }))
          break
        case 'content-change':
          setChanges((prev) => [...prev, data.change])
          break
      }
    }

    return () => socketRef.current?.close()
  }, [documentId])

  const broadcastCursor = (position) => {
    socketRef.current?.send(
      JSON.stringify({
        type: 'cursor-move',
        position,
      })
    )
  }

  const broadcastChange = (change) => {
    socketRef.current?.send(
      JSON.stringify({
        type: 'content-change',
        change,
      })
    )
  }

  const value = {
    activeUsers,
    cursors,
    changes,
    broadcastCursor,
    broadcastChange,
  }

  return <CollaborationContext.Provider value={value}>{children}</CollaborationContext.Provider>
}

function useCollaboration() {
  const context = useContext(CollaborationContext)
  if (!context) {
    throw new Error('useCollaboration must be used within CollaborationProvider')
  }
  return context
}

// 2. Document Context
const DocumentContext = createContext()

function DocumentProvider({ documentId, children }) {
  const [content, setContent] = useState('')
  const [savedState, setSavedState] = useState('')
  const [isSaving, setIsSaving] = useState(false)
  const { broadcastChange } = useCollaboration()

  const updateContent = (newContent) => {
    setContent(newContent)
    broadcastChange({
      timestamp: Date.now(),
      content: newContent,
    })
  }

  const saveDocument = async () => {
    setIsSaving(true)
    try {
      await fetch(`/api/documents/${documentId}`, {
        method: 'PUT',
        body: JSON.stringify({ content }),
      })
      setSavedState(content)
    } finally {
      setIsSaving(false)
    }
  }

  const hasUnsavedChanges = content !== savedState

  const value = {
    content,
    updateContent,
    saveDocument,
    isSaving,
    hasUnsavedChanges,
  }

  return <DocumentContext.Provider value={value}>{children}</DocumentContext.Provider>
}

function useDocument() {
  const context = useContext(DocumentContext)
  if (!context) {
    throw new Error('useDocument must be used within DocumentProvider')
  }
  return context
}

// 3. Composing it all together
function CollaborativeEditor({ documentId }) {
  return (
    <CollaborationProvider documentId={documentId}>
      <DocumentProvider documentId={documentId}>
        <EditorLayout>
          <EditorHeader />
          <EditorContent />
          <EditorSidebar />
          <EditorFooter />
        </EditorLayout>
      </DocumentProvider>
    </CollaborationProvider>
  )
}

// 4. Components using the contexts
function EditorHeader() {
  const { activeUsers } = useCollaboration()
  const { saveDocument, isSaving, hasUnsavedChanges } = useDocument()

  return (
    <header className="editor-header">
      <h1>Collaborative Document</h1>

      <div className="active-users">
        {activeUsers.map((user) => (
          <Avatar key={user.id} user={user} />
        ))}
      </div>

      <button onClick={saveDocument} disabled={!hasUnsavedChanges || isSaving}>
        {isSaving ? 'Saving...' : 'Save'}
      </button>
    </header>
  )
}

function EditorContent() {
  const { content, updateContent } = useDocument()
  const { cursors, broadcastCursor } = useCollaboration()

  const handleChange = (e) => {
    updateContent(e.target.value)
  }

  const handleMouseMove = (e) => {
    const selection = window.getSelection()
    if (selection) {
      broadcastCursor({
        line: getLineNumber(selection),
        column: getColumnNumber(selection),
      })
    }
  }

  return (
    <div className="editor-content" onMouseMove={handleMouseMove}>
      <textarea value={content} onChange={handleChange} placeholder="Start typing..." />

      {/* Show other users' cursors */}
      {Object.entries(cursors).map(([userId, position]) => (
        <Cursor key={userId} userId={userId} position={position} />
      ))}
    </div>
  )
}

function EditorSidebar() {
  const { changes } = useCollaboration()

  return (
    <aside className="editor-sidebar">
      <h3>Recent Changes</h3>
      <ul>
        {changes
          .slice(-10)
          .reverse()
          .map((change, i) => (
            <li key={i}>{formatTimestamp(change.timestamp)}</li>
          ))}
      </ul>
    </aside>
  )
}

The Lessons Marcus Learned

1. Context Eliminates Prop Drilling

Before:

<App data={data}>
  <Level1 data={data}>
    <Level2 data={data}>
      <Level3 data={data}>
        <Level4 data={data}>
          <ComponentThatActuallyNeedsData data={data} />
        </Level4>
      </Level3>
    </Level2>
  </Level1>
</App>

After:

<DataProvider>
  <App>
    <Level1>
      <Level2>
        <Level3>
          <Level4>
            <ComponentThatActuallyNeedsData />
            {/* Uses useData() hook */}
          </Level4>
        </Level3>
      </Level2>
    </Level1>
  </App>
</DataProvider>

2. Composition Makes Context Flexible

Context + Composition lets you:

  • Mix and match: Compose providers in any order
  • Scope contexts: Use providers at different levels
  • Reuse logic: Same context in different parts of the app
// Global theme
<ThemeProvider>
  <App />
</ThemeProvider>

// Scoped cart per route
<Route path="/shop" element={
  <CartProvider>
    <ShopPage />
  </CartProvider>
} />

<Route path="/rental" element={
  <CartProvider>
    <RentalPage />
  </CartProvider>
} />

3. Custom Hooks Make Consumption Clean

// Instead of this everywhere:
const context = useContext(ThemeContext)
if (!context) throw new Error('...')

// Create this once:
function useTheme() {
  const context = useContext(ThemeContext)
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider')
  }
  return context
}

// Use it everywhere:
const { theme, toggleTheme } = useTheme()

4. Context + Composition = Scalable Architecture

Marcus's final architecture looked like this:

function App() {
  return (
    <AppProviders>
      <Router>
        <Routes>
          <Route path="/" element={<HomePage />} />
          <Route
            path="/shop"
            element={
              <ShopProviders>
                <ShopPage />
              </ShopProviders>
            }
          />
          <Route
            path="/collab/:id"
            element={
              <CollabProviders>
                <EditorPage />
              </CollabProviders>
            }
          />
        </Routes>
      </Router>
    </AppProviders>
  )
}

Each section had its own composed providers, but they all shared the global ones. Clean, scalable, and easy to understand.

Best Practices Marcus Discovered

1. Keep Contexts Focused

Don't do this:

// God Context - too much responsibility
const AppContext = createContext()

function AppProvider({ children }) {
  const [user, setUser] = useState(null)
  const [theme, setTheme] = useState('light')
  const [cart, setCart] = useState([])
  const [notifications, setNotifications] = useState([])
  // ... 20 more states
}

Do this:

// Separate contexts for separate concerns
<AuthProvider>
  <ThemeProvider>
    <CartProvider>
      <NotificationProvider>{children}</NotificationProvider>
    </CartProvider>
  </ThemeProvider>
</AuthProvider>

2. Memoize Context Values

function CartProvider({ children }) {
  const [items, setItems] = useState([])

  // Memoize to prevent unnecessary re-renders
  const value = useMemo(
    () => ({
      items,
      addItem: (item) => setItems((prev) => [...prev, item]),
      removeItem: (id) => setItems((prev) => prev.filter((i) => i.id !== id)),
    }),
    [items]
  )

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}

3. Provide Default Values

const ThemeContext = createContext({
  theme: 'light',
  setTheme: () => console.warn('setTheme called outside provider'),
})

4. Combine with Other Patterns

// Context + Reducer for complex state
function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState)

  const value = {
    ...state,
    dispatch,
  }

  return <CartContext.Provider value={value}>{children}</CartContext.Provider>
}

// Context + Async data
function DataProvider({ children }) {
  const [data, setData] = useState(null)
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetchData()
      .then(setData)
      .finally(() => setLoading(false))
  }, [])

  if (loading) return <LoadingSpinner />

  return <DataContext.Provider value={data}>{children}</DataContext.Provider>
}

The Transformation

Marcus's codebase went from this:

// Prop drilling nightmare
<Component1 user={user} theme={theme} cart={cart} data={data}>
  <Component2 user={user} theme={theme} cart={cart} data={data}>
    <Component3 theme={theme} cart={cart}>
      <Component4 theme={theme}>
        <Component5 theme={theme} />
      </Component4>
    </Component3>
  </Component2>
</Component1>

To this:

// Clean composition with context
<AppProviders>
  <Component1>
    <Component2>
      <Component3>
        <Component4>
          <Component5 />
        </Component4>
      </Component3>
    </Component2>
  </Component1>
</AppProviders>

Each component simply grabbed what it needed with hooks. No more prop drilling. No more passing data through components that don't care about it.


"Context is the radio tower. Composition is the antenna. Together, they let your components tune into exactly what they need, when they need it." - Marcus's revelation on building scalable React applications