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.
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:
- 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. - 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. - Custom tag names that aren’t registered will still work in CSS selectors, like
price-option.year
. - 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:
- Do I need to bubble the event? Use a custom event with
bubbles: true
. - Is what’s happening a domain event that several components need to know about? Use a custom event that is dispatched to the document root.
- Is this an operation on a component? Use a class method on the custom component.
- Am I passing data from the server to the client? Use a
data-*
attribute.
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:
- it’s responsible for interacting with the Chart.js library
- the
connectedCallback
method initializes the chart and thedisconnectedCallback
method handles cleaning up the chart when the component is removed from the DOM - it’s passed the customer’s data from the server using the
data-plot
attribute - 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:
- The
onComponentLoad
method withthis
as a root waits for the browser to upgrade the decendentsbl-bar-chart
component. - We’re configuring the
SblBarChart
component using a class method (setChartConfig
) that takes a configuration object. This allows the calling component to drive parts of the bar chart’s behavior in a strategy pattern.
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:
- The
clientSecret
is a Promise that resolves to a string containing theclient_secret
returned by the Stripe API. This is passed as adata-token
attribute to theembedded-checkout
component. - Astro supports response streaming which renders asynchronous chunks of a page. This results in a fast time to first byte (TTFB) and allows the browser to start rendering the page while waiting for the
clientSecret
Promise to resolve. - The script that loads the Stripe SDK uses an
is:inline
attribute to instruct Astro not to process this script. - The
onload
attribute sets a global variablewindow.stripeLoaded
to true and dispatches a custom eventstripe-loaded
when the script has loaded. We’ll rely on these signals in theEmbeddedCheckout
component to know when the Stripe SDK is ready to use.
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:
- An
Account Strategy
component that’s a variable list of accounts, each of which has a balance, APY, and liquidity information. - A
Fund Plan
component that needs to know the current account balances to compute coverage - A
Projection
component that visualizes the difference in annual yield between the standard savings account and the strategy
The dependencies look like this:
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
- one canonical representation of state,
- fine-grain control of how our different UI components react to changes in state, and
- SSR’d contenet managed by our web components
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