You're Overthinking Web Components

Learn how Web Components excel at progressively enhancing server-rendered HTML without worrying about additional dependencies, shadow DOM, or going full SPA.

SoftwareWeb DevelopmentFrontend

Contents

Open Contents

Setting the Stage

Web Components are often pitched as a one-stop-shop for building single-page applications (SPAs) - an alternative to client-side rendering technologies like React. This starts people down the path of learning about Shadow DOM, which can fight their CSS tooling, and looking for a web component framework that makes rendering easier (the custom element api doesn’t include a render function), diminishing the no-dependency appeal.

You’re overthinking it.

I realized building Sabal that Web Components work best as progressive enhancement tools, not SPA alternatives. Excluding apps whose primary function is client-side interactivity, you can dramatically simplify your stack by starting with server-rendered HTML, then add interactivity where needed. This approach plays nicely with View Transitions, SSR, SSG, and leverages the web platform’s strengths alongside your existing templating and styling tools. In this post, we’ll cover common patterns and production examples from building Sabal to help you visualize using this approach in your app.

I use Astro as my server and TailwindCSS + DaisyUI for styling, but the principles apply to any approach for serving and styling HTML.

Web Components for Progressive Enhancement

Suppose you’re working on a simple pricing card for a subscription product. A vistor to your site should be able to see options for monthly and yearly pricing. Your HTML might look like this:

<pricing-card class="flex flex-col gap-4">
    <billing-period class="flex gap-4 items-center">
        <div>Monthly</div>
        <input type="checkbox" checked="checked" class="toggle toggle-sm">
        <div>Yearly</div>
    </billing-period>
    <price-option class="month flex hidden flex-col items-center gap-1">
        <div class="text-4xl font-bold">$9.99</div>
        <div class="text-base-content/70 text-sm">per month</div>
    </price-option>
    <price-option class="year flex flex-col items-center gap-1">
        <div class="text-4xl font-bold">$99.99</div>
        <div class="text-base-content/70 text-sm">per year</div>
    </price-option>
</pricing-card>

Currently, the monthly price is hidden and the yearly price is shown, but clicking the toggle does nothing. Using flex hidden classes together might seem odd, but it let us change visibility by adding or removing a single class.

To make this interactive, we’ll create a custom element that listens for toggle changes:

class PricingCard extends HTMLElement {
    connectedCallback() {
        this.addEventListener('change', () => {
            // Listen for toggle changes within this pricing card
            this.querySelector('price-option.year').classList.toggle('hidden');
            this.querySelector('price-option.month').classList.toggle('hidden');
        })
    }
}
if (!customElements.get('pricing-card')) {
    customElements.define('pricing-card', PricingCard)
}

Since the change event bubbles (moves up the DOM tree), we can listen for it on the pricing-card element. When the toggle is clicked, we find the price-option elements and toggle their visibility.

The customElements.define adds our custom pricing-card element to the CustomElementRegistry. When the browser comes across our custom pricing-card tag, it’ll look it up in the registry and process it according to our PricingCard class.

The connectedCallback method is one of several custom element lifecycle methods and is called when the element is added to a document. This happens on an initial page load or a subsequent page navigation, like what happens in a View Transition. The element can access the document at this point, so this method is where you put interactivity logic (ex. event listeners).

A few things to note so far:

  1. You can use custom tag names without adding them CustomElementRegistry. The browser styles them as display: inline;. If the unknown tag does not contain a hyphen, the browser parses it as a HTMLUnknownElement. Otherwise, the unknown tag is parsed as a HTMLElement.
  2. Any tags that you want to use in DOM APIs - like customElements.define - need a hyphen in the name. This is meant to offer forward compatibility with future HTML elements.
  3. Custom tag names that aren’t registered will still work in CSS selectors, like price-option.year.
  4. We were able to style our custom element and its children with standard CSS classes, just like any other HTML element. This is because we didn’t use Shadow DOM, which encapsulates styles and markup.

The “if not defined define this component” pattern comes up often, so I’ll use this helper in later examples:

function registerElement(name: string, constructor: CustomElementConstructor, options?: ElementDefinitionOptions): void {
    if (!customElements.get(name)) {
        customElements.define(name, constructor, options)
    }
}

Browser-Managed Hydration

Our pricing-card element doesn’t start out with as a PriceCard element. The javascript class definition loads asynchronously after the paint. This process - turning a static page into an interactive one - is a form of hydration. You might have heard this term in the context of modern frameworks like Next.js or SvelteKit, in which state serialization complicates hydration. However, hydration is not inherently complicated: you can implement it yourself with a index.html and an async script tag.

Web Components and Browsers handle hydration natively. As long as the javascript that registers the class definition runs after the HTML is parsed, the browser will automatically upgrade the pricing-card element from a HTMLElement to a PriceCard element. You can detect this upgrade using the Custom Element Registry’s whenDefined method, which returns a promise that resolves when the element is defined.

For self-contained cases, connectedCallback handles everything automatically. But if you need to interact with custom elements from a different web component, you’ll need to wait for the upgrade:

export function onComponentLoad<T extends HTMLElement>(
    tagName: string,
    clazz: new (...args: any[]) => T,
    consumer: (el: T) => void,
    opts?: {
        selector?: string
        root?: HTMLElement | Document
        maxAttempts?: number
    }
): void {
    let attempts = 0
    const maxAttempts = opts?.maxAttempts || 100
    const root = opts?.root || document
    const selector = opts?.selector || tagName

    const checkAndRun = () => {
        const el = root.querySelector(selector) as HTMLElement
        if (el instanceof clazz) {
            consumer(el)
        } else if (attempts++ < maxAttempts) {
            // Wait for the next frame since browser doesn't upgrade all instances simultaneously
            requestAnimationFrame(checkAndRun)
        } else {
            console.warn(`${selector} not ready after maximum attempts`)
        }
    }

    // whenDefined returns a promise that resolves immediately if the tag is already defined
    // or when the tag is eventually registered.
    customElements.whenDefined(tagName).then(checkAndRun)
}

onComponentLoad('pricing-card', PricingCard, (el: PricingCard) => {
    el.someMethod();
})

In the onComponentLoad function, the optional query root and selector allow you to target specific children of a parent element, which is useful if you have multiple instances of the same component on the page. The instanceof check ensures the specific element has been upgraded, since the browser doesn’t upgrade all instances simultaneously. The requestAnimationFrame method is used to wait for the next frame before checking again, which is appropriate when waiting for interactivity.

This browser-native approach allows you to incrementally add interactivity to prerendered content (SSR or SSG) without the complexities that plague frameworks.

Handling Dynamic Content with Web Components

So far, we’ve discussed how to enhance static content, but what about dynamic content that needs to be added after the initial page load? A simple, effective approach is to render a template server-side and clone it when needed. This lets you leverage whatever template engine and styling solution you’re using server-side on the client. The web component becomes responsible for cloning the template, making any updates necessary, and inserting it into the document.

Here’s a classic list example of adding items to a list dynamically. The server has rendered two list items already and has rendered a template for rendering items client-side. Try the demo below, then inspect the dropdown to see the template structure:

Dynamic List Demo
  • Server Rendered Item 1
  • Server Rendered Item 2
<details class="collapse collapse-arrow border-1 bg-info/10 border-info rounded-sm font-semibold px-4">
    <summary class="collapse-title">Dynamic List Demo</summary>
    <dynamic-list class="flex flex-col items-center collapse-content w-fit mx-auto max-w-lg pb-8">
        <ul>
            <li>Server Rendered Item 1</li>
            <li>Server Rendered Item 2</li>
        </ul>
        <button name="addItem" class="btn btn-primary">
            Add Item
        </button>
        <template>
            <li class="text-info font-bold"></li>
        </template>
    </dynamic-list>
</details>

The dynamic-list element clones the template on each button click, sets the text using its internal state, and then appends the new item to the list. Here’s the code for the DynamicList component:

class DynamicList extends HTMLElement {
    private template!: HTMLTemplateElement;
    private list!: HTMLUListElement;
    private addedCount = 0;

    connectedCallback() {
        this.template = this.querySelector('template')!;
        this.list = this.querySelector('ul')!;
        this.querySelector('button[name="addItem"]')!
            .addEventListener('click', this.addElement.bind(this));
    }

    private addElement() {
        const frag = this.template.content.cloneNode(true) as DocumentFragment;
        const newText = `Client Rendered Item ${++this.addedCount}`;
        frag.querySelector('li')!.textContent = newText
        // Triggers a paint
        this.list.appendChild(frag);
    }
}
customElements.define('dynamic-list', DynamicList);

Notice how we’re able to use our styling framework (TailwindCSS + DaisyUI) on the template without any special handling and how simple it is to add a light-weight DocumentFragment to the list.

This is essentially isomorphic rendering - the same templates work on both server and client - but with much less overhead than you’d find in frameworks.

Passing Data to Web Components

Applications need data to be useful. Most libraries and frameworks have some concept of properties (aka. props) that can be passed into templates to customize the output. Web Components can handle data in several ways, each with its own use cases.

Element Attributes

Attributes are the most straightforward way to pass data to web components. While you can give an attribute any name, it’s best to use data-* attributes for custom data. This avoids conflicts with future HTML specifications and allows you use DOMStringMap, which offers a convenient API for accessing data inside the component and handles converting from kebab-case in the markup to camelCase in Javascript.

<app-state 
    data-user-id="123" 
    data-workspace-id="0c6fd54e-594a-4488-bd23-b1be10b6fd9a" 
/>

<script>
    class AppState extends HTMLElement {
        connectedCallback() {
            const userId = this.dataset.userId;
            const workspaceId = this.dataset.workspaceId;            
            console.log(`User ID: ${userId}, Workspace ID: ${workspaceId}`);
        }
    }
    registerElement('app-state', AppState);
</script>

This is a simple way to pass data from your server to the client. If you expect an attribute to change and you want the component to react to those changes, you can use the observedAttributes static property and implement the attributeChangedCallback lifecycle method, which is called whenever an observed attribute changes:

class AppState extends HTMLElement {
    static observedAttributes = ['data-alert-text'];
    
    attributeChangedCallback(name: string, oldValue: string, newValue: string) {
        if (oldValue !== newValue) {
            if (name === 'data-alert-text') {
                this.showAlert(newValue);
            }
        }
    }

    // Remaining component definition
}

Dispatching Custom Events

The browser has a built-in event bus that allows elements to publish and “subscribe” to events. This event system handles the standard events you’re accustomed to (ex. change and click events) and can also handle custom events. Events can carry complex data structures and can be sent to a specific component (called dispatching) or bubbled up the DOM tree. This process uses two methods from HTMLElement: dispatchEvent and addEventListener.

Dispatching a Custom Event to a Specific Element

Calling an element’s dispatchEvent method sends an event to that specific element. This is useful when you want to notify a specific component about something that has happened, like a user action or data change.

// Create your custom event with data 
const data: FilterDeletedData = { 
    filterType: this.getAttribute('data-filter-type'), 
    chip: this
}
const event = new CustomEvent('filter-deleted', { detail: data })

// Sending the event to a specific element
document.querySelector('#some-element')?.dispatchEvent(event)

// Listening for the event in #some-element
this.addEventListener('filter-deleted', (event: CustomEvent<FilterDeletedData>) => {
    const { filterType, chip } = event.detail;
    console.log(`Filter deleted: ${filterType}`, chip);
});

Dispatching a Custom Event that Bubbles Up the DOM Tree

Bubbling an event refers to sending the event up the DOM tree, allowing all ancestors of the target element to act on the event. Custom events do not bubble by default, so you need to set the bubbles option to true when creating the event:

// Create your custom event with data that bubbles up the DOM 
const data: FilterDeletedData = { 
    filterType: this.getAttribute('data-filter-type'), 
    chip: this
}
const event = new CustomEvent('filter-deleted', { detail: data, bubbles: true })

// Send the event to the current element to start bubbling
this.dispatchEvent(event)

Custom Element Class Methods

Since our custom components are implemented as JavaScript classes, we’re free to add public APIs that can be called from other JavaScript code. The catch is that we need to verify that the component has been upgraded by the browser before calling any methods. This is where the onComponentLoad helper we defined earlier comes in handy.

Suppose we had a dropdown that needs to be opened programmatically after some parent component is added to the document. We can define a method on the dropdown component to handle this:

class MyDropdown extends HTMLElement {
    open() { ... }
}
registerElement('my-dropdown', MyDropdown);

Then, in the parent component, we can call this method once the dropdown component has been upgraded by the browser:

class ParentComponent extends HTMLElement {
    connectedCallback() {
        onComponentLoad(
            'my-dropdown',
            MyDropdown,
            (dropdown: MyDropdown) => dropdown.open(),
            { root: this }
        );
    }
}
registerElement('parent-component', ParentComponent);

Choosing the Right Approach

Like most things in software, you’ll need to play with each of these approaches and get a feel for when to use each one. Personally, I end up using the following patterns:

An added benefit of each of these approaches is that they’re platform native, stable, and work without any additional dependencies. This means you can use them in any web application, regardless of the framework or library you’re using.

Examples from Production

Here are a few practical examples I’ve lifted from my projects in production that demonstrate what we’ve talked about so far.

Chart.js Integration

Data visualization comes up in many applications, and Chart.js is a great library for creating interactive charts. It’s built around the HTML5 <canvas> element, has a reasonable bundle size, and is easy to customize.

I use Chart.js for all of Sabal’s charts, which involve loading in some data from the server, rendering the chart, and making parts of the chart clickable. Here’s how I make a bar chart that shows account balances and directs the user to an account details page when a bar is clicked.

Full Bar Chart Code
---
export interface Props {
    id: string
    data: unknown
}
---

<sbl-bar-chart class="sbl-bar-plot" id={Astro.props.id} data-plot={JSON.stringify(Astro.props.data)}>
    <canvas></canvas>
</sbl-bar-chart>
<script>
    import { registerElement } from 'sbl-ui/utils'
    import { SblBarChart } from '@components/common/charts/Bar.ts'
    registerElement('sbl-bar-chart', SblBarChart)
</script>

The class definition for the SblBarChart component looks like this:

interface ProcessedChartData {
    title?: string
    labels: string[]
    values: string[]
    customData: string[][]
    displayTexts: string[]
    hoverTemplate: string
}

interface ChartConfig<T = any> {
    dataHandler: (data: T) => ProcessedChartData
    clickHandler?: (event: { clickedLabel: string; allLabels: string[]; index: number }) => void
    processedData: ProcessedChartData
}

export class SblBarChart extends HTMLElement {
    private chart: Chart | null = null
    private clickHandler: ((event: { clickedLabel: string; allLabels: string[]; index: number }) => void) | null = null
    private chartConfig: ChartConfig | null = null

    connectedCallback() {
        Chart.register(...) // Register Chart.js components as needed
        const canvas = this.querySelector('canvas')
        this.renderChart(canvas)
    }

    setChartConfig<T = any>(config: SetupBarChartArgs<T>) {
        this.chartConfig = config as ChartConfig
        this.clickHandler = config.clickHandler || null

        const plotData = this.dataset['plot']
        if (plotData && this.chart) {
            const data = JSON.parse(plotData) as T
            const processedData = config.dataHandler(data)
            this.updateChart(processedData)
        }
    }

    private renderChart(canvas: HTMLCanvasElement) {
        this.chart = new Chart(canvas, {
            type: 'bar',
            data: { ... } // Initialize chart data structure,
            options: {
                ... // Additional config
                onClick: (_event: ChartEvent, elements: ActiveElement[]) => {
                    if (elements.length > 0 && this.clickHandler) {
                        const index = elements[0].index
                        const label = this.chart?.data.labels?.[index]
                        const allLabels = this.chart?.data.labels || []

                        this.clickHandler({
                            clickedLabel: label as string,
                            allLabels: allLabels as string[],
                            index: index,
                        })
                    }
                },
            }
        })
    }

    private updateChart(processedData: ProcessedChartData) {
        if (!this.chart || !this.chartConfig) return

        this.chartConfig.processedData = processedData

        // Update chart data - use IDs as labels for click handling, displayTexts for visual display
        this.chart.data.labels = processedData.labels // Keep IDs as labels for click handler
        this.chart.data.datasets[0].data = processedData.values.map((v: string) => parseFloat(v))

        // Update the chart options to use displayTexts for visual labels
        if (this.chart.options.scales?.x?.ticks) {
            this.chart.options.scales.x.ticks.callback = function (value, index) {
                return processedData.displayTexts?.[index] || processedData.labels[index]
            }
        }

        this.chart.update()
    }

    resetChart() {
        if (this.chart) {
            this.chart.destroy()
            this.chart = null
        }
    }

    disconnectedCallback() {
        this.resetChart()
    }
}

The big take-aways from the BarChart component are:

  1. it’s responsible for interacting with the Chart.js library
  2. the connectedCallback method initializes the chart and the disconnectedCallback method handles cleaning up the chart when the component is removed from the DOM
  3. it’s passed the customer’s data from the server using the data-plot attribute
  4. it uses the setChartConfig method to set up the chart configuration, which includes a data handler and an optional click handler

Now, let’s take a look at how we use this component in a report page:

<report-content id="report-content">
    <!-- Other elements that make up the report -->
    <Bar
        id="report-balance-chart"
        data={{
            ids: data.chart.map((item) => item.id),
            names: data.chart.map((item) => item.accountName),
            values: data.chart.map((item) => item.accountValue),
            totalBalance: data.totalBalance,
        }}
    />
</report-content>
<script>
class ReportContent extends HTMLElement {
    connectedCallback() {
        onComponentLoad('sbl-bar-chart', SblBarChart, (chartElement: SblBarChart) => {
            chartElement.setChartConfig({
                id: 'report-balance-chart',
                dataHandler(data: any) {
                    // data comes from the data-plot attribute.
                    // This method sets up the ids, names, labels and values for the chart
                    return { ... }
                },
                clickHandler(data) {
                    const id = data.clickedLabel
                    window.location.assign(`/accounts/${id}/`)
                },
            })
        }, { root: this })
    }
}
registerElement('report-content', ReportContent)
<script>

You’ll notice the application of a few patterns we’ve discussed:

A cool consequence of this approach is that if add a filter to the report, we can return the report-content component as a HTML fragement with updated data in the data-plot attribute. If we swap this new report component into the DOM, the chart will automatically update with the new data without needing to re-render the entire page: the connectedCallback renders for ReportContent and for the SblBarChart component will run again, updating the chart with the new data.

Stripe Embedded Checkout Integration

In any checkout flow, performance is critical. You want to minimize the time it takes for a user to complete a purchase, avoid unnecessary redirects or page reloads, and keep the user on the conversion path. Stripe’s Embedded Checkout Form is a great way to achieve this, as it allows you to embed the checkout experience directly into your application without having to think much about the design.

Integration requires making a call to the Stripe API to create a checkout session, which sets up the pricing, allowed payment methods, and other details. The API returns a client_secret that use to initialize the embedded checkout form using their client-side JavaScript library.

Here’s how I set up the embedded checkout in an Astro component:

<main style="background-color: #f3f3f2;" class:list={[className]}>
{   
    // This is a Promise<string> that comes from the Stripe server API
    clientSecret.then((token) =>
        token ?
            <embedded-checkout data-token={token} class="flex flex-col items-center justify-center min-h-svh h-full" />
        :   <ErrorMessage mailtoSubject="Problem Creating Billing" userMessage="We experienced a problem starting a billing session." />
    )
}
</main>
<script
    is:inline
    src="https://js.stripe.com/basil/stripe.js"
    onload="window.stripeLoaded = true; document.dispatchEvent(new CustomEvent('stripe-loaded', {}));"
/>
<script>
    // EmbeddedCheckout definition
</script>

Before we get to the EmbeddedCheckout class, let’s break down the markup:

Here’s the EmbeddedCheckout component that handles the initialization:

class EmbeddedCheckout extends HTMLElement {
    async connectedCallback() {
        await this.stripeJsLoaded()
        const clientSecret = this.dataset.token
        if (clientSecret) {
            const stripe = Stripe(import.meta.env.PUBLIC_STRIPE_PUBLIC_KEY)
            const checkout = await stripe.initEmbeddedCheckout({ fetchClientSecret: () => clientSecret })
            await checkout.mount(this)
        } else {
            console.error('Unable to load checkout. No client secret provided.')
        }
    }

    private stripeJsLoaded(): Promise<void> {
        return new Promise((resolve) => {
            if (window.stripeLoaded) {
                resolve()
            } else {
                document.addEventListener('stripe-loaded', () => resolve())
            }
        })
    }
}
registerElement('embedded-checkout', EmbeddedCheckout)

When the browser upgrades the embedded-checkout element, the connectedCallback method is called. This method waits for the Stripe SDK to load, then initializes the embedded checkout form with the client_secret provided in the data-token attribute. The checkout form is mounted directly into the embedded-checkout element using the SDK. The stripeJsLoaded method handles any potential race condition between the script loading and the component being connected to the DOM.

Emergency Fund Planner - Consolidating State

The Setup

I built an Emergency Fund Planner with this stack and ran into a situation where I needed to manage state across multiple components. The planner allows people to build an emergency fund strategy out of some account data, calculates how many months of expenses their fund covers, and visualizes the difference in annual yield between a standard savings accounut and their strategy.

The planner has several components that need to react to the same state changes:

The dependencies look like this:

Visualization of dependencies

My initial approach had the Account Strategy component publishing accounts-changed events to the document with the other two components listening. The server set the initial state for each component.

Handling Default and URL State

I wanted to add a feature that allowed people to easily share their strategy with others. When I noticed that this would require each component to maintain their own logic for parsing the URL, I thought it would be to consolidate state into one AppState component. That component would be responsible for loading in the initial state from the server, overriding that state with the information passed through the URL, and exposing methods for the other components to publish and subscribe to changes in the state.

I chose to a Nanostores to implement this pattern, since it’s a small, tree-shakable library that offers simple APIs for the derived state and for pub-sub. Using Nanostores, I implemented the AppState custom component using a few of the tricks we discussed earlier:

class AppStateElement extends HTMLElement {
    private $accounts: ReturnType<typeof atom<Account[]>>
    private $accountCount: ReturnType<typeof computed<number, any>>

    connectedCallback() {
        const urlConfig = parseConfigFromURL()
        const serverConfig = JSON.parse(this.dataset.data!) as EmergencyFundConfig
        const startingState = urlConfig || serverConfig

        this.$accounts = atom<Account[]>(startingState.accounts)
        this.$accountCount = computed(this.$accounts, (accounts) => accounts.length)

        onPlannerFormLoad(
            (planner) => planner.setMonthlyExpenses(startingState.monthlyExpenses),
            { root: this }
        )

        onAccountSummaryLoad(
            (accounts) => accounts.updateAccountsFromConfig(startingState.accounts), 
            { root: this }
        )
    }

    subscribeToAccounts(callback: (accounts: Account[]) => void) {
        this.$accounts.subscribe(callback)
    }

    accountCount(): number {
        return this.$accountCount.get()
    }

    accounts(): Account[] {
        return this.$accounts.get()
    }

    updateAccount(idx: number, account: Account): void {
        const current = this.$accounts.get()
        current[idx] = account
        this.$accounts.set([...current])
    }

    deleteAccount(idx: number): void {
        this.$accounts.set(this.$accounts.get().filter((_, i) => i !== idx))
    }

    addAccount(account: Account): void {
        const current = this.$accounts.get()
        this.$accounts.set([...current, account])
    }
}
registerElement('app-state', AppStateElement)

Our state component completely encapsulates our usage of nanostores, so we could easily swap out the state implementation if needed. Without going too deep into nanostores, storing state and reacting to changes in that state on stores called atoms. You can also defined derived stores using the computed method.

For those familiar, Nanostores behaves very similar to React’s useState and useEffect hooks or Svelte’s $state, $derived, and $effect runes:

// Nanostores
const $accountStore = atom<Account[]>(startingState.accounts)
const currentAccounts = $accountStore.get();
$accountStore.set([{ name: 'a1'}])
this.$accounts.listen((accounts) => console.log ('Accounts changed: ' + accounts.length))
this.$accountCount = computed(this.$accounts, (accounts) => accounts.length)


// React
const [currentAccounts, setAccounts] = useState(startingState.accounts)
// currentAccounts returned from hook is Account[]
setAccounts([{ name: 'a1'}])
useEffect(() => console.log('Accounts changed: ' + currentAccounts.lenth), [ currenetAccounts ])
const accountCount = currentAccounts.length

// Svelte 
let currentAccounts = $state(startingState.accounts)
// currentAccounts returned from rune is Account[]
currentAccounts = [{ name: 'a1'}]
$effect(() => console.log('Accounts changed: ' + currentAccounts.lenth))
let accountLength = $derived(currentAccounts.length)

The onPlannerFormLoad and onAccountSummaryLoad methods are simple wrappers around our onComponentLoad function so that consuming components, like our AppStateElement, do not need to know about the tag or class names. A simpliar wrapper for the AppStateElement looks like this:

export function onStateLoad(
    consumer: (el: T) => void,
    opts?: {
        selector?: string
        root?: HTMLElement | Document
        maxAttempts?: number
    }
): {
    return onComponentLoad('app-state', AppStateElement, consumer, opts)
}

We’ll use that onStateLoad helper and show how to interact with the state in the AccountsSummary component definition:

export class AccountsSummary extends HTMLElement {
    private accountContainer: HTMLElement
    private template: HTMLTemplateElement
    private state: AppStateElement

    connectedCallback() {
        this.accountContainer = this.querySelector('account-container') as HTMLElement
        this.template = document.querySelector('#account-card-template') as HTMLTemplateElement

        onStateLoad((state: AppStateElement) => {
            this.state = state

            const addAccountBtn = this.querySelector('button[name="addAccount"]') as HTMLButtonElement
            addAccountBtn?.addEventListener('click', this.addNewAccount.bind(this))

            this.addEventListener('account-deleted', this.handleDeleteAccount.bind(this))
            this.addEventListener('account-changed', this.handleChangeAccount.bind(this))
        })
    }

    // Called in AppStateElement's connectedCallback
    updateAccountsFromConfig(accounts: Account[]): void {
        this.accountContainer.innerHTML = ''
        accounts.forEach((account, index) => {
            const newElement = this.createAccountCard(account, index)
            this.accountContainer.appendChild(newElement)
        })
    }

    // Reading information frorm the state and adding a new account
    private addNewAccount() {
        const newIdx = this.state.accountCount()
        const newAccount = {
            name: `Account ${newIdx + 1}`,
            balance: 1000,
            yield: 4.5,
            accountType: 'high-yield-savings' as const,
            liquidity: 'immediate' as const,
        }

        const newElement = this.createAccountCard(newAccount, newIdx)
        this.accountContainer.appendChild(newElement)
        this.state.addAccount(newAccount)
    }

    // Telling the state to remove an account after deletion
    private handleDeleteAccount(event: CustomEvent) {
        const accountIndex = parseInt(event.detail, 10)
        this.accountContainer.removeChild(this.accountContainer.querySelector(`account-info[data-index="${accountIndex}"]`) as HTMLElement)
        this.state.deleteAccount(accountIndex)
    }

    private handleChangeAccount(event: CustomEvent) {
        const accountIndex = parseInt(event.detail, 10)
        const accountType = this.accountContainer
            .querySelector(`#account-type-${accountIndex} option[selected]`).value as keyof typeof liquidityMap
        const data: Account = {
            name: this.accountContainer.querySelector(`#account-name-${accountIndex}`)?.value,
            balance: parseFloat(this.accountContainer.querySelector(`#account-balance-${accountIndex}`)?.value),
            yield: parseFloat(this.accountContainer.querySelector(`#account-yield-${accountIndex}`)?.value),
            accountType,
            liquidity: liquidityMap[accountType],
        }
        this.state.updateAccount(accountIndex, data)
    }

    ...
}

This code shows how to publish read, create, update, and delete from the Nanostore’s state. The Projection component shows how listed for changes in the account data:

class ProjectionElement extends HTMLElement {
    connectedCallback() {
        ...
        onStateLoad((state: AppStateElement) => {
            // subscribe passes the current value of accounts and then listens
            // for future changes.
            state.subscribeToAccounts(this.handleNewAccountData.bind(this))
            ...
        })
    }

    handleNewAccountData(accounts: Account[]): void {
        this.comparisonData = calculateComparisonChartData(accounts)
        this.breakdownData = calculateBreakdownData(accounts)
        this.render()
    }

    ...
}

Pulling all of this together, we have

This approach gives us sophisticated state management without framework lock-in. Our components work with any server-side rendering approach, and the state management complexity is isolated to just the components that need it. The rest of our application remains simple HTML enhanced with web components.

Wrapping Up

Web Components are a powerful way to enhance HTML rendered by your templating engine. By leaning into the web platform instead of fighting it, you get progressive enhancement, framework independence, and components that you can forget about.

For applications you own and control, you don’t need the complexity of Shadow DOM. Colocate your interactivity, markup, and styles using platform APIs that aren’t going to change every quarter. The web platform gives you everything you need.

Sometimes the simplest approach is the most powerful one.

Back to Posts