Don't extend UIs, compose them
Creating the best user interfaces translates to the best user experience, which then can translate to an app's success. Which is why frontend developers and UX designers spend their waking hours crafting the best interfaces (at least I hope they do).
Like with everything, there are compromises in building UIs, perfection is unattainable (especially on the web) and certain UI patterns can be not worth implementing when their implementation complexity is considered.
In this post I want to talk about "composing" UIs. Composing means that we use independent UI blocks or complete UI flows that we link together to create the different pages and user journeys. At the other spectrum would be a completely unique UI for every new feature or user journey.
In modern web design, independent UI blocks, also called components, are heavily used , be it developers or designers, we all use components like "buttons", "accordions", "cards" and many more we discover while building our applications. Some design systems use "atomic" design to group these into buckets depending on their "atomicity" with a button being an atom, a search form being a molecule, then we have organisms, templates & pages (I personally find this overly semantic and not useful in practice - I just use components & pages).
The goal is to favor composability. We want to have lots of components and flows that we can reuse over and over again, as this will lead to less complexity and thus faster implementation times. But it's not as easy as it sounds, as this clashes with the theoretical optimal user experience - apps look different for a reason, providing the best UX does mean creating custom components. Nevertheless, favoring composability should be the default and customization or extension a less frequent choice.
This is why I want to showcase two "strategies" that I found that come up quite often, that put "favoring composition" into more actionable items:
- Don't extend the component, wrap it with another
- Don't extend the component, delegate to another
We explore these strategies with two interactive examples. The code for the examples is available below them, but reading it is not required, it's just for the curious React developer.
1. Wrap, don't extend
To showcase the first strategy, we start off with a simple card component that is used to show some metrics for an imaginary car rental company. The component has a title, description and the metric itself.
It's a perfectly fine component, but then we get a new requirement:
- The user should be able to toggle between multiple intervals, instead of only showing the last 30 days. They should be able to see the last 30, 60 and 90 days.
The UX team decides that the functionality to switch between them should be placed in the card itself. They want to extend it. The switching should be done using a dropdown menu that also functions as the header of the card.
This is a design that extends the existing component, therefore, we have to either change the implementation of the existing component or create a new one. It is a rather simple component, but the point stands that for any change where new functionality is placed inside a component, we need to do substantial changes to it .
Instead of extending the existing component, let's try to use the composition approach, to be specific, we will wrap the component. We keep the existing component as is and build around it. For this example, it could look like this:
With this version, we did not have to change the existing component as we put the new controls outside of it. Despite being simpler to implement, favoring composition gives us also more opportunities to create new blocks to compose with, as the new UI component that we used to wrap the existing one can wrap any component.
The downside is that we had to change the surrounding layout, which could be a substantial downgrade in user experience. So one cannot always wrap in these instances, sometimes we have to go with the "extension" strategy.
2. Delegate, don't extend
Now we explore the second composability strategy with a rather complex UI pattern, that of a modal flow. A modal flow is a multi-step user journey that happens in a modal . They are a great tool to simplify the process for a user and, as with every modal, are amazing for composability as you can reuse them throughout the whole application. The issue arises when we want to customize such a modal flow for a new user journey.
Below we see a simple modal component. We skip all the normally complicated elements, e.g., rendering it above everything and managing the visible state. The important part is that modals have a header and a body, which is what makes it also especially hard to reuse parts of a modal flow - the header and body are visually linked, but have to be kept separate in code.
We can use this modal now to implement the user flow of renting a car. The modal has four steps:
- Rental dates and location
- Car preferences
- Car selection
- Rental complete
The user can see the progress as well as go back to the previous screen. This is actually pretty close to what car rental companies' processes look like.
This gets implemented and all is good, it's a nice UI that can be reused whenever we want to prompt the user to rent a car. But then we get a new requirement:
- Extend the flow with an optional discount screen. It should be shown as the very first step of the user flow.
The UX team, in their pursuit to create the most optimal user experience, found that extending the existing modal flow will exactly do that. They put the optional discount screen at the beginning of the user journey and have it tightly integrated with the existing one.
This design wants us to extend the existing flow. The difficulties of implementing this design are that we cannot reuse the existing component without modifying it. We have again the option to extend the existing component or create a new one (that may reuse parts of the old component).
It's quite some work, especially if we don't already have a component that abstracts steps for us. But maybe we could talk to UX and convince them to create a "composed" UI instead? Something that would leave the existing component as is and we just plug something in front? They come up with the following design. Instead of extending the existing flow, they composed the existing modal with a new modal that is opened first and when "Next" is clicked, the new modal closes and the existing one will open. (The "Reset to discount" is for your convenience and not part of the design.)
I call this strategy delegate, because the component delegates functionality to another. Delegation happens quite a bit in UIs, every modal, every sub view, they can all be considered to have been delegated by the component that dispatched or linked to them.
The "delegation" design is easy to implement. The existing component did not have to be changed at all and adding a modal for one screen is simple. This approach would also make it easy to get rid of the feature if the company ever decides it doesn't want to do discounts anymore. The downside is that the user experience is slightly degraded - the user is missing context from the initial screen in which step they are, they also cannot go back to the discount screen. If these were actual modals, we would also see the discount modal closing and the rent car modal opening, which might feel slightly jarring. But we gained so much and lost so little.
The code necessary to achieve this is almost trivial and most importantly, we didn't have to change the existing component, keeping us safe from regression bugs and leaving more room to work on other features. "Delegate, don't extend" saved the day some development time.
Final thoughts
Keeping these strategies (don't extend, wrap or delegate) in mind and making designers aware of the challenges engineers face with seemingly innocent UI changes can be of great help in keeping complexity at bay. Creating composable UIs, rather than custom ones for each user journey, is easier to develop and maintain. When it's an option, I would always recommend going for it. This doesn't mean that custom (and more perfect) UIs should never be done, but they should only be used when it's worth it, e.g., when they're part of the core user journey.