Building a UI Component in 2017 and Beyond
As web developers, we have seen the guidelines for building UI components evolve over the years. Starting from jQuery UI to the current Custom Elements, various patterns have emerged. To top it off, there are numerous libraries and frameworks, each advocating their own style on how a component should be built. So in today’s world, what would be the best approach in terms of thinking about a UI component interface? That is the essence of this blog. Huge thanks to folks mentioned in the bottom Acknowledgments section. Also, this post leverages a lot of learnings from the articles and projects listed in the Reference section at the end.
Setting the context
Before we get started, let’s set the context on what this post covers
- By UI components, we mean the core, standalone UI patterns that apply to any web page in general. Examples would be Button, Dropdown Menu, Carousel, Dialog, Tab, etc. Organizations in general maintain a pattern library for these core components. We are NOT talking about application-specific components here, like a Photo Viewer used in social apps or an Item Card used in eCommerce apps. They are designed to solve application-specific (social, eCommerce etc.) use cases. Application components are usually built using core UI components and are tied with a JavaScript framework (Marko, Vue.js, React, etc.) to create a fully featured web page.
- We will only be talking about the interface (that is, API) of the component and how to communicate. We will not go over the implementation details in depth, just a quick overview.
Declarative HTML based interface
Our fundamental principle behind building a UI component API is to make it agnostic in regard to any library or framework. This means the API should be able to work in any context and be framework-interoperable. Any popular framework can just leverage the interface out of the box and augment it with their own implementation. With this principle in mind, what would be the best way to get started? The answer is pretty simple — make the component API look like how any other HTML element would look like. Just like how a <div>
, <img>
, or a <video>
tag would work. This is the idea behind having a declarative HTML based interface/API.
So how does a component look like? Let’s take carousel as an example, this is how the component API will look like
<my-carousel index="2" controls label-next="next" label-previous="previous" autoplay>
<div>Markup for Item 1</div>
<div>Markup for Item 2</div>
...
</my-carousel>
What does this mean? We are declaratively telling the component that the rendered markup should do the following.
- Start at the second item in the carousel
- Display the left and right arrow key controls
- Use “previous” and “next” as the
aria-label
attribute values for the left and right arrow keys - Also autoplay the carousel after it is loaded
For a consumer, this is the only information they need to know to include this component. It is exactly like how you include a <button>
or <canvas>
HTML element. Component names are always lowercase . They should also be hyphenated to distinguish them from native HTML elements. A good suggestion would be to prefix them with a namespace, usually your organization or project name, for example ebay-
, core-
, git-
, etc.
Attributes form the base on how you will pass the initial state (data and configuration) to a component. Let’s talk about them.
Attributes
- An attribute is a name-value pair where the value is always a string. Now the question may arise that anything can be serialized as a string and the component can de-serialize the string to the associated data type (JSON for example). While that is true, the guideline is to NOT do that. A component can only interpret the value as a String (which is default) or a Number (similar to tabindex) or a JavaScript event handler (similar to DOM on-event handlers). Again, at the end of the day, this is exactly how an HTML element works.
- Attributes can also be boolean. As per the HTML5 spec, “The presence of a boolean attribute on an element represents the true value, and the absence of the attribute represents the false value.” This means that as a component developer, when you need a boolean attribute, just check for the presence of it on the element and ignore the value. Having a value for it has no significance; both creator and consumer of a component should follow the same. For example,
<button disabled="false">
will still disable the button, even if it is set tofalse
, just because the boolean attributedisabled
is present. - All attribute names should be lowercase. Camel case or Pascal case is NOT allowed. For certain multiword attributes, hyphenated names like
accept-charset
,data-*
etc. can be used. Even for multiwords, try your best to keep them as one lowercased name, for example,crossorigin
,contenteditable
, etc. Check out the HTML attribute reference for tips on how the native elements are doing it.
We can correlate the above attribute rules with our <my-carousel>
example.
label-next
andlabel-previous
as string attributes. We hyphenate them as they are multiwords, very similar to the HTMLaria-label
attribute.index
attribute will be deserialized as a number, to indicate the position of the item to be displayed.controls
andautoplay
will be considered as boolean attributes.
A common pattern that used to exist (or still exists) is to pass configuration and data as JSON strings. For our carousel, it would be something like the below example.
Here the component developer reads the data attribute data-config
, does a JSON parse, and then initializes the component with the provided configuration. They also build the items of the carousel using data-items
. This may not be intuitive and it works against a natural HTML-based approach. Instead consider a declarative API as proposed above, which is easy to understand and aligns with the HTML spec. Finally, in the case of a carousel, give the component consumers the flexibility to build the carousel items however they want. This decouples a core component from the context in which it is going to be used, which is usually application-specific.
Array based
There will be scenarios where you really need to pass an array of items to a core component, for example, a dropdown menu. How to do this declaratively? Let’s see how HTML does it. Whenever any input is a list, HTML uses the <option>
element to represent an item in that list. As a reference, check out how the <select>
and <datalist>
elements leverage the <option>
element to list out an array of items. Our component API can use the same technique. So in the case of a dropdown menu, the component API would look like the following.
It is not necessary that we should always use the <option>
element here. We could create our own element, something like <dropdown-option>
, which represents children of the <dropdown-menu>
component and customize it however we want. For example, if you have an array of objects, you can represent each object ({"userName": "jdoe", "score": 99, "displayName": "John Doe"}
) declaratively in the markup as <dropdown-option value="jdoe" score="99">John Doe</dropdown-option>
. Hopefully you do not need a complex object for a core component.
Config based
You may also argue that there is a scenario where I need to pass a JSON config for it to work or else usability becomes painful. Although this is a rare scenario for core components, a use case I can think about will be a core analytics component. This component may not have a UI, but it does all tracking related stuff, where you need to pass in a complex JSON object. What do we do? The AMP Project has a good solution for this. The component would look like the following.
Here again we piggyback the interface based on how we would do it in simple HTML. We use a <script>
element inside the component and set the type
to application/json
, which is exactly what we want. This brings back the declarative approach and makes it look natural.
Communication
Till now we talked only about the initial component API. This enables consumers to include a component in a page and set the initial state. Once the component is rendered in the browser, how do you interact with it? This is where the communication mechanism comes into play. And for this, the golden rule comes from the reactive principles of
Data in via attributes and properties, data out via events
This means that attributes and properties can be used to send data to a component and events send the data out. If you take a closer look, this is exactly how any normal HTML element (input
, button
etc.) behaves. We already discussed attributes in detail. To summarize, attributes set the initial state of a component, whereas properties update or reflect the state of a component. Let’s dive into properties a bit more.
Properties
At any point in time, properties are your source of truth. After setting the initial state, some attributes do not get updated as the component changes over time. For example, typing in a new phrase in an input text box and then calling element.getAttribute('value')
will produce the previous (stale) value. But doing element.value
will always produce the current typed-in phrase. Certain attributes like disabled
do get reflected when the corresponding property is changed. There has always been some confusion around this topic, partly due to legacy reasons. It would be ideal for attributes and properties to be in sync, as the usability benefits are undeniable.
If you are using Custom Elements, implementing properties is quite straightforward. For a carousel, we could do this.
Here the index
property gets all its associated characteristics. If you are doing carouselElement.index=4
, it will update the internal state and then perform the corresponding DOM operations to move the carousel to the fourth item. Additionally, even if you directly update the attribute carouselElement.setAttribute('index', 4)
, the component will still update the index
property and the internal state and perform the exact DOM operations to move the carousel to the fourth item.
However, until Custom Elements gain massive browser adoption and have a good server side rendering story, we need to come up with other mechanisms to implement properties. And one way would be to use the Object.defineProperty() API.
Here we are augmenting the carousel element DOM node with the index
property. When you do carouselElement.index=4
, it gives us the same functionality as the Custom Element implementation. But directly updating an attribute carouselElement.setAttribute('index', 4)
, will do nothing. This is the tradeoff in this approach (technically we could still use a MutationObserver to achieve the missing functionality). Hopefully as a team, if you can standardize that state updates should only happen through properties, then it should be less of a concern.
With respect to naming conventions, since properties are accessed programmatically, they should always be Camel Cased. All exposed attributes (an exception would be ARIA attributes) should have a corresponding camel cased property, very similar to native DOM elements.
Events
When the state of a component has changed, either programmatically or due to user interaction, it has to communicate the change to the outside world. And the best way to do it is by dispatching events, very similar to click
or touchstart
events dispatched by a native HTML element. The good news is DOM comes with a built-in custom eventing mechanism through the CustomEvent constructor. So in the case of a carousel, we can tell the outside world that a slide change happened in the carousel by dispatching a slidechange
event as shown below.
By doing this, we get all the benefits of DOM events like bubbling, capture etc. and also the event APIs like event.stopPropagation()
, event.preventDefault()
etc. Another added advantage is that it makes the component framework-agnostic, as most frameworks already have built-in mechanisms for listening to DOM events. Checkout Rob Dodson’s post on how this works with major frameworks.
Regarding a naming convention for events, I would go with the same guidelines that we listed above for attribute names. Again, when in doubt, look at how the native DOM does it.
Implementation
Let me briefly touch upon the implementation details, as they give the full picture. We have been only talking about the component API and communication patterns till now. But the critical missing part is that we still need JavaScript to provide the desired functionality and encapsulation. Some components can be purely markup and CSS based, but in reality, most of them will require some amount of JavaScript. How do we implement this JavaScript? Well, there a couple of ways.
- Use vanilla JavaScript. Here the developer builds their own JavaScript logic for each component. But you will soon see a common pattern across components, and the need for abstraction arises. This abstraction library will pretty much be similar to those numerous frameworks out in the wild. So why reinvent the wheel? We can just choose one of them.
- Usually in organizations, web pages are built with a particular library or framework (Angular, Ember, Preact, etc.). You can piggyback on that library to implement the functionality and provide encapsulation. The drawback here is that your core components are also tied with the page framework. So in case you decide to move to a different framework or do a major version upgrade, the core components should also change with it. This can cause a lot of inconvenience.
- You can use Custom Elements. That would be ideal, as it comes default in the browsers, and the browser makers recommend it. But you need a polyfill to make it work across all of them. You can try a Progressive Enhancement technique as described here, but you would lose functionality in non-supportive browsers. Moreover, until we have a solid and performant server side rendering mechanism, Custom Elements would lack mass adoption.
And yes, all options are open-ended. It all boils down to choices, and software engineering is all about the right tradeoffs. My recommendation would be to go with either Option 2 or 3, based on your use cases.
Conclusion
Though the title mentions the year “2017”, this is more about building an interface that works not only today but also in the future. We are making the component API agnostic of the underlying implementation. This enables developers to use a library or framework of their choice, and it gives them the flexibility to switch in the future (based on what is popular at that point in time). The key takeaway is that the component API and the principles behind it always stay the same. I believe Custom Elements will become the default implementation mechanism for core UI components as soon as they gain mainstream browser adoption.
The ideal state is when a UI component can be used in any page, without the need of a library or polyfill and it can work with the page owner’s framework of choice. We need to design our component APIs with that ideal state in mind and this is a step towards it. Finally, worth repeating, when in doubt, check how HTML does it, and you will probably have an answer.
Acknowledgments
Many thanks to Rob Dodson and Lea Verou for their technical reviews and valuable suggestions. Also thanks to my colleagues Ian McBurnie, Arun Selvaraj, Tony Topper and Andrew Wooldridge for their valuable feedback.