Building Composable React Components

Imagine you're building a <Button> component. You might add some props to control the appearance, like variant or size. You end up with a component that looks like this:

const Button = ({ variant, size, children }) => {
	const className = getCorrectStylesFor(variant, size);

	return (
		<button className={className}>
			{children}
		</button>
	);
}

Okay, but now you run into a problem. This always renders as a <button> HTML element, and you might need it to be an <a> tag sometimes so that you can use it as a link. Or maybe even a Link from React Router or Next, so that you can link to other pages within your SPA.

Here's two simple ways to take care of this:

Adding an as prop

This is the classic. Adding a prop that allows you to define the actual component to render, so that you can do the following:

<Button as={a} href="https://example.com">
	Link to other website
</Button>

Or maybe like this:

import { Link } from "react-router-dom";

<Button as={Link} to="/profile">
	Edit profile
</Button>

An implementation of that could look something like this:

const Button = ({ children, as, ...props }) => {
	const Component = as || "button";

	return (
		<Component {...props}>
			{children}
		</Component>
	);
}

Note how the Component variable needs to start with a capital to make sure that React renders it as a React component, rather than just as a HTML element (a lowercased component would be rendered as the HTML element <component>, rather than the actual value of the component variable).

This approach has a drawback, however: you need to pass through all props you want the final element to have. This works using the normal ...props spreading method, but editors will get confused because there's no Typescript definition for what those props could be, and therefore you're missing out on linting and validation.

A better approach: asChild

Because of those reasons, an alternative way has become more popular recently – adding an asChild prop. That makes our Link example from above look something like this:

import { Link } from "react-router-dom";

<Button asChild>
	<Link to="/profile">
		Edit profile
	</Link>
</Button>

That has the advantage of not needing to pass through our props directly from Button to Link. But now we need to do the opposite: we need to pass through some of our styling props (className) in the Button component so that they're applied to Link.

A nice and easy way to do so is using the Slot component from Radix:

import { Slot } from "@radix-ui/react-slot";

const Button = ({ variant, size, children, asChild }) => {
	const className = getCorrectStylesFor(variant, size);
	const Component = asChild ? Slot : "button";

	return (
		<Component className={className}>
			{children}
		</Component>
	);
}

You can also build your own simple Slot, if you want to understand how it works:

const Slot = ({ children, ...props }) => {
  const slots = React.Children.toArray(children);

  return slots.map((slot) => {
    return React.cloneElement(slot, {
      ...slot.props,
      ...props,
    });
  });
};

Of course that's a very naive implementation - to make this usable, you'd want to be able to deal with non-React elements (e.g. strings), merge certain props (like className and style), and maybe allow multiple levels of passing through.

Or just use the Radix one, it does all of those things.