Composition

An overview of component composition patterns in Wave React.

Compound components#

The compound components pattern in React is a design strategy for building flexible and reusable components. Instead of creating a single, monolithic component that tries to handle every possible variation through an ever-growing number of props, you break it down into multiple, interconnected components that work together. These components implicitly share state and logic, often using React Context behind the scenes.

Transitioning from a monolithic component to compound components inverts control, giving consumers granular access to each part of the larger component as needed. This allows them to:

  • Compose individual parts with other components or logic.
  • Control the rendering order and structure.
  • Add custom props, event listeners, or refs directly to the relevant sub-component.
  • Apply contextual styles.

This approach strikes a balance between simplicity and flexibility. In simple, common cases, consumers can use the components with minimal configuration. However, components remain open for extension and composition with other elements, adapting gracefully to a variety of use cases.

Below is an example of using Popover and Form Field components to demonstrate the pattern. You compose the desired structure using the provided parts while allowing customization of each part:

<Popover.Root>
<Popover.Trigger as={Button} variant="outline">
{'Open Popover'}
</Popover.Trigger>
<Popover.Content
placement="top_left"
padding="spacingM"
css={{ width: '24rem' }}
>
<Flex as="form" flow="column" gap="spacingM">
<FormField.Root as={Flex} flow="column">
<FormField.Label>{'First name'}</FormField.Label>
<TextInput />
</FormField.Root>
<FormField.Root as={Flex} flow="column">
<FormField.Label>{'Last Name'}</FormField.Label>
<TextInput />
</FormField.Root>
<Flex main="end">
<Popover.Close as={Button} variant="ghost">
{'Cancel'}
</Popover.Close>
<Button type="submit">{'Submit'}</Button>
</Flex>
</Flex>
</Popover.Content>
</Popover.Root>

Polymorphism#

Polymorphism in React allows components to change their underlying rendered element or compose with other components while preserving their core functionality.

Wave components support common pattern for achieving polymorphism: the as prop. This pattern further enhances reuse and versatility of the components, while being type-safe because props can be correctly inferred based on the provided element or component.

as prop is especially handy when your goal is to swap out the default HTML element while maintaining the component's behavior and styling. For example, to render a Button component as an anchor element:

<Button
as="a"
variant="outline"
rightIcon={<SvgIcon iconName="arrowRight" />}
href="https://volue.com"
target="_blank"
>
{'Go to volue.com'}
</Button>

In the example above, the Button component renders as an <a> tag, inheriting all the props specific to anchor elements, such as href and target.

The as prop can also be used to render another component, enabling composition of behavior:

<Popover.Root>
<Popover.Trigger as={Button} variant="outline">
{'Open Popover'}
</Popover.Trigger>
<Popover.Content placement="top_left" padding="spacingM">
{'Popover content'}
<Box marginTop="spacingM">
<Popover.Close as={Button} variant="outline">
{'Close'}
</Popover.Close>
</Box>
</Popover.Content>
</Popover.Root>

In the example above, the Popover.Trigger and Popover.Close render as the Button component, inheriting its props while maintaining its own behavior.

In frameworks like Next.js App Router, you can use as to render Next.js Link component that handles routing. This is useful when you want to maintain the behavior of a Wave component while also providing navigation functionality:

import Link from 'next/link';
function MainNav() {
return (
<SidebarNavigation.Root>
<SidebarNavigation.Item
as={Link}
href="/dashboard"
label="Dashboard"
iconName="home"
/>
</SidebarNavigation.Root>
);
}

The as prop has some limitations by default, as it only accepts a single element or component. However, you can use the as prop with a Slot utility, allowing for composing more than two components together:

<Dialog.Root>
<Dialog.Trigger as={Slot}>
<Link as="button">{'Dialog trigger with a link appearance'}</Link>
</Dialog.Trigger>
<Dialog.Box padding="spacingM">
{'Dialog content'}
<Box marginTop="spacingM">
<Dialog.Close as={Button} variant="outline">
{'Close'}
</Dialog.Close>
</Box>
</Dialog.Box>
</Dialog.Root>

The trade-off is that you lose some type safety compared to passing component directly to as prop. With Slot rendering is delegated to the child element or component, thus the strict contract between parent and child is lost.

Best practices for creating compatible components#

When creating your own custom components that are intended to work seamlessly as targets for the as prop, follow these practices to make them open for extension:

  • Ensure your component spreads all incoming props onto the underlying DOM element or component it renders.
  • Chain the event props with the internal event handlers, if any.
  • Combine any incoming style or className props with your component's internal styles or classes.
  • Use React.forwardRef to accept a ref and forward it to the underlying DOM element. If your component uses an internal ref, merge the forwarded ref with your internal one.

For example:

const MyButton = React.forwardRef((props, forwardedRef) => (
<button
ref={forwardedRef}
{...props}
style={{
position: 'relative',
...props.style
}}
onClick={event => {
props.onClick?.(event);
// Additional behavior here
}}
/>
));
// Usage
<Dialog.Trigger as={MyButton}>Open dialog</Dialog.Trigger>;