dio.la

Dani Guardiola’s blog

Feb 22February 22nd · 1 minute read · tweet

The open/closed component

A powerful pattern to build extensible UI components that do more with less.

This article's main image

"The open/closed component" series

  1. The open/closed component (you're here)

In this article, I'm going to talk about a pattern for building UI components in frameworks like React or Solid. A pattern that I've come to understand as a must-have for almost every single component I build.

I'm talking about the open/closed pattern, as coined by Diego Haz (author of Ariakit), who introduced this concept in this Twitter thread.

Note that developers have been doing some of these things for a long time, but it's great to have a name and some specific guidelines for it.

Let's start with a common example.

A props situation

The problem

When building and using a component, there is a situation that happens very often. Consider the following React example:

jsx
function Button({ variant }) {
return <button className={variant} />;
}
function App() {
return (
<>
<Button variant="primary" />
<Button variant="secondary" />
</>
);
}

This looks fine. Just one (kind of important) problem... The buttons are useless! There is no way to pass a click event listener!

We could fix it by adding an onClick prop to the Button component:

jsx
function Button({ variant, onClick }) {
return <button className={variant} onClick={onClick} />;
}

That works, but further down the line, you'll probably need other <button /> props, such as disabled, type, form, etc.

It then makes sense to just collect the "custom" props like variant (e.g. with destructuring), and pass the rest straight down to the <button /> element:

jsx
function Button({ variant, ...props }: ButtonProps) {
return <button className={variant} {...props} />;
}

Makes sense so far, but we're not done. What happens now if you pass a className prop to the Button component?

It will override className={variant} since a prop spread ({...props}) behaves like an object spread (or Object.assign()), which means that new properties replace previous ones.

That's not what we want, we need our variant class to be set. We could invert the order, like this:

jsx
function Button({ variant, ...props }: ButtonProps) {
return <button {...props} className={variant} />;
}

However, that doesn't fix anything, it simply switches the problem around. Now, the variant class will take precedence, and the incoming className prop will be ignored.

The solution

The obvious solution is to merge the classes:

jsx
function Button({ variant, ...props }) {
return <button {...props} className={`${variant} ${props.className}`} />;
}

We can go further, and merge other props as well, like event handlers:

jsx
function Button({ variant, ...props }) {
return (
<button
{...props}
className={`${variant} ${props.className}`}
onClick={(event) => {
props.onClick?.(event); // <-- external handler
console.log("internal handler");
}}
/>
);
}

Note that these two examples are simplified and incomplete. I go into more detail on these topics in the next part of the series.

This strategy is great, but we can't merge every prop. Normally, only className, style and event handlers are safe to merge.

The only solution is to let other external props override the internal ones. This is normally okay since the internal props are usually just "defaults", and users know what they're doing (most of the time, anyway!).

What is the open/closed pattern?

This approach to handling props is a part of the open/closed pattern. Hopefully, the previous example will make it easier to understand.

Here's how Diego defined it:

The open/closed (...) component:

A component that is based on the open/closed principle, which states that “software entities (classes, modules, functions, etc.) should be open for extension, but closed for modification”.

Let's digest this:

  • The component is closed for modification, meaning that you can't change the component itself. You can't edit its source code to adapt it to your needs!
  • The component is open for extension, meaning that you can extend it, without changing its implementation.

This is a critical thing to understand. As Diego put it:

A component is closed for modification when you don't need to update its source code to extend its functionality.

So it's not just that you can't update the source code, it's that you don't need to.

Sprinkles on top

Native HTML elements are good examples of this. You can't modify how they work directly, but you can extend them to fit your needs by adding attributes and event listeners. When you build a component, you're essentially extending a native element (even on non-web platforms like React Native!).

This pattern is really neat because it allows you to create components that are also extensible, on top of native ones. If you're building a custom button and you apply this pattern, you're allowing users to interface both with your custom features (e.g. variant) and with the ones provided by the native element (e.g. onClick).

Even better, you can create additional custom components that extend your custom components, creating a "cascade" of extensibility.

If this sounds familiar, that's because it's just good old object-oriented programming! The open/closed principle is an OOP concept.

A way I like to think about this is that you're taking a native element or component, and adding a few sprinkles of functionality (or aesthetics) on top of it.

A diagram representing the idea above with an example of a Button component

Open/closed vs. wrapping

While there's no exact hard distinction between a wrapper component and an open/closed component, I see them as opposites.

In both cases, you're effectively wrapping something else, that much is true.

The key difference is that a wrapper component behaves like a "black box", with a custom API. It might pass down a prop or two, but even then it's not uncommon to see, for example, props called onSelect or action which are passed down to onClick and which exclude the event argument.

This can make sense sometimes, of course. For example, the React Aria library has a usePress utility that wraps all kinds of pointer events (touch, mouse...) and exposes a custom, unified API for them. That can be an immensely useful thing to have.

The way I think about this, though, is that it's probably better to always start with open/closed, and only opt into wrapping (and out of open/closed) when you have really good reasons to do so.


"Enough philosophical stuff!" you say. "How do I actually build an open/closed component?"

Okay, okay. We'll do it in the next article (in React for now).

Stay tuned!

Keep up with my stuff. Zero spam.