Advanced Patterns in React: When to Use Compound Components, Render Props, or Custom Hooks

By Adam Hultman

ReactReact Patterns
Advanced Patterns in React: When to Use Compound Components, Render Props, or Custom Hooks

As you build more complex React applications, the need for reusable, flexible components becomes more important. Patterns like compound components, render props, and custom hooks allow you to create components that are powerful and adaptable, but they come with their own sets of challenges. In this post, we’ll explore each pattern in depth, discuss when to use them, and show you how to refactor between them as your app evolves.


What Are Compound Components, Render Props, and Custom Hooks?

Before we get into the details, let’s define these three patterns:

  • Compound Components: A pattern where a group of components work together to manage state or behavior. The parent component provides context, and child components can be used as building blocks to create flexible UIs.
  • Render Props: A technique where a component accepts a function as a prop, which returns a React element. It allows you to share stateful logic between components.
  • Custom Hooks: Functions that let you extract and reuse stateful logic across components. They leverage React’s useState, useEffect, and other hooks to encapsulate complex behaviors.

Each pattern has its place, but understanding when to use them is key to building maintainable React applications.


When to Use Compound Components

Compound components shine when you have multiple components that need to work together and share context, but you still want to provide flexibility to the user of the component. A classic example is building a form with multiple inputs and controls:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 // Accordion.js import React, { createContext, useContext, useState } from 'react'; const AccordionContext = createContext(); function Accordion({ children }) { const [openIndex, setOpenIndex] = useState(0); return ( <AccordionContext.Provider value={{ openIndex, setOpenIndex }}> <div className="accordion">{children}</div> </AccordionContext.Provider> ); } function AccordionItem({ index, children }) { const { openIndex, setOpenIndex } = useContext(AccordionContext); const isOpen = index === openIndex; return ( <div className="accordion-item"> <div className="accordion-header" onClick={() => setOpenIndex(index)}> {isOpen ? '▼' : '▶'} {children.header} </div> {isOpen && <div className="accordion-body">{children.body}</div>} </div> ); } Accordion.Item = AccordionItem; export default Accordion;

With this setup, you can use the Accordion like this:

1 2 3 <Accordion> <Accordion.Item index={0} header="Item 1" body="This is the content of Item 1" /><Accordion.Item index={1} header="Item 2" body="This is the content of Item 2" /> </Accordion>

Why Use Compound Components?

  • Flexibility: It allows users to control the layout of child components while maintaining shared behavior.
  • Readability: The parent component provides context, making it easier to understand how the components interact.
  • Avoids Prop Drilling: Shared state is managed through context, which simplifies communication between components.

When to Avoid Compound Components:

  • They can become overly complex if too many props or state values are shared.
  • They may introduce unnecessary re-renders if not optimized correctly with React.memo or context selector patterns.

When to Use Render Props

The render props pattern is great for sharing logic between components without relying on context or higher-order components (HOCs). It allows you to define the UI structure in the parent component but still share stateful logic with the child component.

Here’s an example using render props to share hover state:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 // Hover.js import React, { useState } from 'react'; function Hover({ children }) { const [isHovered, setIsHovered] = useState(false); return children({ isHovered, onMouseEnter: () => setIsHovered(true), onMouseLeave: () => setIsHovered(false) }); } export default Hover;

Using the Hover component:

1 2 3 4 5 6 7 <Hover> {({ isHovered, onMouseEnter, onMouseLeave }) => ( <div onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> {isHovered ? '👀 Hovering!' : '❓ Not hovering'} </div> )} </Hover>

Why Use Render Props?

  • Reusability: You can share logic without dictating how the UI should look.
  • Flexibility: It gives complete control over rendering to the parent component.
  • No Context Needed: Unlike compound components, you don’t need to rely on React.createContext.

When to Avoid Render Props:

  • They can result in deeply nested code, making components harder to read.
  • Frequent changes to the render function can cause performance issues due to re-renders.

When to Use Custom Hooks

Custom hooks are the new kids on the block but have quickly become a favorite among developers. They allow you to extract and reuse logic without having to worry about component structure.

Here’s an example of a custom hook for handling form input state:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 // useForm.js import { useState } from 'react'; export function useForm(initialValues) { const [values, setValues] = useState(initialValues); const handleChange = (e) => { const { name, value } = e.target; setValues({ ...values, [name]: value, }); }; return { values, handleChange, }; }

Using the useForm hook in a component:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 import { useForm } from './useForm'; function LoginForm() { const { values, handleChange } = useForm({ username: '', password: '' }); return ( <form> <input name="username" value={values.username} onChange={handleChange} placeholder="Username" /> <input name="password" type="password" value={values.password} onChange={handleChange} placeholder="Password" /> </form> ); }

Why Use Custom Hooks?

  • Encapsulation: They allow you to bundle stateful logic into a single function.
  • Reusability: Extract logic like data fetching, form handling, or subscriptions into a hook and reuse it across components.
  • Cleaner Components: Components become smaller and more focused on UI when the logic is extracted into hooks.

When to Avoid Custom Hooks:

  • Overusing hooks for simple logic can make code harder to follow.
  • They can lead to too much abstraction, making debugging more difficult if the hooks themselves become complex.

Performance Considerations

Each pattern has unique performance characteristics:

  • Compound Components: Be aware of unnecessary re-renders caused by context updates. Use React.memo or context selectors to optimize.
  • Render Props: Since render props rely on functions being passed down, changes to these functions can trigger re-renders. Use useCallback to memoize these functions where possible.
  • Custom Hooks: While hooks themselves don’t cause performance issues, you should be careful with how often you update state within them. For example, avoid creating new instances of functions inside useEffect without dependencies.

Refactoring Between Patterns

As your app evolves, you may find that a pattern that worked initially is no longer ideal. Here’s how to decide when to refactor:

  • From Compound Components to Custom Hooks: If you find that the shared state logic in a compound component is useful elsewhere, consider refactoring that logic into a custom hook.
  • From Render Props to Custom Hooks: If you’re using render props primarily for logic and don’t need to pass down complex UI structures, refactoring to a custom hook can simplify your component tree.
  • From Custom Hooks to Compound Components: If you find yourself constantly passing the same props and callbacks through multiple components, a compound component can encapsulate that logic and make the usage simpler.

Choosing the right pattern in React is all about balancing flexibility, simplicity, and maintainability. Compound components provide a way to build highly flexible, context-driven UIs. Render props are great for sharing logic without dictating UI structure. Custom hooks allow you to reuse logic cleanly across components.

Each pattern has its strengths and weaknesses, and the key is understanding when each one is most appropriate. By mastering these patterns, you can build React applications that are not just functional but elegant and maintainable as they grow. Happy coding!


© 2024 Adam Hultman