Hacker News new | ask | show | jobs
by lewisl9029 2616 days ago
The overrides pattern they describe sounds like a nightmare to maintain. It's almost the exact opposite of how I'd design the API for a set of reusable components: provide a minimal API surface to cover existing use cases, and evolve the API deliberately to cover new use cases as they arise.

Allowing users to override arbitrary styling parameters is a recipe for disaster in a reusable component, because once you start doing that, literally any change you make to the component could become a breaking change for some usage of it in the wild. There is no more explicit interface that users of the component are expected to work with that you can hide implementation details behind, as to afford you the ability to change those implementation details without breaking users, because users can just reach into your implementation details with those arbitrary parameters and change them at will, in a way that's potentially incompatible with how you might want to evolve the component in the future. As a side effect, any sense of brand/design consistency you might want to enforce through a design system goes out the window. Interestingly enough, this set of components use component-oriented CSS-in-JS, which is a pattern developed specifically to provide style isolation between components that wasn't possible (or at least not in a foolproof way) with regular CSS, yet they chose to open that can of worms back up through their API.

I've found that a much better middle ground is to deliberately accept React nodes or render props in your components so that you can explicitly yield control of rendering to users for specific, isolated pieces of the component, like the contents of a modal or the individual options of a dropdown. This gives users freedom to render what they need to render, but within the confines of a consistent design framework that enforces overarching rules around consistent use of spacing, colors, transitions, etc. And such an API can still be evolved deliberately to support new use cases without the risk of breaking users unintentionally.

2 comments

I get this concern and I used to have a very similar feeling about it. Uber had an older React component library that was more locked down in a way you pretty much described. Number one complain was "not easy to customize" and that probably applies for every component library out there.

You describe render props as a "middle ground". For us, it is the last resort / nuclear option (as described here https://baseweb.design/theming/understanding-overrides/#over...). You give the consumer all the freedom to completely replace the guts of your component. Sure, nothing provides more flexibility but at the same time, consumer has to do a lot of heavy lifting - creating a whole new component.

However, developers usually want to tweak some small things. Maybe changing some color or padding. Forcing them to swap the whole subcomponent through render prop is an overkill and once they "opt-out" that way, they will never get any updates from us since that part of component is completely replaced.

Of course, ideally we want them to always use the defaults but that's not how real organizations with thousand of engineers and hundreds of apps work so we rather let them customize but on our terms. We don't want to see them hacking the styles through CSS selectors or "render prop" everything which equals not using our visual components at all. It's a compromise we had to make to make everyone reasonably happy.

Surprisingly, it's not a nightmare to maintain. We don't consider changed styles as a breaking change and in last 6 months I haven't seen complains about that. Although, it makes the Base Web codebase more complex and all changes need to be thoughtful. But that's a cost that Base Web pays so other teams don't have to.

Do you use any sort of visual comparison tools or snapshotting in CI to catch regressions caused by changes to Base? Seems like this would remove any (technical) objections to having overrides.
We currently use https://screener.io/ for Base Web itself and working on an app that should automatically crawl all our web apps, analyze them and provide insights into how exactly our components are being used. It should be open sourced eventually.
The folks behind Storybook created a product for that: https://hichroma.com/
I can appreciate that you guys weighed the tradeoffs and decided that giving users freedom to override arbitrary styles is worth the cost of whatever breakage might result from that decision as you upgrade the library. We're all engineers who build things to solve real world problems at the end of the day, and I don't have any of the context that led up to your decision, so I can't say for certain that I'd have weighed those tradeoffs any differently if I were in your shoes and have to cater to the whims of thousands of engineers with differing opinions on how things should be done.

However, I have to disagree with your characterization of render props as "the freedom to completely replace the guts of your component". Offering a render prop API should be an explicit decision to fundamentally stop treating that branch of the render tree as part of the "guts of your component". It's a decision to delegate to users on how to best render that part of the component.

Of course, you're right that in a vacuum, that would equate to throwing up our arms and asking users to figure it all out on their own as to how to implement the styling and functionality of that part of the tree.

However, when we're the maintainer of a component _library_, we're in the unique position where we can provide additional, complementary components that provide styling and functionality for users to use to implement that part of the tree, composed with their own custom components when appropriate, without having to build everything from scratch.

These components usually start out as the same components that used to reside in that part of the tree in the original parent component. By decoupling them from the parent, they're then immediately able to start providing their own explicit interfaces that can be evolved independently from the parent without risk of breaking usages on implementation detail changes.

Of course, users are free to simply not use those components because they might not address whatever problem they need to solve. Rather than taking that as a failure of the approach, I'd take that as a triumph because it demonstrates that this approach gives users the flexibility to experiment with how best to solve their particular problems within the confines of the isolated subsection of the original component without affecting the maintainability of the component itself. And we as library maintainers are then able to examine the various custom components created for those use cases to see if any particular implementation is suitable for extracting directly into the library, or at least learn from them when building new reusable components to support those use cases officially (and users are free to choose to adopt those new components at their own pace, without any fear of things breaking under their feet).

This is why I point to this approach as the middle ground. Component composition is a much more sustainable mechanism to provide to users for customization, in my opinion, compared to arbitrary overrides. In fact, if anything, I'd consider arbitrary overrides to be the nuclear option here, because once we start offering that option, people are going to start using our components ways that we can't possibly ever fully anticipate, so we end up having to _really_ throw up our arms as maintainers and start saying things like "We don't consider changed styles as a breaking change".

Right. We do exactly that as well. You can import all our styled subcomponents separately and then plug them back into render props. We just call it a bit differently - it's the "overrides" object prop and not a top level renderFoo prop. Example: https://baseweb.design/theming/understanding-overrides/#over...

The ability to extend the styles is just a shortcut. If I want to change a border for some specific subcomponent, I could import it, change the color (in our case through a css-in-js API) and plug it back through a overrides.FooComponent.component (which is nothing else then a render prop).

But that's a lot of steps to just tweak a border, isn't it? Why not to just have "overrides.FooComponent.style" and let the library do the rest. This doesn't open more surface, just a quality of life API. Arguably you can run into the same type of style breakages when devs are restyling our subcomponents. They will not look inside of them every single time we release a new version.

To be honest, the only really unique thing about Base Web is that you can replace every single subcomponent (1 subcomponent always renders 1 styled DOM Element) through render prop pattern and you can even do it in a layered fashion (passing children through). "overrides.Foo.props" and "overrides.Foo.style" are just shortcuts for render props. Other than that, it's just an another React component library.

"people are going to start using our components ways that we can't possibly ever fully anticipate" - That always happens no matter what you do! Unless you make your API super strict and then nobody will want to use your library. It's a delicate balance - keeping other teams moving fast and happy while not introducing breakages often.

> But that's a lot of steps to just tweak a border, isn't it? Why not to just have "overrides.FooComponent.style" and let the library do the rest. This doesn't open more surface, just a quality of life API. Arguably you can run into the same type of style breakages when devs are restyling our subcomponents. They will not look inside of them every single time we release a new version.

The ability to arbitrarily override styles in every single component exposed through the library is exactly the crux of my concern outlined in my original post and later reply.

> "people are going to start using our components ways that we can't possibly ever fully anticipate" - That always happens no matter what you do! Unless you make your API super strict and then nobody will want to use your library. It's a delicate balance - keeping other teams moving fast and happy while not introducing breakages often.

Yes, there's definitely a balance to be struck here.

Opening up the API to arbitrary style overrides is sitting at one end of that spectrum, optimizing for short term user freedom/productivity and quick adoption of the component library over the long term maintainability of the library (every change becomes a potential breaking change), and with it, the ability of users to upgrade with confidence to take advantage of new changes and features.

However, the alternative to the wild west approach of arbitrary styling overrides isn't only to make an API so strict that it will never fit anybody's use cases outside of the ones it was originally designed for.

There exists a middle ground of providing an explicit API designed for specific use cases and then deliberately growing that explicit API to cover more use cases, informed by real world usage patterns, while taking care to make sure that those use cases are actually ones that make sense for the library to support long term (rather than built as one-off usage specific components), and that the resulting changes aligns with whatever styling consistency standards the library wants to enforce.

This approach affords maintainers an API surface to reason about and communicate breaking vs non-breaking styling changes to users, in exchange for short term speed of adoption because we're deliberately limiting the initial breadth of use cases the library can cover.

The former approach could definitely be the right tradeoff to make at this time given Uber's current circumstances.

Personally, I still have my doubts, after seeing how quickly this kind of API can break down even at a much smaller scale, but ruling that out without any context would be pure hubris on my part.

However, one caveat I'd like to add is moving from an explicit API to one that allows arbitrary overrides is easy, because adding an arbitrary overrides system is a non-breaking change, but going the other way is another story, because doing so would break everybody that relied on the overrides system to cover whatever use case the base API ever bothered to grow to cover, so it's not a decision to be made lightly. This is definitely not meant to imply that the team at Uber made the decision lightly, as I can tell from your thoughtful responses that you certainly did not, but rather as a word of caution to anyone else who's thinking about going this route.

Note that the above mostly applies to company/product specific component libraries where maintaining design/brand consistency is of great importance. For components meant for public consumption, I personally would only use libraries that yield _all_ rendering decisions to the user: not just styling, but the components being rendered as well as the way in which those components are composed.

Usually that means providing state & handlers through a render prop API, so users can compose their own styled components in arbitrary arrangements that wouldn't be possible with an interface that only exposes specific component overrides inside of a rigid structure. See downshift as an example of a library that does this really well (https://github.com/downshift-js/downshift), and contrast that to something like react-select that only offers the ability to provide overrides for specific components in its own predefined render tree (https://github.com/JedWatson/react-select).

Perhaps on top of that you can also provide a reasonable styled default, but not offering low level control of rendering is usually a deal breaker in my book when picking third party components to work with.

I think there's a big difference between Downshift and Base Web. Downshift is a low-level component offering powerful behaviors that you can use to build your own UI widgets, etc. Base Web looks more like a set of ready-to-use UI widgets. You use it because you specifically want a button that's styled and works the Uber Base Web way. Uber wants all of their buttons to look consistent, so obviously Base Web is going to have styling built into it. Maybe Base Web's button could be implemented using Downshift. If you don't want Base Web's button styling, then you're not Base Web's target audience. You're probably best off implementing your own button using Downshift too.
That's just partly true. From the Base Web website:

"Base Web is a set of reusable React components that implements the Base Web design language and can be used in two different ways:

To build an application that fully adopts the Base Web design language, you import and use Base Web components out of the box.

To build a new design system inherited from the Base, you take Base Web components and customize them through the Overrides mechanism.

If you are building an application using the Base Web design language (the first scenario), you should avoid further customization. This helps to keep the design of your application consistent and makes future upgrades easier."

Uber uses Base Web in both ways since there are some additional related design systems (historic reasons) and that's why a lot of effort was put into overrides.

Funny, I just wasted a good while trying to modify the suggestions dropdown for an input in the react-select.