Hello everyone! My name is Arthur, and I work as a frontend developer at Exness. Recently, we migrated one of our projects to web components. I will share the challenges I ran into, and how many concepts you are used to from framework development map quite naturally to web components.
To cut to the chase: the shipped version held up well with our broad user base, and we cut bundle size and load time substantially.
I assume you already have a basic grasp of the technology, but even without it, you should get a sense that web components are workable in practice and can support a sensible project architecture.
The project was small but strict about bundle size; the main UI pieces were forms. Some would argue you could ship plain HTML and JavaScript and stay as light as possible. When you need to maintain and extend a codebase, though, dropping component-oriented development feels like a step backward. You could also reach for Svelte or, say, Preact. In the end, building on native primitives alone was too appealing to pass up.
Most modern browsers, including on mobile, ship web components without extra setup. Where they do not, you can fall back on polyfills. Notably, there is a small loader (~2 kB) that skips any polyfill the browser already provides natively, so modern browsers pick up almost nothing extra. The package claims support back to IE11 and older Edge builds. We do not target IE in our projects; in practice everything behaves as expected in Edge. The same holds for UC Browser and QQ Browser in China, mobile builds included.
A few caveats with these polyfills:
- The
<slot>polyfill for IE11 and Edge does not take part in event bubbling. - This bundle does not cover extending native HTML elements (customized built-ins). More on that below.
“So much boilerplate to build components and wire props reactively! I need a shortcut—a class extending HTMLElement that handles all of that.” That was my first reaction when digging into web components. As it turned out, I did not have to hand-roll it: LitElement (~7 kB) does the heavy lifting, and the lit-html layer inside it gives you an ergonomic, optimized renderer with straightforward data binding and event wiring.
LitElement also layers helpful lifecycle helpers on top of the standard web component lifecycle, so it feels close to what you know from mainstream frameworks.
Yes, LitElement is another dependency—some will call it “another framework.” I still accepted the extra ~7 kB on purpose: it reads as a thin, natural extension of the platform APIs. Either way, some version of that glue would have lived in the project if only to trim boilerplate.
This is only tangential to the article, but lit-html packs into ~3.5 kB a very handy way to describe UI with plain functions. Updates to the DOM for those “function components” stay granular: only the slices whose values changed since the last render are touched. With enough care, you can sketch the whole UI from functions, decorators, and directives (more on those shortly):
import { html, render } from 'lit-html'
const ui = data => html`...${data}...`
render(ui('Hello!'), document.body)Templates can nest one inside another:
const myHeader = html`<h1>Header</h1>`
const myPage = html`
${myHeader}
<div>Here's my main page.</div>
`You can also wrap a function in a real custom element when you need a tag on the wire:
const defineFxComponent = (tagName, FxComponent, Parent = LitElement) => {
const Component = class extends Parent {
render() {
return FxComponent(this.data)
}
}
customElements.define(tagName, Component)
}
defineFxComponent('custom-ui', ui)
render(html`<custom-ui .data="Hello!"></custom-ui>`, document.body)I will not walk through every nicety of lit-html—templates, styling, attributes, data binding, events, conditionals, and lists are all covered in the documentation. Instead, here is what you can easily skim past in the guide but may still need day-to-day.
The easiest piece to overlook is the svg tagged template literal: it is absent from the main guide but documented in the API. It exists for SVG, where the generic html helper can bite you. I hit that when piping a TemplateResult from a plain html template into my icon component—bogus closing tags showed up and nothing drew. Switching to svg and an SVGTemplateResult fixed it.
Directives are functions that control how their slice of template output is handled. lit-html relies on classes implementing the Part interface to persist values and reflect them in the DOM. That Part layer is what makes incremental updates possible; directives are your hook into the same machinery.
There are five directive families, each tied to a concrete Part type:
- Content (NodePart)
- Attributes (AttributePart)
- Boolean attributes (BooleanAttributePart)
- Properties (PropertyPart)
- Event listeners (EventPart)
Each kind only makes sense in the matching template position.
A directive keeps the last rendered value in value. Call setValue() to stage a new value, and commit() if you need to flush DOM updates after render completes—handy for async work.
Here is a tiny custom directive with NodePart access that tracks how many times it rendered:
import { directive } from 'lit-html'
const renderCounter = directive(() => part =>
part.setValue(part.value === undefined ? 0 : part.value + 1)
)lit-html ships a solid built-in directive set: React-hook-like helpers, class/style object helpers, async rendering utilities, guarded unsafeHTML, and more.
You can keep richer state beside a directive when the built-ins are not enough—see the stateful directive example.
Custom directives (and decorators) can stand in for higher-order components, but you own the reactivity story inside the directive. One illustration is lit-redux.
If you pair lit-html with Shadow DOM and must support older browsers, you need the Shady DOM / Shady CSS polyfills. lit-html exposes a dedicated shady-render entry point that wires those polyfills in for you.
Higher-order components are a familiar pattern in React and Vue: they keep composition short and explicit. I wanted something similar for web components. Because custom elements are just classes, my stand-in for an HOC became a function that takes a class and returns an extended subclass.
The project used Redux, so take lite-redux as an example. The snippet below is a factory that accepts a store and returns a classic connect-style wrapper. Inside the wrapper, every mapStateToProps along the inheritance chain is collected (including nested HOCs that also talk to Redux). When the element mounts, a single store subscription refreshes all of them; when it unmounts, the subscription is torn down.
import { bindActionCreators } from 'redux'
export default store => (mapStateToProps, mapDispatchToProps) => Component =>
class Connect extends Component {
constructor(props) {
super(props)
this._getPropsFromStore = this._getPropsFromStore.bind(this)
this._getInheritChainProps = this._getInheritChainProps.bind(this)
// Collect mapStateToProps from the inheritance chain
this._inheritChainProps = (this._inheritChainProps || []).concat(
mapStateToProps
)
}
// Function for getting data from store
_getPropsFromStore(mapStateToProps) {
if (!mapStateToProps) return
const state = store.getState()
const props = mapStateToProps(state)
for (const prop in props) {
this[prop] = props[prop]
}
}
// Callback to subscribe to the store change, which will call all mapStateToProps from the inheritance chain
_getInheritChainProps() {
this._inheritChainProps.forEach(i => this._getPropsFromStore(i))
}
connectedCallback() {
this._getPropsFromStore(mapStateToProps)
this._unsubscriber = store.subscribe(this._getInheritChainProps)
if (mapDispatchToProps) {
const dispatchers =
typeof mapDispatchToProps === 'function'
? mapDispatchToProps(store.dispatch)
: mapDispatchToProps
for (const dispatcher in dispatchers) {
typeof mapDispatchToProps === 'function'
? (this[dispatcher] = dispatchers[dispatcher])
: (this[dispatcher] = bindActionCreators(
dispatchers[dispatcher],
store.dispatch,
() => store.getState()
))
}
}
super.connectedCallback()
}
disconnectedCallback() {
// Remove subscription to change store
this._unsubscriber()
super.disconnectedCallback()
}
}The usual pattern is to create the store once, then export a ready-made connect helper you can treat like any other HOC:
// store.js
import { createStore } from 'redux'
import makeConnect from 'lite-redux'
import reducer from './reducer'
const store = createStore(reducer)
export default store
// Create a standard connector
export const connect = makeConnect(store)// Component.js
import { connect } from './store'
class Component extends WhatEver {
/* ... */
}
export default connect(mapStateToProps, mapDispatchToProps)(Component)Often you need to wrap not only behavior but the template itself. The cleanest path is to call through to the wrapped class’s render(). You may also need to widen the reactive surface—observedAttributes for vanilla custom elements, or static get properties() for LitElement. Here is a password field that wraps a plain text input component:
const withPassword = Component =>
class PasswordInput extends Component {
static get properties() {
return {
// Assume super.properties already contains type property
...super.properties,
addonIcon: { type: String }
}
}
constructor(props) {
super(props)
this.type = 'password'
this.addonIcon = 'invisible'
}
setType(e) {
this.type = this.type === 'text' ? 'password' : 'text'
this.addonIcon = this.type === 'password' ? 'invisible' : 'visible'
}
render() {
return html`
<div class="with-addon">
<!-- Wrapped component output -->
${super.render()}
<div @click=${this.setType}>
<custom-icon icon=${this.addonIcon}></custom-icon>
</div>
</div>
`
}
}
customElements.define('password-input', withPassword(TextInput))Two lines deserve emphasis: ...super.properties inside static get properties() lets you inherit the wrapped component’s reactive fields without re-declaring them, and super.render() drops the wrapped markup exactly where you need it in the outer template.
If you lean on this pattern as an HOC substitute, keep a few pitfalls in mind:
- Watch names for collisions—easy to shadow a property or method from the wrapped class.
- Passing a class method straight into
@eventcan break if a subclass overrides it; you may end up with only the outermost HOC’s handler, not both. - Be explicit when subscribing to props injected by outer HOCs so you do not miss updates.
As I mentioned, most of the UI in that project was forms and inputs. I wanted one layer that handled form ergonomics end to end: holding and updating state (values, errors, touched flags), validation, submit and reset flows.
That problem is orthogonal to web components themselves, but it forced a packaging decision. Before landing on something that shipped, I tried three shapes: a classic custom element, a customized built-in <form>, and an HOC-style wrapper. That journey is worth a short detour.
The first plan was textbook: Shadow DOM for inputs and the form shell, HOCs on inputs to talk to the parent form, and a dedicated custom tag for the form on the page. Walk through it in order.
I still needed real <form> semantics from HTMLFormElement—Enter to submit, native validation hooks, and so on—without bolting a separate <form> next to my custom wrapper every time. At the same time, wrapping slotted children in an actual <form> broke form events; it looks as if <form> is not quite ready for that slot-in-the-middle layout yet.
The workaround was to pass the inner markup as a property on the custom form element—roughly the same idea as handing React a render prop:
// Form component
import { LitElement, html } from 'lit-element'
class LiteForm extends LitElement {
/* ...form logic... */
render() {
return html`<form @submit=${this.handleSubmit} method=${this.method}>
${this.formTemplate(this)}
</form>`
}
}
customElements.define('lite-form', LiteForm)// Form example
import { html, render } from 'lit-element'
const formTemplate = ({ values, handleBlur, handleChange, ...props }) =>
html`<input
.value=${values.firstName}
@input=${handleChange}
@blur=${handleBlur}
/>
<input
.value=${values.lastName}
@input=${handleChange}
@blur=${handleBlur}
/>
<button type="submit">Submit</button>`
const MyForm = html`<lite-form
method="POST"
.formTemplate=${formTemplate}
.onSubmit=${{/*...*/}}
.initialValues=${{/*...*/}}
.validationSchema=${{/*...*/}}
></lite-form>`
render(html`${MyForm}`, document.getElementById('root'))I did not want every input to thread form props and events by hand, and I needed first-class custom inputs plus nicer error surfacing. That pushed me toward small HOCs such as withField and withError.
Those HOCs had to locate their owning form on their own, or talk to it indirectly. I prototyped an event bus and a shared context—fine when there is exactly one form on the page—and ended up with something simpler that still worked:
// here the IS_LITE_FORM constant is the name of the boolean attribute that each element of the custom form has
const getFormClass = element => {
const form = element.closest(`[${IS_LITE_FORM}]`)
if (form) return form
const host = element.getRootNode().host
if (!host) throw new Error('Lite-form not found')
return host[IS_LITE_FORM] ? host : getFormClass(host)
}The idea is simple: walk outward until you hit an element flagged as the form host. Worth calling out getRootNode()—it lets the walk cross nested shadow roots, which you need for this kind of plumbing.
Using withField, I could greatly simplify the form template:
const formTemplate = props =>
html`<custom-input name="firstName"></custom-input>
<custom-input name="lastName"></custom-input>
<button type="submit">Submit</button>`Everything worked—until it did not. Before the Shadow DOM verdict, two more details matter.
CSS custom properties pierce shadow boundaries and are an easy way to theme from the page. Sometimes variables are not enough—for conditional layout you reach for shadow-only selectors:
:host([rtl]) {
text-align: right;
}
:host-context(body[dir='rtl']) .text {
text-align: right;
}:host(...) styles the component’s host only when the selector matches—here, right-align text when the host carries rtl.
:host-context(...) reacts to ancestors outside the shadow tree. In the snippet, .text inside the component tracks dir on body.
When events bubble out of a shadow tree, event.target is retargeted to the shadow host. That preserves encapsulation, but it also means the object you see at the end of the bubble chain may lack fields that existed on the original target.
Whether an event escapes a shadow root is governed by composed (and bubbles still has to be true). Most built-in DOM events are composed and bubble as you would expect.
First, autofill and password managers largely fail when the real <form> or inputs live under closed shadow roots, and the browser will not offer to remember credentials either. On my project that trade-off—call it a bug or a feature—was a blocker.
If the form body is slotted from the light DOM so the fields truly belong to the document tree, autofill can still work with a shadow-hosted form wrapper—but every ancestor between the page and those fields must also avoid hiding inputs in shadow. The constraints get so tight that skipping Shadow DOM altogether is simpler.
Second, there is no querySelector that drills through arbitrary shadow trees. For analytics and tag managers you end up chaining .shadowRoot.querySelector(...) by hand, which does not scale.
Turning Shadow DOM off in LitElement is a one-liner:
createRenderRoot() {
return this
}Giving up shadow roots also meant losing blur bubbling from nested inputs. I had relied on that inside withField to avoid threading blur manually; listening on the capture phase instead kept the same behavior.
Once Shadow DOM was off the table, extending HTMLFormElement directly looked attractive: markup stays a real <form>, all native events stay put, and the diff from the previous approach stayed small:
// Form component
class LiteForm extends HTMLFormElement {
connectedCallback() {
this.addEventListener('submit', this.handleSubmit)
}
disconnectedCallback() {
this.removeEventListener('submit', this.handleSubmit)
}
/* ...form logic... */
}
customElements.define('lite-form', LiteForm, { extends: 'form' })It behaved like a normal form, just with extra behavior layered on:
// Form example
const MyForm = html`<form
method="POST"
is="lite-form"
.onSubmit=${{...}}
.initialValues=${{...}}
.validationSchema=${{...}}
>
<custom-input name="firstName"></custom-input>
<custom-input name="lastName"></custom-input>
<button type="submit">Submit</button>
</form>`
render(html`${MyForm}`, document.getElementById('root'))Note how customElements.define pairs with the is attribute to say which built-in you are specializing, and how the { extends: 'form' } option names the tag being subclassed.
It worked beautifully—in every browser except Apple’s. Safari still refuses customized built-ins on security grounds, and every iOS browser inherits that limitation regardless of branding. Apple may revisit the stance someday, but through Safari 13 and matching iOS releases nothing changed. Polyfills exist, yet I shelved customized built-ins until first-class Safari support lands.
After two dead ends, I moved the shared form logic into an HOC and wrapped whatever concrete form classes I needed. The refactor was mostly mechanical:
// Form higher-order component
export const withForm = ({
onSubmit,
initialValues,
validationSchema,
...config
} = {}) => Component =>
class LiteForm extends Component {
connectedCallback() {
this._onSubmit = (onSubmit || this.onSubmit || function () {}).bind(this)
this._initialValues = initialValues || this.initialValues || {}
this._validationSchema = validationSchema || this.validationSchema || {}
/* ... */
super.connectedCallback && super.connectedCallback()
}
/* ...form logic... */
}Inside connectedCallback(), the HOC reads onSubmit, initialValues, validationSchema, and friends either from the withForm({...}) call or from the wrapped class itself. That lets you enhance arbitrary element classes and still share one implementation. You can even rebuild the two earlier form shells with the same helper:
// An example of a base class from the first implementation of the form:
// Standard web component or LitElement
import { withForm } from 'lite-form'
class LiteForm extends LitElement {
render() {
return html`<form @submit=${this.handleSubmit} method=${this.method}>
${this.formRender(this)}
</form>`
}
}
customElements.define('lite-form', withForm(LiteForm))// An example of a base class from the second implementation of the form:
// Extend the inline element
import { withForm } from 'lite-form'
class LiteForm extends HTMLFormElement {
connectedCallback() {
this.addEventListener('submit', this.handleSubmit)
}
disconnectedCallback() {
this.removeEventListener('submit', this.handleSubmit)
}
}
customElements.define('lite-form', withForm(LiteForm), { extends: 'form' })Alternatively, skip a shared base class entirely: wrap leaf components that already contain <form> markup and pass configuration through withForm().
// Form example
import { withForm } from 'lite-form'
class UserForm extends LitElement {
render() {
return html`
<form method="POST" @submit=${this.handleSubmit}>
<custom-input name="firstName"></custom-input>
<custom-input name="lastName"></custom-input>
<button type="submit">Submit</button>
</form>
`
}
}
const enhance = withForm({
initialValues: {/*...*/},
onSubmit: {/*...*/},
validationSchema: {/*...*/}
})
customElements.define('user-form', enhance(UserForm))Wrapped classes may still opt into Shadow DOM when isolation helps, or stay in the light DOM when the product needs tighter integration with the page. Full source and samples for the form helper live in the lite-form repository.
You could swap “web components” for “custom elements” in the title: custom elements were the only API I actually depended on, and they are the most mature slice of the broader web-components stack. I hope the ergonomics around construction, property forwarding, and change notification keep improving. LitElement was mostly there to paper over boilerplate; the stock lifecycle callbacks were enough for my case.
Slots remain promising, especially if they ever work without shadow DOM (without hacks). That would unlock richer composition patterns.
Shadow DOM is still the right tool for some problems, but defaulting to it everywhere is harder to defend. It carries real product constraints, while CSS scoping has lighter-weight answers now, and not every widget needs DOM encapsulation at all.
Web components still leave rough edges, yet the platform already looks viable for small, self-contained surfaces. Replacing a large framework on a multi-team codebase, though, still feels early.
webcomponents.org, Polymer, LitElement, lit-html, Vaadin Router, Vaadin Components, lite-redux, lite-form, awesome-lit-html, Polyfills, custom-elements-builtin polyfill
