dio.la

Dani Guardiola’s blog

Jan 1, 2000January 1st, 2000 · 1 minute read · tweet

Building an open/closed React component

A detailed recipe for building open/closed React components, plus a few tips.

This article's main image

"The open/closed component" series

  1. The open/closed component

In part 1, we learned about the open/closed pattern. Now, it's time to learn how to actually build an open/closed (React) component!

Pass the rest props down

We already did this in the initial example, but let's be more detailed now. We have two types of props:

  • Non-mergeable: we want these to be overridable.
  • Mergeable: we want to merge these (duh!).

In React, passing props to a component behaves like an object spread (or Object.assign()).

Since we want any non-mergeable props we're internally setting to be overridable by the user, we'll need to pass them first. For example:

jsx
function Button() {
return <button type="button" />;
}

Next, we spread the rest props:

jsx
function Button(props) {
return <button type="button" {...props} />;
}

This way, {...props} will override any previous props.

Finally, we want to merge and pass the mergeable props. We do it after the spread to prevent them from being accidentally overridden. We're already taking care of merging all of the values ourselves, so it's okay to override previous properties.

jsx
function Button({ variant, ...props }) {
return (
<button
type="button"
{...props}
className={clsx(variant, props.className)}
/>
);
}

In short, here's the order:

  1. Pass non-mergeable props.
  2. Spread the rest props.
  3. Merge and pass mergeable props.

Let's take a closer look at mergeable props. Only classes, inline styles and event handlers are usually safe to merge.

Merging classes

Simple, just concatenate all values separated by spaces. Libraries like clsx are recommended.

jsx
const className = clsx(internalClassName, props.className);

Merging styles

In React, styles are objects, so we just need to merge them. We can do so easily with the spread syntax.

jsx
const style = { ...internalStyle, ...props.style };

Merging event handlers

Event handler props typically start with on (e.g. onClick), so when building a general helper or utility to merge handlers, a common technique is to detect them that way.

In any case, for a given event handler prop, the way to merge both values is to create a wrapper function that calls both the internal and external handlers.

The external one must be called first. This is important because that handler could have called event.preventDefault(), which is self-explanatory (stops the default behavior of the event).

Our internal handler is the "default behavior" in this case, so we need to check if preventDefault() was called (through event.defaultPrevented) and, in that case, the internal handler shouldn't run to respect the user's wishes.

The logic is a bit complicated, so it is left as an exercise for the reader. There are a few open-source libraries like Ariakit and Radix UI that do this though, in case you want to see a good example.

Forward (and merge) refs

In React, we need to forward the ref to the underlying element, which can be done with React.forwardRef().

jsx
const Button = React.forwardRef((props, ref) => {
return <button ref={ref} {...props} />;
});

I won't get into the TypeScript story here, because things get real messy real fast (maybe an article for another day?).

If you're internally using a ref already, you'll need to merge both the internal and the incoming refs. That is beyond the scope of this article, but again, Ariakit and Radix UI have good examples of this, and some libraries have ready-made utilities for this as well.

Edge case: children

Some components, like our Button, are pretty straightforward. If they receive a children prop, it is just passed down to <button /> and that's it.

However, we could complicate things, and add things like icons, a dropdown arrow, etc. What happens then?

Well, there is no exact answer to this, I think it's something you have to think about and decide what makes more sense for your specific situation. I tend to think about children as the main "slot" for user-provided content.

Here's a more advanced example: let's say I have a Popover component that can have some custom header and footer content. My typical approach there is to leave the children prop for the main content, and add headerSlot and footerSlot props for the header and footer content. For example:

jsx
<Popover headerSlot={<h1>Header</h1>} footerSlot={<button>Footer</button>}>
<p>Content</p>
</Popover>

I think of these -Slot props as complementary to children. In a way, children is nothing more than a "default" slot.

Edge case: multiple root elements or components

Another tricky situation is when there are multiple top-level components instead of a single one, like this:

jsx
function Dialog() {
return (
<>
<button>Open dialog</button>
<div>Dialog content</div>
</>
);
}

In this case, we need to choose a "main" element or component, and apply the open/closed pattern to it (pass it the rest props and forward the ref). Similarly to the children prop, it depends on the context and how the component is supposed to be composed or extended.

For this specific example, I would tend to think of the <button /> as the main element, mostly because it's the thing that would be unconditionally rendered, and because it is typically going to be part of the layout flow.

Edge case: unclear root element or component

This is quite similar to the previous case, but it's a bit more ambiguous. For example:

jsx
function TextInput() {
return (
<div>
<label>Label</label>
<input type="text" />
</div>
);
}

The tricky thing here is that there is a clear root element, strictly speaking (the div), but the component is supposed to be a text input, which comes with a few implications about its API.

For instance, you would expect to be able to pass a value, or event handlers to track changes to the input. However, you would also expect to be able to pass style information for layout and sizing purposes (for example, a width or a margin).

For the former, we would need to pass the props to the <input /> element, but for the latter, we would need to pass them to the <div /> element since it's the actual root element.

This is a very tricky one, and I don't have a perfect answer for it. I can think of two different approaches:

  • The "purist" approach would be to stick with div being the sole root element, and just expose an inputProps prop that is forwarded to the <input /> element.

    This might be the most straightforward approach, but it's not very ergonomic.

  • The "hybrid" approach would be to be more nitpicky with which props are passed to which element. For example, we could forward all the props to the <div /> element by default, but make some exceptions for things like value and onChange (which would be forwarded to the <input /> element).

    This is more ergonomic, but has the potential to be confusing or limiting.

It's all about trade-offs.


An alternative method that I'd call the "inverse purist" approach consists of selecting the inner component as the main one (like <input />), and using a wrapperProps prop for the root element (e.g., <div />) (thanks Diego Haz for bringing this to my attention!).

I don't recommend this for our TextInput example, as it may confuse users. For instance, if they attempt to add a margin for layout purposes, they'll find an unwanted gap between the label and input. Thus, it's better to use the root component for prop and ref forwarding.

However, this approach can work in some situations, such as with the Ariakit Popover component which renders a positioning wrapper with a "content" element inside. Users generally want to style the content element (e.g. display: flex or padding), while adjusting the positioning wrapper is rare. In this case, selecting the content as the main element is reasonable, and wrapperProps can be used for the wrapper.

Since the root element is not part of the normal layout flow (it's a popover, it floats on top of other content!), the issue mentioned earlier is unlikely to arise.

Some notes on API design

The open/closed pattern is a very powerful tool, but it's not a silver bullet. As you can see, there are a few edge cases and tradeoffs to consider, and it's not always clear what the best approach is.

Here are a few API design recommendations that I think work great with this pattern:

  • Slots: as I mentioned when talking about children, I like to provide "slots" for user-provided content, and think of children as the default (but not necessarily exclusive) slot.

    These slots are simply props that accept any React node, and I typically name them with the -Slot suffix (e.g. headerSlot, footerSlot, etc.).

  • Props forwarding: an open/closed component already forwards props and refs to the main element/component, but a complex enough component is likely to render a non-trivial tree (like the previous TextInput example).

    In those cases, I like to expose props such as inputProps and inputRef to allow users to extend those parts of the component too. This is not strictly necessary for every single element, but it can be very useful sometimes.

  • Documenting the API: for users already familiar with the open/closed pattern, a simple "this component extends button" in a JSDoc description can be incredibly helpful.

    In line with the "sprinkles on top" analogy I made before, it's very useful to know that you're working with a <button /> that just happens to have some extra features. Web developers are already familiar with the web platform, no need to reinvent the wheel! More on this in the next article in the series.

Beware of side effects!

There is a subtle way to break open/closed components, and it's by introducing side effects.

For instance, if your component is doing event.stopPropagation() on a certain event, it is effectively breaking the API of the inner component (for example, stopping the propagation of a click event in a <button />).

The API is not limited to props and refs, it also includes other stuff like events. A parent component might be expecting a certain event to bubble up!

The open/closed component checklist

In a nutshell, when building an open/closed React component:

  1. Pick a main root element or component.

  2. Forward non-custom props to it.

    1. Pass non-mergeable props.

    2. Spread the rest props.

    3. Merge and pass mergeable props.

      1. Merge classes: concatenate them into a single, space-separated string.

      2. Merge styles: merge them into a single object.

      3. Merge event handlers: merge them into a single function.

        External first! Beware of event.defaultPrevented!

  3. Forward the ref. If using a ref internally, merge it with the external one.

  4. Forward children to a place where it makes sense.

  5. Be careful to not introduce side effects that alter the API.

  6. (optional) Create "slots" for other user-provided content (e.g. headerSlot, footerSlot, etc).

  7. (optional) Forward props/refs to other elements (e.g. inputProps and inputRef).


At this point, if you've worked with design systems before, you might be wondering...

What about the constraints?!?

I hear you. In a design system, the idea of constraining the options or features of a component is seen as a good thing, because it helps to keep the design consistent and predictable.

The open/closed pattern seems at odds with this, but I disagree!

Stay tuned for my next article in the series, where I'll discuss this.

Keep up with my stuff. Zero spam.

You're looking at my drafts!

Visit the main site: dio.la