[SDKs & UI Libraries] Decomposing UI Components
With this post, I’m continuing publishing the v2 of my book dedicated to APIs. If you like this book, please rate it on GitHub, Amazon, or Goodreads and
Let's transition to a more substantive conversation and try to understand why the requirement to allow the replacement of a component's subsystems with alternative implementations leads to a dramatic increase in interface complexity. We will continue studying the SearchBox
component from the previous chapter. Allow us to remind the reader of the factors that complicate the design of APIs for visual components:
Coupling heterogeneous functionality (such as business logic, appearance styling, and behavior) into a single entity
Introducing shared resources, i.e. an object state that could be simultaneously modified by different actors, including the end user
The emergence of ambivalent hierarchies in the inheritance of entity properties and options.
Let's make the task more specific. Imagine that we need to develop a SearchBox
that allows for the following modifications:
Replacing the textual paragraphs representing an offer with a map with markers that could be highlighted:
Search results on a map
This illustrates the problem of replacing a subcomponent (the offer list) while preserving the behavior and design of other parts of the system as well as the complexity of implementing shared states.
Combining short and full descriptions of an offer in a single UI (a list item could be expanded, and the order can be created in-place):
A list of offers with short descriptions
A list of offers with some of them expanded
This illustrates the problem of fully removing a subcomponent and transferring its business logic to other parts of the system.
Manipulating the data presented to the user and the available actions for an offer through adding new buttons, such as “Previous offer,” “Next offer,” and “Make a call.”
An offer panel with additional icons and buttons
In this scenario, we're evaluating different chains of propagating data and options down to the offer panel and building dynamic UIs on top of it:
Some data fields (such as the logo and phone number) are properties of a real object received in the search API response.
Some data fields make sense only in the context of this specific UI and reflect its design principles (for instance, the “Previous” and “Next” buttons).
Some data fields (such as the icons of the “Not now” and “Make a call” buttons) are bound to the button type (i.e., the business logic it provides).
The obvious approach to tackling these scenarios appears to be creating two additional subcomponents responsible for presenting a list of offers and the details of the specific offer. Let's name them OfferList
and OfferPanel
respectively.
The subcomponents of a `SearchBox`
If we had no customization requirements, the pseudo-code implementing interactions between all three components would look rather trivial:
class SearchBox implements ISearchBox {
// The responsibility of `SearchBox` is:
// 1. Creating a container for rendering
// an offer list, preparing option values
// and creating the `OfferList` instance
constructor(container, options) {
…
this.offerList = new OfferList(
this,
offerListContainer,
offerListOptions
);
}
// 2. Triggering an offer search when
// a user presses the corresponding button
// and providing an analogous programmable
// interface for developers
onSearchButtonClick() {
this.search(this.searchInput.value);
}
search(query) {
…
}
// 3. Notifying about new search results
// being received from the server
onSearchResultsReceived(searchResults) {
…
this.offerList.setOfferList(searchResults)
}
// 4. Creating orders (and manipulating
// subcomponents if needed)
createOrder(offer) {
this.offerList.destroy();
ourCoffeeSdk.createOrder(offer);
…
}
// 5. Self-destructing if requested
destroy() {
this.offerList.destroy();
…
}
}
class OfferList implements IOfferList {
// The responsibility of `OfferList` is:
// 1. Creating a container for rendering
// an offer panel, preparing option values
// and creating the `OfferPanel` instance
constructor(searchBox, container, options) {
…
this.offerPanel = new OfferPanel(
searchBox,
offerPanelContainer,
offerPanelOptions
);
…
}
// 2. Providing a method to change the list
// of offers to be presented
setOfferList(offerList) { … }
// 3. When an offer is selected, opening
// an offer panel to present it
onOfferClick(offer) {
this.offerPanel.show(offer)
}
// 4. Self-destructing if requested
destroy() {
this.offerPanel.destroy();
…
}
}
class OfferPanel implements IOfferPanel {
constructor(
searchBox, container, options
) { … }
// The responsibility of `OfferPanel` is:
// 1. Presenting an offer
show(offer) {
this.offer = offer;
…
}
// 2. Creating an order when the user
// presses the “Place an order” button
onCreateOrderButtonClick() {
this.searchBox.createOrder(this.offer);
}
// 3. Closing itself when the user
// presses the “Not now” button
onCancelButtonClick() {
// …
}
// 4. Self-destructing if requested
destroy() { … }
}
The ISearchBox
/ IOfferPanel
/ IOfferView
interfaces are concise as well (constructors and destructors omitted):
interface ISearchBox {
search(query);
createOrder(offer);
}
interface IOfferList {
setOfferList(offerList);
}
interface IOfferPanel {
show(offer);
}
If we aren't making an SDK and have not had the task of making these components customizable, the approach would be perfectly viable. However, let's discuss how we would solve the three sample tasks described above.
Displaying an offer list on the map: at first glance, we can develop an alternative component for displaying offers that implements the
IOfferList
interface (let's call itOfferMap
) and reuses the standard offer panel. However, we have a problem:OfferList
only sends commands toOfferPanel
whileOfferMap
also needs to receive feedback — an event of panel closure to deselect a marker. The API of our components does not encompass this functionality, and implementing it is not that simple:
class CustomOfferPanel extends OfferPanel {
constructor(
searchBox, offerMap, container, options
) {
this.offerMap = offerMap;
super(searchBox, container, options);
}
onCancelButtonClick() {
offerMap.resetCurrentOffer();
super.onCancelButtonClick();
}
}
class OfferMap implements IOfferList {
constructor(searchBox, container, options) {
…
this.offerPanel = new CustomOfferPanel(
this,
searchBox,
offerPanelContainer,
offerPanelOptions
)
}
resetCurrentOffer() { … }
…
}
We have to create a
CustomOfferPanel
class, and this implementation, unlike its parent class, now only works withOfferMap
, not with anyIOfferList
-compatible component.The case of making full offer details and action controls in place in the offer list is pretty obvious: we can achieve this only by writing a new
IOfferList
-compatible component from scratch because whatever overrides we apply to the standardOfferList
, it will continue creating anOfferPanel
and open it upon offer selection.To implement new buttons, we can only propose to developers to create a custom offer list component (to provide methods for selecting previous and next offers) and a custom offer panel that will call these methods. If we find a simple solution for customizing, let's say, the “Place an order” button text, this solution needs to be supported in the
OfferList
code:
let searchBox = new SearchBox(…, {
offerPanelCreateOrderButtonText:
'Drink overpriced coffee!'
});
class OfferList {
constructor(…, options) {
…
// It is `OfferList`'s responsibility
// to isolate the injection point and
// to propagate the overridden value
// to the `OfferPanel` instance
this.offerPanel = new OfferPanel(…, {
createOrderButtonText: options
.offerPanelCreateOrderButtonText
…
})
}
}
The solutions we discuss are also poorly extendable. For example, in #1, if we decide to make the offer list react to the closing of an offer panel as a part of the standard interface for developers to use, we will need to add a new method to the IOfferList
interface and make it optional to maintain backward compatibility:
interface IOfferList {
…
onOfferPanelClose?();
}
In the OfferPanel
code, the support of this new method will look like:
if (Type(this.offerList.onOfferPanelClose)
== 'function') {
this.offerList.onOfferPanelClose();
}
Certainly, this will not make our code any cleaner. Additionally, OfferList
and OfferPanel
will become even more tightly coupled.
As we discussed in the “Weak Coupling” chapter, to solve such problems we need to reduce the strong coupling of the components in favor of weak coupling, for example, by generating events instead of calling methods directly. An IOfferPanel
could have emitted a 'close'
event, so that an OfferList
could have listened to it:
class OfferList {
setup() {
…
this.offerPanel.events.on(
'close',
function () {
this.resetCurrentOffer();
}
)
}
…
}
This code looks more sensible but doesn't eliminate the mutual dependencies of the components: an OfferList
still cannot be used without an OfferPanel
as required in Case #2.
Let us note that all the code samples above are a full chaos of abstraction levels: an OfferList
instantiates an OfferPanel
and manages it directly, and an OfferPanel
has to jump over levels to create an order. We can try to unlink them if we route all calls through the SearchBox
itself, for example, like this:
class SearchBox() {
constructor() {
this.offerList = new OfferList(…);
this.offerPanel = new OfferPanel(…);
this.offerList.events.on(
'offerSelect', function (offer) {
this.offerPanel.show(offer);
}
);
this.offerPanel.events.on(
'close', function () {
this.offerList
.resetSelectedOffer();
}
);
}
}
Now OfferList
and OfferPanel
are independent, but we have another issue: to replace them with alternative implementations we have to change the SearchBox
itself. We can go even further and make it like this:
class SearchBox {
constructor() {
…
this.offerList.events.on(
'offerSelect', function (event) {
this.events.emit('offerSelect', {
offer: event.selectedOffer
});
}
);
}
…
}
So a SearchBox
just translates events, maybe with some data alterations. We can even force the SearchBox
to transmit any events of child components, which will allow us to extend the functionality by adding new events. However, this is definitely not the responsibility of a high-level component, being mostly a proxy for translating events. Also, using these event chains is error prone. For example, how should the functionality of selecting a next offer in the offer panel (Case #3) be implemented? We need an OfferList
to both generate an 'offerSelect'
event and react when the parent context emits it. One can easily create an infinite loop of it:
class OfferList {
constructor(searchBox, …) {
…
searchBox.events.on(
'offerSelect',
this.selectOffer
);
}
selectOffer(offer) {
…
this.events.emit(
'offerSelect', offer
);
}
}
class SearchBox {
constructor() {
…
this.offerList.events.on(
'offerSelect', function (offer) {
…
this.events.emit(
'offerSelect', offer
);
}
);
}
}
To avoid infinite loops, we could split the events:
class SearchBox {
constructor() {
…
// An `OfferList` notifies about
// low-level events, while a `SearchBox`,
// about high-level ones
this.offerList.events.on(
'click', function (target) {
…
this.events.emit(
'offerSelect',
target.dataset.offer
);
}
);
}
}
Then the code will become ultimately unmaintainable: to open an OfferPanel
, developers will need to generate a 'click'
event on an OfferList
instance.
In the end, we have already examined five different options for decomposing a UI component employing very different approaches, but found no acceptable solution. Obviously, we can conclude that the problem is not about specific interfaces. What is it about, then?
Let us formulate what the responsibility of each of the components is:
SearchBox
presents the general interface. It is an entry point both for users and developers. If we ask ourselves what a maximum abstract component still constitutes aSearchBox
, the response will obviously be “the one that allows for entering a search phrase and presenting the results in the UI with the ability to place an order.”OfferList
serves the purpose of showing offers to users. The user can interact with a list — iterate over offers and “activate” them (i.e., perform some actions on a list item).OfferPanel
displays a specific offer and renders all the information that is meaningful for the user. There is always exactly oneOfferPanel
. The user can work with the panel, performing actions related to this specific offer (including placing an order).
Does the SearchBox
description entail the necessity of OfferList
's existence? Obviously, not: we can imagine quite different variants of UI for presenting offers to the users. An OfferList
is a specific case of organizing the SearchBox
's functionality for presenting search results. Conversely, the idea of “selecting an offer” and the concepts of OfferList
and OfferPanel
performing different actions and having different options are equally inconsequential to the SearchBox
definition. At the SearchBox
level, it doesn't matter how the search results are presented and what states the corresponding UI could have.
This leads to a simple conclusion: we cannot decompose SearchBox
just because we lack a sufficient number of abstraction levels and try to jump over them. We need a “bridge” between an abstract SearchBox
that does not depend on specific UI and the OfferList
/ OfferPanel
components that present a specific case of such a UI. Let us artificially introduce an additional abstraction level (let us call it a “Composer”) to control the data flow:
class SearchBoxComposer
implements ISearchBoxComposer {
// The responsibility of a “Composer” comprises:
// 1. Creating a context for nested subcomponents
constructor(searchBox, container, options) {
…
// The context consists of the list of offers
// and the current selected offer
// (both could be empty)
this.offerList = null;
this.currentOffer = null;
// 2. Creating subcomponents and translating
// their options
this.offerList = this.buildOfferList();
this.offerPanel = this.buildOfferPanel();
// 3. Managing own state and notifying
// about state changes
this.searchBox.events.on(
'offerListChange', this.onOfferListChange
);
// 4. Listening
this.offerListComponent.events.on(
'offerSelect', this.selectOffer
);
this.offerPanelComponent.events.on(
'action', this.performAction
);
}
}
The builder methods to create subcomponents, manage their options and potentially their position on the screen would look like this:
class SearchBoxComposer {
…
buildOfferList() {
return new OfferList(
this,
this.offerListContainer,
this.generateOfferListOptions()
);
}
buildOfferPanel() {
return new OfferPanel(
this,
this.offerPanelContainer,
this.generateOfferPanelOptions()
);
}
}
We can put the burden of translating contexts on SearchBoxComposer
. In particular, the following tasks could be handled by the composer:
Preparing and translating the data. At this level we can stipulate that an
OfferList
shows short information (a “preview”) about the offer, while anOfferPanel
presents full information, and provide potentially overridable methods of generating the required data facets:
class SearchBoxComposer {
…
onContextOfferListChange(offerList) {
…
// A `SearchBoxComposer` translates
// an `offerListChange` event as
// an `offerPreviewListChange` for the
// `OfferList` subcomponent, thus preventing
// an infinite loop in the code, and prepares
// the data
this.events.emit('offerPreviewListChange', {
offerList: this.generateOfferPreviews(
this.offerList,
this.contextOptions
)
});
}
}
Managing the composer's own state (the
currentOffer
field in our case):
class SearchBoxComposer {
…
onContextOfferListChange(offerList) {
// If an offer is shown when the user
// enters a new search phrase,
// it should be hidden
if (this.currentOffer !== null) {
this.currentOffer = null;
// This is an event specifically
// for the `OfferPanel` to listen to
this.events.emit(
'offerFullViewToggle',
{ offer: null }
);
}
…
}
}
Transforming user's actions on a subcomponent into events or actions on the other components or the parent context:
class SearchBoxComposer {
…
public performAction({
action, offerId
}) {
switch (action) {
case 'createOrder':
// The “place an order” action is
// to be handled by the `SearchBox`
this.createOrder(offerId);
break;
case 'close':
// The closing of the offer panel
// event is to be exposed publicly
if (this.currentOffer != null) {
this.currentOffer = null;
this.events.emit(
'offerFullViewToggle',
{ offer: null }
);
}
break;
…
}
}
}
If we revisit the cases we began this chapter with, we can now outline solutions for each of them:
Presenting search results on a map doesn't change the concept of the list-and-panel UI. We need to implement a custom
IOfferList
and override thebuildOfferList
method in the composer.Combining the list and the panel functionality contradicts the UI concept, so we will need to create a custom
ISearchBoxComposer
. However, we can reuse the standardOfferList
as the composer manages both the data for it and the reactions to the user's actions.Enriching the data is compatible with the UI concept, so we continue using standard components. What we need is overriding the functionality of preparing
OfferPanel
's data and options, and implementing additional events and actions for the composer to translate.
The price of this flexibility is the overwhelming complexity of component communications. Each event and data field must be propagated through the chains of such “composers” that elongate the abstraction hierarchy. Every transformation in this chain (for example, generating options for subcomponents or reacting to context events) is to be implemented in an extendable and parametrizable way. We can only offer reasonable helpers to ease using such customization. However, in the SDK code, the complexity will always be present. This is the way.
The reference implementation of all the components with the interfaces we discussed and all three customization cases can be found in this book's repository:
The source code is available on www.github.com/twirl/The-API-Book/docs/examples
There are also additional tasks for self-study
The sandbox with “live” examples is available on twirl.github.io/The-API-Book.
This is Chapter 44 of “The API” book being written by Sergey Konstantinov. I also have a book on the history of beer and historical beer styles, a Telegram channel on interesting classical music recordings, a travel photo blog on Unsplash, and a website with ranking fantasy & science fiction novels based on awards they received.