Chapter 09 - Unlock UI Flexibility: Compound Components, the Lego Bricks of Interface Design

Compound components: flexible UI building blocks. Break complex components into smaller, focused parts. Intuitive APIs, reusable code, and easier state management. Enhances accessibility and testing. Powerful pattern for maintainable UIs.

Chapter 09 - Unlock UI Flexibility: Compound Components, the Lego Bricks of Interface Design

Compound components are like Lego bricks for your UI. They give you the power to build flexible, reusable pieces that snap together in endless ways. Instead of one big, monolithic component that tries to do everything, you break things down into smaller, focused parts that work together seamlessly.

Think of a form. Instead of cramming all the logic and markup into a single <Form> component, you’d have separate pieces like <Form.Input>, <Form.Label>, and <Form.Submit>. Each part knows how to play nice with the others, but they’re independent enough to be used however you need.

This pattern is a game-changer for creating intuitive APIs. Users of your components get a natural, declarative way to compose interfaces. It’s like giving them a box of perfectly compatible puzzle pieces – they can arrange them however they want, but everything just fits.

I remember the first time I really “got” compound components. I was struggling with a complex dropdown menu, trying to make it flexible enough for different use cases. The moment I switched to a compound approach, it was like a light bulb went off. Suddenly, I could handle all sorts of variations without my code turning into spaghetti.

Let’s look at a simple example in React:

function Dropdown({ children }) {
  const [isOpen, setIsOpen] = React.useState(false);
  return (
    <DropdownContext.Provider value={{ isOpen, setIsOpen }}>
      {children}
    </DropdownContext.Provider>
  );
}

Dropdown.Toggle = function DropdownToggle() {
  const { isOpen, setIsOpen } = React.useContext(DropdownContext);
  return (
    <button onClick={() => setIsOpen(!isOpen)}>
      {isOpen ? 'Close' : 'Open'}
    </button>
  );
};

Dropdown.Menu = function DropdownMenu({ children }) {
  const { isOpen } = React.useContext(DropdownContext);
  return isOpen ? <div>{children}</div> : null;
};

Dropdown.Item = function DropdownItem({ children }) {
  return <div>{children}</div>;
};

Now, users can put together dropdowns like this:

<Dropdown>
  <Dropdown.Toggle />
  <Dropdown.Menu>
    <Dropdown.Item>Option 1</Dropdown.Item>
    <Dropdown.Item>Option 2</Dropdown.Item>
  </Dropdown.Menu>
</Dropdown>

It’s clean, it’s intuitive, and it’s flexible. Want to add a custom toggle? No problem. Need to insert something between the toggle and menu? Go for it.

This pattern isn’t just for React, though. You can apply the same principles in Vue, Angular, or even vanilla JavaScript. The key is thinking in terms of small, focused pieces that work together.

One of the coolest things about compound components is how they handle state. Instead of prop drilling or lifting state up constantly, you can use context (in React) or similar mechanisms to share state between the pieces. This keeps things encapsulated and makes your components way easier to reason about.

I’ve used this approach on several projects now, and it’s always a hit with the team. It makes our components more maintainable and easier to extend. Plus, new developers can pick it up quickly – the structure is intuitive once you see it in action.

Of course, like any pattern, it’s not a silver bullet. Sometimes a simple, single component is all you need. And for very complex widgets, you might need a mix of approaches. The key is knowing when to reach for compound components in your toolbox.

One thing I love about this pattern is how it encourages good component design. When you’re building compound components, you naturally start thinking about the different “atoms” that make up your interface. This often leads to more reusable, focused pieces that you can mix and match in ways you didn’t initially expect.

Let’s dive a bit deeper with another example. Imagine we’re building a custom select component:

function Select({ children, onChange }) {
  const [selectedValue, setSelectedValue] = React.useState(null);
  const [isOpen, setIsOpen] = React.useState(false);

  const selectContext = {
    selectedValue,
    setSelectedValue,
    isOpen,
    setIsOpen,
    onChange
  };

  return (
    <SelectContext.Provider value={selectContext}>
      {children}
    </SelectContext.Provider>
  );
}

Select.Trigger = function SelectTrigger() {
  const { selectedValue, isOpen, setIsOpen } = React.useContext(SelectContext);
  return (
    <button onClick={() => setIsOpen(!isOpen)}>
      {selectedValue || 'Select an option'}
    </button>
  );
};

Select.Options = function SelectOptions({ children }) {
  const { isOpen } = React.useContext(SelectContext);
  return isOpen ? <div>{children}</div> : null;
};

Select.Option = function SelectOption({ value, children }) {
  const { setSelectedValue, setIsOpen, onChange } = React.useContext(SelectContext);
  return (
    <div
      onClick={() => {
        setSelectedValue(value);
        setIsOpen(false);
        onChange(value);
      }}
    >
      {children}
    </div>
  );
};

Now we can use it like this:

<Select onChange={(value) => console.log('Selected:', value)}>
  <Select.Trigger />
  <Select.Options>
    <Select.Option value="apple">Apple</Select.Option>
    <Select.Option value="banana">Banana</Select.Option>
    <Select.Option value="cherry">Cherry</Select.Option>
  </Select.Options>
</Select>

This approach gives us so much flexibility. Want to add a custom trigger? Easy. Need to group options or add dividers? No problem. The compound component pattern makes these kinds of customizations a breeze.

One thing I’ve learned from using this pattern is the importance of good documentation. When you’re creating a library of compound components, it’s crucial to explain not just how each piece works, but how they work together. I like to include lots of examples showing different configurations and use cases.

Accessibility is another area where compound components really shine. By breaking things down into logical pieces, it’s easier to ensure that each part has the right ARIA attributes and keyboard interactions. You can bake good accessibility practices right into the individual components.

Let’s enhance our Select component with some basic accessibility features:

Select.Trigger = function SelectTrigger() {
  const { selectedValue, isOpen, setIsOpen } = React.useContext(SelectContext);
  return (
    <button 
      onClick={() => setIsOpen(!isOpen)}
      aria-haspopup="listbox"
      aria-expanded={isOpen}
    >
      {selectedValue || 'Select an option'}
    </button>
  );
};

Select.Options = function SelectOptions({ children }) {
  const { isOpen } = React.useContext(SelectContext);
  return isOpen ? (
    <div role="listbox">
      {children}
    </div>
  ) : null;
};

Select.Option = function SelectOption({ value, children }) {
  const { setSelectedValue, setIsOpen, onChange, selectedValue } = React.useContext(SelectContext);
  const isSelected = value === selectedValue;
  return (
    <div
      role="option"
      aria-selected={isSelected}
      onClick={() => {
        setSelectedValue(value);
        setIsOpen(false);
        onChange(value);
      }}
    >
      {children}
    </div>
  );
};

These small additions make our select component much more accessible, and the compound structure makes it easy to add these features in a logical way.

One challenge you might face when implementing compound components is deciding how implicit or explicit to make the relationships between components. Some libraries require you to explicitly connect child components to their parent, while others use React’s context API or similar mechanisms to implicitly share state.

I generally prefer a more implicit approach, as it leads to cleaner, more declarative code. However, there are times when being more explicit can make your components easier to understand and debug. It’s all about finding the right balance for your specific use case.

Testing is another area where compound components can really shine. Because each piece is separate, you can easily unit test individual components. And when it comes to integration tests, the declarative nature of compound components often makes it easier to set up different scenarios.

Here’s a quick example of how you might test our Select component:

test('Select opens and closes when trigger is clicked', () => {
  render(
    <Select>
      <Select.Trigger />
      <Select.Options>
        <Select.Option value="test">Test</Select.Option>
      </Select.Options>
    </Select>
  );

  const trigger = screen.getByRole('button');
  expect(screen.queryByRole('listbox')).not.toBeInTheDocument();

  fireEvent.click(trigger);
  expect(screen.getByRole('listbox')).toBeInTheDocument();

  fireEvent.click(trigger);
  expect(screen.queryByRole('listbox')).not.toBeInTheDocument();
});

test('Selecting an option updates the trigger text', () => {
  render(
    <Select>
      <Select.Trigger />
      <Select.Options>
        <Select.Option value="apple">Apple</Select.Option>
        <Select.Option value="banana">Banana</Select.Option>
      </Select.Options>
    </Select>
  );

  fireEvent.click(screen.getByRole('button'));
  fireEvent.click(screen.getByText('Banana'));

  expect(screen.getByRole('button')).toHaveTextContent('Banana');
});

These tests are clear and focused, thanks to the structure of our compound components.

As you start using compound components more, you’ll likely find yourself creating higher-order components or hooks to abstract common patterns. For example, you might create a useCompoundComponent hook that handles setting up the context and providing common functionality.

Here’s a simple version of what that might look like:

function useCompoundComponent(initialState) {
  const [state, setState] = React.useState(initialState);

  const setPartialState = React.useCallback((updates) => {
    setState((prevState) => ({ ...prevState, ...updates }));
  }, []);

  const contextValue = React.useMemo(() => ({
    ...state,
    setPartialState,
  }), [state, setPartialState]);

  return [contextValue, CompoundContext.Provider];
}

Then you could use it like this:

function Select({ children, onChange }) {
  const [selectState, SelectProvider] = useCompoundComponent({
    selectedValue: null,
    isOpen: false,
  });

  return (
    <SelectProvider value={{ ...selectState, onChange }}>
      {children}
    </SelectProvider>
  );
}

This approach can help reduce boilerplate and make your compound components even more flexible and reusable.

One last thing to consider when working with compound components is performance. While the flexibility is great, it’s important to make sure you’re not introducing unnecessary re-renders. Tools like React’s useMemo and useCallback can be really helpful here.

For example, you might optimize our Select component like this:

function Select({ children, onChange }) {
  const [selectedValue, setSelectedValue] = React.useState(null);
  const [isOpen, setIsOpen] = React.useState(false);

  const handleChange = React.useCallback((value) => {
    setSelectedValue(value);
    setIsOpen(false);
    onChange(value);
  }, [onChange]);

  const selectContext = React.useMemo(() => ({
    selectedValue,
    setSelectedValue,
    isOpen,
    setIsOpen,
    onChange: handleChange
  }), [selectedValue, isOpen, handleChange]);

  return (
    <SelectContext.Provider value={selectContext}>
      {children}
    </SelectContext.Provider>
  );
}

These optimizations can make a big difference in complex UIs with many compound components.

In conclusion, compound components are a powerful pattern for building flexible, maintainable UI components. They encourage good component design, make it easier to create intuitive APIs, and can lead to more reusable code. While they’re not the right solution for every situation, they’re a valuable tool to have in