DEV Community

Cover image for Building Reusable UI in React: Compound Components, Render Props, and API Design
Ward Khaddour
Ward Khaddour

Posted on • Originally published at Medium

Building Reusable UI in React: Compound Components, Render Props, and API Design

Have you ever built a React component that started clean — and a couple of days later had a billion props, conflicting booleans, and a README no one trusts?

Each new boolean doubles the number of possible UI states. By the time you have six booleans, you’re supporting 64 UI variants — most of them undocumented.

Reusable UI is not about writing less code — it’s about designing APIs that survive change.

Most React components don’t fail because of bugs; they fail because their APIs collapse under real-world requirements.

This article is for developers building shared components, design systems, or complex UI primitives.

We’ll walk through how and why to build reusable UI using:

  • Compound Components

  • Render Props

  • A real Accordion implementation

  • Trade-offs, alternatives, and improvements

The Core Problem: Prop-Driven APIs Don’t Scale

Most components start with something like this:

    const [activeItems, setActiveItems] = useState<string[]>([])

    const toggleItem = (id: string)=> { ... }

    <Accordion
      activeItems={activeItems}
      onToggle={toggleItem}
      allowMultiple
      showIcon
      className='...'
      classNames={{
        header:'...',
        body:'...',
        icon:'...',
      }}
    />
Enter fullscreen mode Exit fullscreen mode

At first, it feels fine, then requirements grow:

  • Different layouts

  • Custom headers

  • Conditional behavior

  • Design system constraints

Suddenly:

  • Props explode
    Accordion
     ├── activeItems
     ├── allowMultiple
     ├── showIcon
     ├── animated
     ├── collapsible
     ├── headerLayout
     └── onToggle
            ↓
       Interdependent logic
       spread across consumers
Enter fullscreen mode Exit fullscreen mode

Each prop looks innocent in isolation, but together they create hidden coupling and undocumented behavior.

  • Usually, the logic is spread across the component’s internals, while the consumer has to manage the state. So the component becomes a “black box” that is hard to tweak.

  • Small changes break everything.

The root issue: you’re encoding layout and behavior into props.

We’ll refactor this into a compound component by the end.

The Mental Model Shift

Before writing code, we need the right mental model.

Old model (Prop-Driven)

“I configure a component.”

  • Component dictates layout.

  • Limited to predefined props.

  • Increases complexity with every feature.

New model (Composition-Driven)

“I assemble behavior from parts.”

  • Consumer dictates layout.

  • High flexibility with wrapping and reordering elements.

  • Complexity stays flat; parts are isolated.

Instead of telling a component what it should look like, we give it state and rules, and let the consumer decide the structure. This is where Compound Components and Render Props shine.

This shift changes how you design components — not just how you implement them.

What Are Compound Components?

Compound Components allow you to expose a set of related components that:

  • Share state implicitly

  • Work only inside a specific parent

  • Form a declarative, readable API

This feels natural because it mirrors how we already think about HTML: select /option and similar HTML pairs, behave like compound components.

Example usage:

    <Accordion>
      <Accordion.Item itemId="one">
        <Accordion.Header>Title</Accordion.Header>
        <Accordion.Body>Content</Accordion.Body>
      </Accordion.Item>
    </Accordion>
Enter fullscreen mode Exit fullscreen mode
  • No prop drilling.

  • No configuration hell.

  • The structure explains itself.

The Mental Model of Compound Components

Think of it like this:

    Accordion (state owner)
     ├── Item (scopes state)
     │    ├── Header (reads + triggers)
     │    └── Body (reads)
Enter fullscreen mode Exit fullscreen mode

State flows down, events flow up — but only within a narrow scope.

  • Accordion owns the global state

  • Item narrows that state to a single item

  • Header / Body consume only what they need

Each layer reduces responsibility and knows less — that’s the point, and each component can be understood in isolation.

This is what enables compound components to be maintainable at scale.


Building the Accordion from Scratch

1. State Design

    const [activeItems, setActiveItems] = useState<Set<string>>(...);

    const toggleItem = useCallback(
       (itemId: string) => {
         if (allowMultiple) {
           setActiveItems(prev => {
             const newItems = new Set(prev);
             if (newItems.has(itemId)) {
               newItems.delete(itemId);
             } else {
               newItems.add(itemId);
             }
             return newItems;
           });

           return;
         }

         setActiveItems(prev =>
           prev.has(itemId) ? new Set([]) : new Set([itemId])
         );
       },
       [allowMultiple]
     );
Enter fullscreen mode Exit fullscreen mode

The accordion state needs to answer one question efficiently: Is this item open?

Why Set?

  • O(1) lookups

  • Prevents duplicate IDs automatically

  • Natural fit for “multiple open items.”

  • Clear semantic intent

This decision alone enables reuse across multiple UX patterns — and makes it easy to support:

  • Both controlled and uncontrolled usage.

  • Both single and multiple open items.

    This example focuses on uncontrolled usage; a controlled version would accept value and onChange and derive activeItems externally.

2. Context as an Internal Contract

type AccordionContextValue = {
  activeItems: Set<string>;
  toggleItem: (id: string) => void;
};
Enter fullscreen mode Exit fullscreen mode

Context is used internally — not as a public API. Developers should never need to think about context to use your component.

    Public API
    -------------------
    <Accordion>
      <Accordion.Item />
    </Accordion>

    Internal API
    ----------------------
    AccordionContext
      ├── activeItems
      └── toggleItem
Enter fullscreen mode Exit fullscreen mode

Consumers depend on components, not on how those components work internally.

If consumers depend on your context shape, you’ve leaked your internals.
Compound components enable you to update everything behind the scenes without breaking the consumers.

This is key:

Consumers compose — they don’t manage state.

3. Creating contexts

First, let’s create the contexts

    import { createContext, useContext } from 'react';

    // Accordion Context
    type AccordionContextValue = {
      allowMultiple?: boolean;
      activeItems: Set<string>;
      toggleItem: (id: string) => void;
    };

    export const AccordionContext = createContext<AccordionContextValue | null>(
      null
    );

    export const useAccordion = () => {
      const accordionContext = useContext(AccordionContext);
      if (!accordionContext) {
        throw new Error('useAccordion must be used within <Accordion />');
      }

      return accordionContext;
    };

    // Accordion Item Context
    type AccordionItemContextValue = {
      id: string;
      isActive: boolean;
    };

    export const AccordionItemContext =
      createContext<AccordionItemContextValue | null>(null);

    export const useAccordionItem = () => {
      const accordionItemContext = useContext(AccordionItemContext);
      if (!accordionItemContext) {
        throw new Error('useAccordionItem must be used within <AccordionItem />');
      }

      return accordionItemContext;
    };
Enter fullscreen mode Exit fullscreen mode

Using AccordionContext.Provider

    const getInitialValue = (defaultValue?: string | string[]): string[] => {
      if (!defaultValue) return [];
      if (Array.isArray(defaultValue)) return defaultValue;

      return [defaultValue];
    };

    function Accordion({ children, allowMultiple, defaultValue }: Props) {
      const [activeItems, setActiveItems] = useState<Set<string>>(
        () => new Set(getInitialValue(defaultValue))
      );

      const toggleItem = useCallback(
        (itemId: string) => {
          if (allowMultiple) {
            setActiveItems(prev => {
              const newItems = new Set(prev);
              if (newItems.has(itemId)) {
                newItems.delete(itemId);
              } else {
                newItems.add(itemId);
              }
              return newItems;
            });

            return;
          }
          setActiveItems(prev =>
            prev.has(itemId) ? new Set([]) : new Set([itemId])
          );
        },
        [allowMultiple]
      );

      const value = useMemo(
        () => ({ allowMultiple, activeItems, toggleItem }),
        [allowMultiple, activeItems, toggleItem]
      );

      return (
        <AccordionContext.Provider value={value}>
          {children}
        </AccordionContext.Provider>
      );
    }
Enter fullscreen mode Exit fullscreen mode

4. Scoping with Accordion.Item

    type ItemProps = {
      children: Children<{ isActive: boolean }>;
      itemId: string;
    };

    function AccordionItem({ children, itemId }: ItemProps) {
      const { activeItems } = useAccordion();

      const isActive = activeItems.has(itemId);

      const { element } = renderChildren(children, { isActive });

      return (
        <AccordionItemContext.Provider value={{ id: itemId, isActive }}>
          {element}
        </AccordionItemContext.Provider>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Why a second context?

Because the Header and Body should not care about the entire accordion.
They only care about this item.

  • Headers don’t know about other items, and don’t need to.

  • Bodies don’t know how toggling works.

  • This prevents abstraction leakage.

Without this layer, every subcomponent would need to understand the global accordion state.
That’s how abstractions leak — and complexity spreads.

This dramatically reduces coupling.

5. Render Props — Without Forcing Them

Render Props aren’t outdated — they’re just specialized.
They shine when you want escape hatches without committing to new APIs.

    <Accordion.Header>
      {({ isActive }) => <button>{isActive ? 'Open' : 'Closed'}</button>}
    </Accordion.Header>
Enter fullscreen mode Exit fullscreen mode

And:

    <Accordion.Header>Title</Accordion.Header>
Enter fullscreen mode Exit fullscreen mode

Internally, Accordion.Header checks whether children is a function and calls it if so.

This hybrid approach is powerful because:

  • Beginners get clean JSX

  • Advanced users get full control

  • No API duplication

How does that work? With a simple utility, we can support Render Prop while preserving normal children.

    export type Children<T> = React.ReactNode | ((props: T) => React.ReactNode);


    export const renderChildren = <T>(children: Children<T>, props: T) => {
      if (typeof children === 'function')
        return {
          element: children(props),
          isRenderProp: true,
        };
      return {
        element: children,
        isRenderProp: false,
      };
    };
Enter fullscreen mode Exit fullscreen mode
  • This avoids two APIs.

  • Consumers opt in to power only when needed.

  • Default usage stays clean.

Usage in Accordion.Header :

    type HeaderProps = {
      children: Children<{ isActive: boolean; onClick: () => void }>;
      className?: string;
    };

    function AccordionHeader({ children, className }: HeaderProps) {
      const { isActive, id } = useAccordionItem();
      const { toggleItem } = useAccordion();
      const handleToggle = useCallback(() => toggleItem(id), [id, toggleItem]);
      const { element, isRenderProp } = renderChildren(children, {
        isActive,
        onClick: handleToggle,
      });
      if (isRenderProp) return element;

      return (
        <button
          onClick={handleToggle}
          type="button" 
          aria-expanded={isActive}
          aria-controls={`panel-${id}`}
          id={`button-${id}`}
          className={clsx(
            'flex items-center justify-between w-full cursor-pointer px-3 py-2',
            !isActive && 'border-b border-gray-400',
            className
          )}
        >
          {element}

          <ChevronDown
            className={clsx('transition-all  h-5 w-5', isActive && 'rotate-180')}
          />
        </button>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Note that using Render Prop gives “Total Control” to the user, meaning the user is responsible for the interaction element.

clsx is a small utility for constructing className strings conditionally.

Consider adding aria- attributes for better accessibility (A11y), and type='button' to prevent form submission.

You should also handle keyboard navigation (Arrow keys, Home/End).

  • We didn’t add a new prop.

  • We didn’t branch the API.

  • We didn’t break existing users.

    Render Props are just specialized.

6. The Body: Consuming the State

    type BodyProps = {
      children: Children<{ isActive: boolean }>;
    };
    function AccordionBody({ children }: BodyProps) {
      const { isActive, id } = useAccordionItem();
      const { element } = renderChildren(children, { isActive });

      return (
        <div
          id={`panel-${id}`}
          aria-labelledby={`button-${id}`}
          className={clsx(
            'overflow-hidden',
            isActive ? 'h-fit border-b border-gray-400' : 'h-0'
          )}
        >
          {element}
        </div>
      );
    }
Enter fullscreen mode Exit fullscreen mode

The body consumes the isActive state to decide whether to collapse or show content.

7. Wrapping it all together

After building each component Accordion Root, Item, Header, Body We can export each component alone, or attach them to the Root component and export it alone with simple lines

    Accordion.Item = AccordionItem;
    Accordion.Header = AccordionHeader;
    Accordion.Body = AccordionBody;

    export { Accordion };
Enter fullscreen mode Exit fullscreen mode

The nice thing about this implementation:

  • You import a single component and use all its sub-components.

  • TypeScript understands it well, and you get full type-safe components.

Example Usage

So far, we’ve talked about principles and partial snippets.
Let’s ground this in an example:

    import { Accordion } from './components/accordion';
    import { FAQs } from './data/faqs';

    function App() {
      return (
        <Accordion allowMultiple>
          {FAQs.map(faq => (
            <Accordion.Item key={faq.id} itemId={faq.id}>
              <Accordion.Header>{faq.question}</Accordion.Header>
              <Accordion.Body>{faq.answer}</Accordion.Body>
            </Accordion.Item>
          ))}
        </Accordion>
      );
    }

    export default App;
Enter fullscreen mode Exit fullscreen mode

Another example use-case

  1. Building a Modal component with Open and Close components > These examples illustrate the pattern — not complete implementations.
    function App() {
      return (
        <Modal>
          <Modal.Window name="window-1">{...}</Modal.Window>
          <Modal.Open name="window-1">
            <Button>Open Modal</Button>
          </Modal.Open>
          <Modal.Close name="window-1">
            <Button>Close Modal</Button>
          </Modal.Close>
        </Modal>
      );
    }
Enter fullscreen mode Exit fullscreen mode

What’s powerful here is that

  • You can render the window in one component and open or close it from anywhere inside the Modal context.

  • You can export a hook useModal and control the modal state programmatically.

function App() {
  const { openWindow, closeWindow } = useModal();

  const someHandler = () => {
    // Do some logic

    openWindow('window-1');
   };
}
Enter fullscreen mode Exit fullscreen mode

Notice how the mental model is identical: One state owner. Many declarative consumers.

2. Building a File input component with Trigger, Preview, Dropzone, and even Upload Error components

Building a FileInput Component is a nightmare with standard props, especially if you need to include dropzone, preview, and remove files. And it becomes unmaintainable if the preview layout needs to vary across the app.

See this example with Compound Components and Render Prop.

Here we can see how the Render Prop shines.

    function App() {
      return (
        <FileInput>
          <FileInput.Trigger>
            <Button>Upload Image</Button>
          </FileInput.Trigger>

          <FileInput.Dropzone>{/**
           * Add a styled dropzone
           * You can add The `FileInput.Trigger` here, it's OK!
           */}
          </FileInput.Dropzone>

          <FileInput.Preview>
            {(files, removeFile) => (
              <div>
                {/**
                 * Control how each file is displayed and styled
                 * Add delete button any where you want
                 */}
              </div>
            )}
          </FileInput.Preview>
        </FileInput>
      );
    }
Enter fullscreen mode Exit fullscreen mode

Improving the Accordion: Performance & Architecture

At this point, the API is already solid. These improvements are about scale, not correctness, and matter in libraries — not in every app.
Don’t cargo-cult them.

Improvement 1: Split Contexts for Fewer Re-renders

Currently:

  • Any change in activeItems re-renders all consumers

We can split contexts:

    type AccordionValue = {
      allowMultiple?: boolean;
      activeItems: Set<string>;
    };
    const AccordionStateContext = createContext<AccordionValue | null>(null);


    type AccordionActions = { toggleItem: (id: string) => void };
    const AccordionActionsContext = createContext<AccordionActions | null>(null);
Enter fullscreen mode Exit fullscreen mode

Now:

  • This can reduce unnecessary re-renders when combined with memoization.

  • Actions can be kept stable.

These optimizations matter most when your components are widely reused, or if you have 100 accordion items open at once, for example.

Warning: This adds complexity — measure before optimizing.

Improvement 2: Extract a Headless Hook

    function useAccordionState({ defaultActive, allowMultiple }) {
      // state + logic only
    }
Enter fullscreen mode Exit fullscreen mode

Benefits:

  • Testable without UI

  • Reusable in different components

  • Cleaner separation of concerns

This turns the Accordion into a headless + styled hybrid

Improvement 3: Memoize Subcomponents Strategically

    const AccordionHeader = memo(function AccordionHeader(...) { ... });
Enter fullscreen mode Exit fullscreen mode

Not everywhere — only where re-renders are costly.

Trade-offs

Pros

  • Extremely flexible API.

  • Clear separation of concerns.

  • Scales well in design systems.

  • Excellent developer experience.

  • Simplifying parts of Accessibility (A11y), because we control the id in the Accordion.Item context, we can automatically link aria-controls and aria-labelledby without the consumer ever having to manually pass IDs.

Cons

  • More code upfront.

  • Potential context-based re-render cost.

  • Harder debugging than simple prop-based components.

  • Not suitable for trivial components.

When Should You Use This Pattern?

Use Compound Components when:

  • Layout varies

  • Consumers need composition freedom

  • You’re building a shared UI library

  • API longevity matters

Avoid them when:

  • The component is simple

  • Performance is extremely sensitive

  • There’s only one valid layout

Limits of Compound Components

Compound components are powerful — but they have limits. Understanding them is crucial.

  1. Too much freedom can hurt consistency.

Consumers can reorder or omit subcomponents, breaking UX or accessibility. Design systems must define safe composition boundaries.

Unlike props, requirements (e.g., which subcomponents are required) aren’t explicit. Clear documentation is essential.

Even TypeScript cannot fully enforce:

  • Correct composition order

  • Required subcomponents

  • Semantic correctness

2. Debugging & performance
Logic is spread across context, hooks, and Render Props, which can make bugs hard to trace. Heavy children or large lists may cause unexpected re-renders.

3. Not every component deserves it

The biggest mistake is overusing compound components. They are a poor fit when:

  • The layout is fixed

  • There’s only one valid structure

  • The component is trivial

  • The API is unlikely to evolve

For example:

  • Buttons

  • Icons

  • Badges

  • Avatars

In these cases, compound components add:

  • Unnecessary abstraction

  • More files

  • Harder onboarding

Avoid them for trivial or fixed-layout components — here, simple props are simpler and safer.

In short: compound components trade explicit configuration for compositional flexibility. Use deliberately, not by default.

Final Thought

Compound Components and Render Props are tools — not goals.

Reusable UI is not about clever or trendy patterns; it’s about respecting change.

The compound components pattern is a very powerful pattern, used in many UI libraries, like:

You can find the full Accordion implementation on GitHub.

If this article was useful, consider starring the repo and connecting with me on LinkedIn.

Top comments (0)