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 often support two common patterns for achieving polymorphism: the as prop and the asChild prop. These APIs further enhance reuse and versatility of the components.

The as prop#

The as prop lets you replace the default HTML element with another element or component. This approach is highly type-safe because your component’s 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.

Although the as prop is generally the preferred way to achieve polymorphism because of its type safety, it's flexibility becomes limited in the context of React Server Components as props passed from the server to client components need to be serializable by React.

This means that in a server context the as prop continues to work well for replacing the underlying HTML element (strings are serializable), but it will break when you try to pass a React component as its value.

The asChild prop#

The asChild pattern is an alternative approach to polymorphism that allows complete delegation of rendering to the child element or component. The asChild prop is essentially equivalent to as={Slot} under the hood, where Slot handles the composition logic.

Advantage of this pattern is that it makes components even more versatile by allowing deeper composition, however, the trade-off is that you lose some type safety compared to the as prop. Additionally, the asChild prop is useful in the context of React Server Components as it allows component composition without serialization issues that arise with the as prop.

With asChild you wrap the component instead of specifying it via as prop:

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

A key feature of asChild is its ability to chain composition. Unlike as, it can compose more than two components together. A component using asChild can have a child that also uses asChild (or as prop for that matter). Props get merged down the chain onto the final, non-asChild element:

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

In frameworks like Next.js App Router, you can use asChild 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';
// Assuming this is a server somponent
function MainNav() {
return (
<SidebarNavigation.Root>
<SidebarNavigation.Item asChild label="Dashboard" iconName="home">
<Link href="/dashboard" />
</SidebarNavigation.Item>
</SidebarNavigation.Root>
);
}

While more verbose than the as prop, you can also use asChild to just swap the underlying HTML element:

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

Best practices for creating compatible components#

When creating your own custom components that are intended to work seamlessly as targets for the as prop or as children within an asChild component, 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
}}
/>
));
// with `as` prop
<Dialog.Trigger as={MyButton}>Open dialog</Dialog.Trigger>
// with `asChild` prop
<Dialog.Trigger asChild>
<MyComponent>Open dialog</MyComponent>
</Dialog.Trigger>