Do your React components compose?
See how to make truly reusable UI components with forwardRef and prop forwarding.
Video resources
Summary
Sometimes when you're building reusable UI components for your app or your team, it can be tricky to make sure that they play well with other components in the React ecosystem. So today we're gonna look at a simple UI button, it's a reusable button part of this project, and we're gonna see how well it integrates with a Radix popover, and we're gonna see where it falls down and we're gonna fix it and learn a lot along the way.
So we're looking at this demo right here, I'm just working on this little nav, and we see we have a New Invoice button right here using Radix Popover. If you've never heard of Radix before, it's one of these headless UI libraries. You might have seen me use Headless UI, this is another library in the same category, they're both fantastic. I've just been playing with Radix recently and I really like it.
So if I click this button, we're gonna see that it renders this <Popover.Content />
. So this is right here, and we get all these cool features from Radix like I can hit escape to dismiss it, I can click this close button, I can click anywhere outside the popover to dismiss it. This is the kind of behavior that you get using these libraries. And we also see we have this little class name right here that we're styling our button with. This data-state
attribute is another feature we get from Popover just to kind of further customize our UI.
And so now what we want to do is bring in a button that we would have from the project kind of styled in the style of our app. So, this is a Next project, you can see right over here I have this button. So let's go ahead and just kind of try to use it and see what happens.
So this is the trigger which renders the button, and if we wanna write our own custom markup here, this is where we do it. So I'm just going to wrap this "New invoice" in a button, and we'll import this from @/components/button
, and we can see it right up here at the top.
<Popover.Trigger className="data-[state=open]:opacity-50">
- + New invoice
+ <Button>New invoice</Button>
</Popover.Trigger>
And if we save we see our styled button. So this is, again, just a button that you might expect to have from your project, we have some styling, we have some other cool props. And it seems like it's rendering pretty well. But if I go ahead and refresh, we're actually gonna see an error. It says
Hydration failed because the initial UI does not match what was rendered on the server.
And so whenever you see this error, it's a good thing to go ahead and pop open the console, see if we see any more information in the console right here. It says
Expected server HTML to contain a matching <button> in <button>.
Okay, that sounds a little strange. Let's take a look at this button right here, and if we actually open this up, we are going to see, this is our kind of component, our UI button, and then this looks like the button that Radix is rendering. And look at all this cool stuff it has. It has aria labels, it has this data-state
prop. And this is obviously a problem, you can't render a button in a button, but fortunately Radix has an API specifically for this. So, Radix says if you're gonna render your own button tag inside of a Trigger, just let us know using the asChild
prop. Just like that.
<Popover.Trigger asChild className="data-[state=open]:opacity-50">
<Button>New invoice</Button>
</Popover.Trigger>
And we can see Fast Refresh there went ahead and updated our markup, so now we only have one button in the DOM. And so, if we were to close this and refresh, we don't see that error anymore. So that's nice.
And so now basically, we can come and customize our button. Your UI buttons might have things like variants on them. Mine has this nice leading
prop, which takes in an icon. So I'm gonna go ahead and grab a <PlusIcon />
from Heroicons, just like that.
<Popover.Trigger asChild className="data-[state=open]:opacity-50">
<Button leading={<PlusIcon />}>New invoice</Button>
</Popover.Trigger>
And we can see it lays this out pretty nicely. So again, this is the kind of thing you might expect to have on your own UI components, and I just wanted to show you here that with the libraries that we're using like this, these headless libraries, you can use any of these props and it should play nicely.
But now let's see if this thing actually works. So if I click this, well we don't actually see the popover show up. So let's go ahead and pop open the inspector, and interestingly, if I click this and click away, it does look like there's some Radix elements being appended, and check this out. If I grab the HTML element and we go ahead and scale this down, eventually we will see the popover is actually rendering. It's just not anywhere near our button. So it is being appended.
Let's take a look at the console and see if we can find out what's going on. And sure enough, we have an error here. It says
Warning: Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef?
Okay, well let's go ahead and pop open the docs for React.forwardRef
right here. I'm using the new beta docs and it says
forwardRef lets your component expose a DOM node to parent component with a ref.
And so as you probably know, refs are how we tell React where our elements are in the DOM. And we can pass those refs around, usually to do imperative things like measure their width, height or focus them. But in this case, we have a custom button and it's telling us forwardRef
lets our component share a ref to the parent. And you can see here, you know, when we zoom this out, the popover is somewhere, but part of what Radix does is position it near our element. So it needs a ref to that button so that it can actually do that.
So let's see how we actually can use forwardRef
here in the docs.
We see the reference says forwardRef
takes in this render argument:
Call
forwardRef()
to let your component receive a ref and forward it to a child component
And the syntax is a bit confusing when you first see it, but we're gonna walk through it together. You just wrap your component in forwardRef
, and the only change we need to make is instead of just getting props, our component takes props and ref.
So let's do this together back in our button, and try to fix this error.
I'm gonna come over to our button, and if we take a look at this purple UI button that we have right now, we are just exporting a default button. We have some TypeScript props right here, we can take a leading icon like we saw earlier, we can take onClick
, children
, and className
, and we return a button with some extra markup here and some styling. And so what we need to do to follow the instructions here is wrap this function in forwardRef
.
So let's go ahead and grab forwardRef
right here, this is coming from React, and we're gonna pass our component in to forwardRef
.
export default forwardRef(function Button({
leading,
className,
onClick,
children,
}: ButtonProps) {
return (
<button
onClick={onClick}
className={`inline-flex items-center rounded bg-purple-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-purple-500 active:bg-purple-500/90 ${className}`}
>
{leading && (
<span className="mr-0.5 -ml-0.5 h-5 w-5">{leading}</span>
)}
<span>{children}</span>
</button>
);
});
So let's go ahead and save that. And now again, if we pop over to the documentation, the only thing that changes is we now have a second argument called ref
that comes after our props.
So our props are right here, this entire thing. If you're not familiar with TypeScript, this is the TypeScript part, but you've probably seen this before where we destructure our different props. Really, we could just change this with props and ref just like that, so it looks like the docs. And then we can just put our props back in.
export default forwardRef(function Button(
{ leading, className, onClick, children }: ButtonProps,
ref
) {
return (
<button
onClick={onClick}
className={`inline-flex items-center rounded bg-purple-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-purple-500 active:bg-purple-500/90 ${className}`}
>
{leading && (
<span className="mr-0.5 -ml-0.5 h-5 w-5">{leading}</span>
)}
<span>{children}</span>
</button>
);
});
So once you kind of see this once and let Prettier format this, this looks a little bit cleaner. Again, this is basically just a component and then a ref. And now we have a ref in our component that we can set on any element we want. And that's exactly how we allow the parent to control the most important element, the element that represents our component from above.
And in our case, we're rendering a button, so we wanna attach this ref to the button just like this:
export default forwardRef(function Button(
{ leading, className, onClick, children }: ButtonProps,
ref
) {
return (
<button
ref={ref}
onClick={onClick}
className={`inline-flex items-center rounded bg-purple-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-purple-500 active:bg-purple-500/90 ${className}`}
>
{leading && (
<span className="mr-0.5 -ml-0.5 h-5 w-5">{leading}</span>
)}
<span>{children}</span>
</button>
);
});
So let's save that, come back to our app here and refresh. Looks good so far, we've got no errors. And if I click this, check that out – the popover is able to be placed, tethered, kind of anchored right to our button. And you can kind of guess based on how this all went, how that parent popover is implemented, right. They are gonna be grabbing the ref from the child, and in our case it's forwarded, but they still end up getting a hold of this button so they can calculate where it is in the DOM and actually position the popover right here to the bottom.
And this is so cool because we still get to use all these awesome features of our popover. You know, we have side bottom right here, we could just change this to side left, save, and check that out, it's on the left just like that. And so this is kind of exactly how all of these pieces compose together. I think it's so cool to learn this, because if you can learn how to make a truly reusable component, you get all of these awesome features from libraries like Radix for free.
So, this is pretty cool. Looks like we have a little TypeScript error here. And it's telling us that this is ref is a type unknown. And indeed, if we hover over this, we're gonna see this as a ForwardedRef
of type unknown, because we're just basically calling forwardRef
, but we have no information about what kind of ref it's gonna be, so the parent who's calling this doesn't know. Well, we can tell this because forwardRef
is actually a generic type, which means we can pass in more type information right here. And if we look, we'll see the first is the ref, and the second is the props.
So, what kind of element are we going to expect to pass the ref to? Well, that is an HTMLButtonElement
. And then secondly, we can pass in the props right here, which we already have defined right here, but if we pass them into the generic, we can actually remove them from here because that's gonna take care of typing all of our props.
type ButtonProps = {
leading?: ReactNode;
onClick?: () => void;
children: ReactNode;
className?: string;
};
export default forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ leading, className, onClick, children },
ref
) {
return (
<button
ref={ref}
onClick={onClick}
className={`inline-flex items-center rounded bg-purple-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-purple-500 active:bg-purple-500/90 ${className}`}
>
{leading && (
<span className="mr-0.5 -ml-0.5 h-5 w-5">{leading}</span>
)}
<span>{children}</span>
</button>
);
});
So now if we look at all this, we'll still see leading
is a ReactNode
, just like we defined right up here. className
is a string, and all that good stuff. But now this ref we know is a forwarded ref, it's gonna be a button element, this goes away, and now anybody who renders our button knows that if they pass a ref, it's gonna be on an HTML button element. So that's pretty cool.
Now, if we come back to our calling site here, there's one piece of functionality that we don't quite have. And if you remember when we initially rendered just the text, we can actually go back to that really quick, if we remove asChild
, put back "+ New invoice", and try this out, we'll see that we do this kind of cool thing where we dim the trigger whenever the popover is open. And we can do that using this kind of Tailwind class, but it's really because if we pop this open and look at the button, you know, one of the things that Radix does is add this nice data-state
prop.
And so, this thing is dimmed whenever it's opened and it's back to full opacity whenever we dismiss it. But if we go back to ours, we're not gonna see kind of that customization here applying to our button. And in fact, if we take a look at the button, we're gonna see, even though the menu is working, we're not getting that data-open
prop. And in fact, we're not getting any of those cool aria
props, or any of the other props that Radix has done all this work to provide to make our button a really good user experience.
And so, this is something else we need to fix. If we come back to our button and take a look, we are forwarding the ref, but you can see here we're whitelisting the props. And to make this button truly reusable, we can't whitelist the props because we don't know which other HTML button props some parent might want to apply to our button. And so that's exactly why we're not getting all those Radix features.
To fix this, we actually want to go ahead and forward all of the other props that might come through to our button. So if we go ahead and grab any other props that are passed in with the splat operator, and then we come down here and splat them in just like this:
export default forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{ leading, className, onClick, children, ...rest },
ref
) {
return (
<button
ref={ref}
onClick={onClick}
className={`inline-flex items-center rounded bg-purple-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-purple-500 active:bg-purple-500/90 ${className}`}
{...rest}
>
{leading && (
<span className="mr-0.5 -ml-0.5 h-5 w-5">{leading}</span>
)}
<span>{children}</span>
</button>
);
});
Check that out – again, Fast Refresh, as soon as I save, look at this. type=button
, aria-haspop=dialog
, this is so cool. data-state=open
, and now look at that: the opacity is halfway right there. So that is very cool. And now, everything is working. I can hit escape, I can click outside to close. But the caller here is able to customize this using this className
prop because now everything's being forwarded down, everything including those data attributes is being forwarded.
Now, just to kind of button this up, it seems like TypeScript is happy on both the calling side right here, as well as our definition. There's no kind of TypeScript errors. But if we look at ...rest
, we're gonna see it's basically an empty object. And so, we could give this a little bit more information.
Instead of ButtonProps
, which is kind of our four whitelisted properties right here, we could use ComponentProps
, which comes from React. And this takes in a type argument as well, which we can say is button
.
import { ComponentProps } from "React";
export default forwardRef<HTMLButtonElement, ComponentProps<"button">>(
function Button(
{ leading, className, onClick, children, ...rest },
ref
) {
return (
<button
ref={ref}
onClick={onClick}
className={`inline-flex items-center rounded bg-purple-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-purple-500 active:bg-purple-500/90 ${className}`}
{...rest}
>
{leading && (
<span className="mr-0.5 -ml-0.5 h-5 w-5">{leading}</span>
)}
<span>{children}</span>
</button>
);
}
);
And this is pretty cool because if we do this, children
, onClick
, className
– these are all normal properties of the button element, and ...rest
is gonna have everything else in there. So if you were to come down and look at rest, all these aria rules, these are some of the things that are getting passed through from Radix. Now we're just splatting them on like this. But the last thing right here, we have our own leading
prop. This is not part of a normal HTML button element, so let's just use our ButtonProps
up here to define kind of what's special, kind of what is just part of our design system from this perspective, and we can go ahead and just merge the base props with our own, just like that.
type ButtonProps = {
leading?: ReactNode;
};
export default forwardRef<
HTMLButtonElement,
ComponentProps<"button"> & ButtonProps
>(function Button(
{ leading, className, onClick, children, ...rest },
ref
) {
return (
<button
ref={ref}
onClick={onClick}
className={`inline-flex items-center rounded bg-purple-600 px-2.5 py-1.5 text-xs font-semibold text-white transition hover:bg-purple-500 active:bg-purple-500/90 ${className}`}
{...rest}
>
{leading && (
<span className="mr-0.5 -ml-0.5 h-5 w-5">{leading}</span>
)}
<span>{children}</span>
</button>
);
});
And so now all of these arguments are typed, this is our ReactNode
, you know if we had docs for this, this would show up. But these are all part of the base button element types, and everything is looking great. We can use className
just like this if we want to, and yeah this is all working great. We've got our ref right here, and the calling site is good as well.
So now our button is kind of properly typed. We've got all these properties being forwarded from the trigger, and the behavior here is great. We've got this showing up just like this, and it can be dismissed. And we've got no errors in the console at all.
So I thought that was a pretty cool example just to show you as you get more advanced in making components and you're extracting components, making them reusable for your team, these are some tricks that you can use – forwardRef
and prop forwarding – in order to make sure that it composes well with the rest of the React ecosystem and the other components that your team might be writing.