Applying Progressive Disclosure to Your Component APIs
I’ve been thinking a lot about component API patterns lately, and I recently read Jason Lengstorf’s article on Progressive Disclosure of Complexity. It really helped me put words to what I was trying to describe (Thanks, Jason 👍). It’s tangentially related to this post, but if you haven’t read it already, you should. He references a Nielsen Norman Group article that’s also worth reading. I won’t spend a lot of time reiterating those points, but I’d like to talk about the problem progressive disclosure solves, and a way to apply that technique to component APIs.
This is the problem: How do we provide simple, clear component APIs for developers while also allowing them to have the power and flexibility to customize when they need it? Developers want clear, simple options, but also the ability to be power users when needed.
I’ve personally encountered (and created) many components that I initially loved and then strongly resented over time. Some would become overextended with conditional logic and unable to function well for any use case. Others were highly customizable, but the API was unclear. Carefully inspecting the source code to understand component behavior is a productivity killer. I’m losing part of the benefit of having a reusable component.
First, we’ll talk about two common component patterns; their structure, strengths, and weaknesses. Then we’ll discuss how to layer them to provide a more resilient component API.
If you’ve been working with React very long, you’re probably familiar with builder component patterns. They typically take a specific config or dataset as a prop and provide a high-level abstraction for generating the component structure.
When They Make Sense
Builder patterns require you to know how to structure the data they need to render properly, but not much else. They hide all the underlying logic for generating markup and handling component-specific logic. This pattern works really well when components are reused, but don’t vary how they render. We’re able to ensure consistency by abstracting ui structure and logic.
However, this pattern quickly breaks down if you need to handle custom functionality. How do we modify this component if one instance of our list needs a popover for further explanation or a badge to highlight a new feature? Often we’re left with two unsatisfying options: Add conditional logic for this particular use-case (and potentially update all existing instances of the component), or start from scratch with a very similar
ListWithPopover component. The first option leaves us with brittle and obscure component APIs, and the second produces component fragmentation. The rigid component API which was once a benefit, now feels like a detriment.
Composable patterns are at the other end of the spectrum. They typically use small components with little-to-no logic and provide a high degree of flexibility for composition. Their individual parts handle styling and a bit of rendering logic, but not much else.
When They Make Sense
Badge or a
Popover component to this structure would be fairly simple. And we can modify this particular instance of the
List without having to pollute our other components. Given that we follow the correct semantics for building a
List, we’ll get a consistent output every time. This pattern’s flexibility provides a high-level of resilience over time. I generally see this component pattern outlast many of its builder pattern counterparts. UI tends to change over time, and builders have difficulty adapting. They often become more brittle as they age and accrue more conditional logic. Composable components are rarely susceptible to that problem.
Composable components exchange flexibility for verbosity. If we have many instances of
List in our applications, we exponentially increase the length of our markup. This seems like a lot of effort if I only need to render a standard list of items.
We’re also requiring developers know and follow the correct structure and recreate any logic that a Builder would have neatly abstracted. In the case of this nested list, the structure is mostly obvious and the logic is minimal, but with more complex components (e.g: a typeahead field), these details can be more opaque.
I often describe good component API patterns as having these qualities:
- Right choices are clear and simple
- Mistakes are difficult and obvious
- Flexibility is first-class
Both builder and composable patterns have their strengths. Builders make the right choice very clear and limit the opportunity for mistakes, and composable components provide consistency with a high level of flexibility. But neither meet all our criteria above. Perhaps we could combine them to provide an even better API.
Applying progressive disclosure, we can assume someone using the higher-level Builder pattern won’t need much customization, and those opting for the composable pattern are willing to trade the convenience of the Builder pattern for a higher degree of flexibility. So, what if we exported our
Builder component to handle the default cases and the underlying composable components to provide additional flexibility? Let’s see what that would look like.
A Reminder: If the gists are challenging to read or understand , you can also look at the example repo here.
First, let’s look at the
Next, we’ll look at the composable
Great! This combination provides a simple higher-level component API for normal use, and an advanced API when we need lower-level customization. Having both means we won’t be tempted to overextend our
Builder component, and we can limit verbosity to only cases when we need our composable
List component. This has an additional benefit of communicating complexity to other team members. If you see the Builder pattern, you can assume everything is running with normal defaults: “Business as usual. Nothing to see here.” And the lower-level composable pattern acts as a flag: “Heads up! There’s some special functionality here.” But both provide sufficient guardrails. In fact, if you look at the demo ui, the two lists look identical except where the composable pattern has intentionally been modified. We’re letting people choose their own path, but we’re not letting them stray too far from the trail.
Thanks for reading! I’ve found applying progressive disclosure by layering component APIs provides improves component resilience and developer productivity, and I personally like this combination a lot. I have a nice default that handles most use cases, but I can drop a level lower when I need to have more control. I hope there were some helpful ideas for you and your team as well.