As browsers start shipping Custom Elements and Shadow DOM v1, it’s a good time to start learning about these new specs and understand how they could (or couldn’t) fit into a modern development workflow. Component-based apps and centralized app states keep gaining traction, and as a new specification rises, it’s tempting to reevaluate the need for libraries and contemplate native implementations of these attractive mental models.
Custom Elements and Shadow DOM are distinct standards that work together to let you build reusable, self-contained components. In that regard, they’re aligned with React. In terms of API, however, they look quite different. Let’s make something similar to React’s introductory HelloMessage component as an example:
This simply renders “Hello Jane”. Now, if we want to stay closer to React’s original example, we need to pass “Jane” as an attribute instead of as the content of the element. That’s great news, as it gives us a chance to introduce new concepts!
attributeChangedCallback will fire whenever any attribute listed in the observedAttributes array changes which, in this case, will insert the person’s name in the slot. Thus, this component renders the same “Hello Jane” as before. It might not seem amazing yet, but this structure gets you actually close to being able to build real, nested components.
Let’s take the canonical todolist example to get started. A todo-list parent component will fetch the todos from a global state and generate a todo-item for each of them.
It’s a good start, but our todolist isn’t very useful yet; we should at least make it possible to check some todos. In order to do that, we’ll need to make a few conceptual changes:
- We need a rendering method. When a todo is completed, the global state changes, and the list should update accordingly.
- We want our global state to automatically trigger a re-render when a todo is removed so our components only care about the state.
- We need to know which item has been clicked in order to remove it from the state.
Here’s how our code now looks like:
The todo-list passes an index to each todo-item it creates, which allows them to filter the state when they’re checked. The store asks for a render whenever it gets updated, which simply rebuilds the root todo-list component and replaces the content of its parent with it. Finally, we initialize our app with an array of todos.
While this specific example is trivial, it contains most of the building blocks we need to develop significant interfaces. We already have a centralized state with top-down data flow, stateless components with a declarative API, DOM and CSS encapsulation, and very little boilerplate code. It doesn’t require too much imagination to leverage this example and build a better structure, for example by implementing Redux-like actions and reducers. So, while most of these patterns are widely accepted, there’s one implementation detail I’d like to discuss.
You’ve probably noticed that instead of using a virtual DOM and some clever diffing techniques, I’m going the brute force way by literally rebuilding the entire DOM on each state change. This is obviously fine for such a basic example, but you might think this would be a showstopper for a real app. I’d like to question this assumption.
When your app state changes, your UI changes accordingly in as many places as needed. Most of the time, several elements need to be updated. For example, editing a list on iOS typically means adding a checkbox on each row, a status bar, a cancel and a save button, etc. Modifying all these elements independently is quite expensive, and a common best practice is to “batch” your DOM updates in order to have a single reflow. This is basically what the approach described above is doing. Rerendering the entire DOM tree might look wasteful and overkill at first sight, but in practice, it’s not quite as bad as it sounds.
As a quick example, I built a little stress-test. Every time you click, a thousand custom elements with shadow DOMs attached are generated and inserted in the DOM. The parent of these elements and the elements themselves are using Flexbox, which is one of the most expensive layout methods. Finally, they all use random sizes and colors.
On a modest Core i5, it’s basically instant in Safari and Chrome. I don’t know how representative of a real app’s paint time this test actually is, but it gives at least a rough idea on the rendering speed. My gut feeling is that it should be fine for most apps, especially if you consider conditional rendering.