tl;dr—<template>
and the HTMLTemplateElement
content
property in combination with the Node
cloneNode()
method are probably the best bits to come out of the Web Component API.
- Web Component variant
qsa-observer
variant- SolidJS variant (on StackBlitz)
- Preact (no signals) variant (on StackBlitz)
The following will discuss a recipe for implementing server rendered Web Components with any server language of choice (while pondering some of the ongoing trends within the Web Component narrative); i.e. without being locked into running JS on the server. Astro is just used as a convenient server platform/templating engine but no "Web Component JS code" is run on the server (i.e. isomorphic JS isn't a requirement).
Lack of standardized SSR support is one of the greatest drawbacks of Web Components, especially as we are in the process of transitioning into Generation 3. Granted some frameworks aim to support SSR (Lit in particular but it still requires JS on the server) but at that point one has already adopted a framework which is acceptable if that is the shop framework. Even when it comes to μ-frontends, the going recommendation is to stick to one, single framework (and version).
The argument that any Web Component based framework will be comparatively long-lived because "it's based on a platform standard" is also more than a little bit disingenuous (AppCache (2018) would like a word); Polymer in particular went through a number of major revisions over the years (1.0 (2015), 2.0 (2017), 3.0 (2018), lit-html (2017, fyi), Lit 1.0 (2019), Lit 2.0 (2021), Lit 3.0 (2023)).
This particular example is based on a reworked version of the Web Components: From zero to hero tutorial. As would be expected, it's unabashedly component-oriented (centric) and by extension client-side rendered (CSR) focused (very pre-2016 and firmly rooted in the traditions of the desktop web). Continued in More Thoughts on Web Components.
At this point we've already achieved SSR but what does this have to do with Web Components? Hint: we aren't going to be using any template literals or tagged templates, vanilla or library supported as it is common in many Web Components. It's simply not possible, as we want to be able to server render in any server side language. Looking at the demo:
---
// file: src/components/todos-view.astro
import type { Todo } from '../types';
import TodoItem from './todo-item.astro';
interface Props {
title: string;
todoItems: Todo[];
}
const { title, todoItems } = Astro.props;
---
<todos-view>
<h3>{title}</h3>
<br />
<h1>To do</h1>
<form>
<input
type="text"
placeholder="Add a new to do"
class="js:c-todos-view__title"
/>
<button class="js:c-todos-view__new">✅</button>
</form>
<ul class="js:c-todos-view__list">
{todoItems.map((todo) => <TodoItem todo={todo} />)}
</ul>
</todos-view>
Astro components are essentially server rendered partials using a JSX-like (but more HTML adjacent) templating syntax that is preceded by some preparatory JavaScript (or TypeScript in this case) code in the frontmatter. todos-view
is the Web Component which is passed some child content. The component starts to manage any relevant parts of light DOM (i.e. normal DOM, as opposed to shadow DOM) that it contains.
FYI: A
js:
prefix identifies a class name as a JavaScript hook; i.e. it's selected and/or manipulated by JS code, so it shouldn't be renamed during a pure CSS refactor. Thec-
prefix namespaces the class name as being (visual design) component-related (design system and UI component boundaries don't always coincide).
A list item for todos-view.astro
is rendered with the todo-item.astro
Astro template (another partial).
---
// file: src/components/todo-item.astro
import type { Todo } from '../types';
interface Props {
todo?: Todo;
}
const todo = Astro.props.todo;
const [todoId, title, index, checked] = todo
? [todo.id, todo.title, String(todo.index), todo.completed ? '' : undefined]
: ['', '', '', undefined];
---
<li class="c-todos-view__item" data-index={index}>
<label data-id={todoId}>
<input type="checkbox" checked={checked} />{title}
</label>
<button>❌</button>
</li>
Note that unlike the original example there isn't another Web Component (to-do-item
) here. See some thoughts on the original example for further discussion.
The todos-view.astro
component is rendered as part of the page:
---
// file: src/pages/index.astro
import Base from '../layouts/base.astro';
import MainTemplates from '../templates/main-templates.astro';
import TodosView from '../components/todos-view.astro';
import { selectTodos } from './todos-store';
const todoItems = await selectTodos(Astro.locals.sessionId);
const title = `Astro "WC: zero to hero" Todos`;
---
<Base {title}>
<TodosView {title} {todoItems} />
<MainTemplates />
{
/* <script is:inline id="resume-data" type="application/json" set:html={JSON.stringify(todoItems)} /> */
}
</Base>
The
<script>
element is commented out but can be easily used to containresume-data
to “boot up” the client-side application embedding data in HTML.
In this case only the markup needed is in todo-item.astro
. One key detail:
interface Props {
todo?: Todo;
}
The partial needs to be able to render itself as a blank. Here when the partial isn't passed a todo
prop it renders the blank variant of itself.
That is what the <MainTemplates />
Astro component in the page accomplishes.
---
// file: src/templates/main-templates.astro
import TodoItem from '../components/todo-item.astro';
---
<template id="template-todo-item">
<TodoItem />
</template>
Each template can be easily selected via its id
. Given that there is only one relevant partial (todo-item.astro
), there is only one <template>
element (id="template-todo-item"
).
Most modern JavaScript is loaded async
, defer
or type="module"
, i.e. by that time the document has already been fully parsed. So the Web Component module can be initialized when the bundle entry code is run (so there is no need to deal with readyState
, DOMContentLoaded
):
// @ts-check
// file: src/client/entry.js
import { makeTodoActions } from './app/browser';
import { makeApp } from './app/index';
import * as todosView from './components/todos-view';
function assembleApp() {
const actions = makeTodoActions('/api/todos');
return makeApp({
addTodo: actions.addTodo,
removeTodo: actions.removeTodo,
toggleTodo: actions.toggleTodo,
});
}
/** @param { ReturnType<typeof makeApp> } app
* @returns { void }
*/
function hookupUI(app) {
customElements.define(
todosView.NAME,
todosView.makeClass({
addTodo: app.addTodo,
removeTodo: app.removeTodo,
toggleTodo: app.toggleTodo,
subscribeTodoEvent: app.subscribeTodoEvent,
})
);
}
hookupUI(assembleApp());
and
// @ts-check
// file: src/components/todos-view.js
// …
const NAME = 'todos-view';
const TEMPLATE_ITEM_ID = 'template-todo-item';
// …
/** @returns {() => HTMLLIElement} */
function makeCloneBlankItem() {
const template = document.getElementById(TEMPLATE_ITEM_ID);
if (!(template instanceof HTMLTemplateElement))
throw Error(`${TEMPLATE_ITEM_ID} template not found`);
const root = template.content.firstElementChild;
if (!(root instanceof HTMLLIElement))
throw new Error(`Unexpected ${TEMPLATE_ITEM_ID} template root`);
return function cloneBlankItem() {
return /** @type {HTMLLIElement} */ (root.cloneNode(true));
};
}
// …
/** @param {{
* addTodo: AddTodo;
* removeTodo: RemoveTodo;
* toggleTodo: ToggleTodo;
* subscribeTodoEvent: SubscribeTodoEvent;
* }} depend
*/
function makeClass({ addTodo, removeTodo, toggleTodo, subscribeTodoEvent }) {
const cloneBlankItem = makeCloneBlankItem();
/** @param {HTMLInputElement} title */
async function dispatchAddTodo(title) {
await addTodo(title.value);
title.value = '';
}
/** @this Binder
* @param {Event} event
*/
function handleEvent(event) {
if (event.type === 'click') {
if (event.target === this.newTitle) {
// Add new todo
event.preventDefault();
if (this.title.value.length < 1) return;
dispatchAddTodo(this.title);
return;
}
// Toggle/Remove Todo
dispatchIntent(toggleTodo, removeTodo, this.items, event.target);
return;
}
}
class TodosView extends HTMLElement {
/** @type {Binder | undefined} */
binder;
constructor() {
super();
}
connectedCallback() {
const title = this.querySelector(SELECTOR_TITLE);
if (!(title instanceof HTMLInputElement))
throw new Error('Unable to bind to todo "title" input');
const newTitle = this.querySelector(SELECTOR_NEW);
if (!(newTitle instanceof HTMLButtonElement))
throw new Error('Unable to bind to "new" todo button');
const list = this.querySelector(SELECTOR_LIST);
if (!(list instanceof HTMLUListElement))
throw new Error('Unable to bind to todo list');
/** @type {Binder} */
const binder = {
root: this,
title,
newTitle,
list,
items: fromUL(list),
handleEvent,
unsubscribeTodoEvent: undefined,
};
binder.unsubscribeTodoEvent = subscribeTodoEvent(
makeTodoNotify(cloneBlankItem, binder)
);
binder.newTitle.addEventListener('click', binder);
binder.list.addEventListener('click', binder);
this.binder = binder;
}
disconnectedCallback() {
if (!this.binder) return;
const binder = this.binder;
this.binder = undefined;
binder.list.removeEventListener('click', binder);
binder.newTitle.removeEventListener('click', binder);
binder.unsubscribeTodoEvent?.();
}
}
return TodosView;
}
export { NAME, makeClass };
Once the blank
content is cloned, simple selectors can locate the relevant elements in order to fill in the necessary information (here setting the index
and id
data attributes and adding the Text
node with the todo's title
)).
// @ts-check
// file: src/components/todos-view.js
// …
const SELECTOR_LABEL = 'label';
const SELECTOR_CHECKBOX = 'input[type=checkbox]';
const SELECTOR_REMOVE = 'button';
// …
/** @param {ReturnType<typeof makeCloneBlankItem>} cloneBlankItem
* @param {Todo} todo
* @returns {[root: HTMLLIElement, binder: ItemBinder]}
*/
function fillItem(cloneBlankItem, todo) {
const root = cloneBlankItem();
const label = root.querySelector(SELECTOR_LABEL);
const checkbox = root.querySelector(SELECTOR_CHECKBOX);
const remove = root.querySelector(SELECTOR_REMOVE);
if (
!(
label instanceof HTMLLabelElement &&
checkbox instanceof HTMLInputElement &&
remove instanceof HTMLButtonElement
)
)
throw new Error('Unexpected <li> shape for todo');
root.dataset['index'] = String(todo.index);
checkbox.checked = todo.completed;
label.dataset['id'] = todo.id;
if (todo.title) label.appendChild(new Text(todo.title));
const binder = makeItemBinder(root, checkbox, remove, todo.id, todo.index);
return [root, binder];
}
// …
/** @param {ItemCollection} binders
* @param {ItemBinder} newBinder
* @returns { HTMLLIElement | undefined }
*/
function spliceItemBinder(binders, newBinder) {
const last = binders.length - 1;
// Scan collection in reverse bailing on the
// first index property smaller than the
// new index property
// (item binders are in ascending index property order)
let i = last;
for (; i > -1; i -= 1) if (binders[i].index < newBinder.index) break;
if (i < 0) {
binders[0] = newBinder;
return undefined;
}
const before = binders[i].root;
if (i === last) {
binders.push(newBinder);
return before;
}
binders.splice(i, 0, newBinder);
return before;
}
// …
/** @param {ReturnType<typeof makeCloneBlankItem>} cloneBlankItem
* @param {HTMLUListElement} list
* @param {ItemCollection} binders
* @param {Readonly<Todo>} todo
*/
function addItem(cloneBlankItem, list, binders, todo) {
const [item, binder] = fillItem(cloneBlankItem, todo);
const before = spliceItemBinder(binders, binder);
if (before) {
before.after(item);
} else {
list.prepend(item);
}
}
// …
While this example only has one single Web Component the guideline still applies. The component's responsibilities are limited to delegating UI interactions to the client side application and projecting some client side application events to the UI. Any behaviour is extremely shallow and strictly limited to manipulating the DOM in response to “UI bound events” and converting DOM events to “application bound events” (Humble Dialog; rather than MVC: misunderstood for 37 years, MVC past, present and future).
To demonstrate the point the example was further reworked in factoring out TodoContent and factoring out TodoNew to yield the todo-new
and todo-list
Web Components. Subsequently:
// @ts-check
// file: src/client/entry.js
import { makeTodoActions } from './app/browser';
import { makeApp } from './app/index';
import { define } from './components/registry';
import * as todoNew from './components/todo-new';
import * as todoContent from './components/todo-content';
import * as todoList from './components/todo-list';
function assembleApp() {
const actions = makeTodoActions('/api/todos');
return makeApp({
addTodo: actions.addTodo,
removeTodo: actions.removeTodo,
toggleTodo: actions.toggleTodo,
});
}
/** @param { ReturnType<typeof makeApp> } app
* @returns { void }
*/
function hookupUI(app) {
const itemContent = todoContent.makeSupport();
define(todoNew.makeDefinition({
addTodo: app.addTodo,
subscribeStatus: app.subscribeStatus,
}));
define(todoList.makeDefinition({
content: {
render: itemContent.render,
from: itemContent.fromContent,
selector: itemContent.selectorRoot,
},
removeTodo: app.removeTodo,
toggleTodo: app.toggleTodo,
subscribeStatus: app.subscribeStatus,
subscribeTodoEvent: app.subscribeTodoEvent,
}));
}
const app = assembleApp();
hookupUI(app);
app.start();
The todo-list
definition is supplied with functions to initiate the removal and toggling of a Todo
. It also gets access to the subscription point for TodoEvent
s which notifies it when a todo has been successfully removed, toggled or created. It monitors the available status
so that it can disable the toggle checkboxes and remove buttons whenever the application isn't ready.
The todo-new
definition also includes the subscription point for the available status
; not only does it disable the c-todo-new__submit
button whenever the application isn't ready but it also activates the spinner (js:c-todo-new--wait
) specifically for the wait status. The definition is also supplied with the function needed to initiate the creation of a new todo.
It needs to be emphasized that with this recipe/approach:
- components never communicate with one another, only with (parts of) the application
- components never render themselves but only render specific parts within their region of control so
- component rendering and component behaviour are separate aspects
- a component factory is expected to be injected with its dependencies during the UI definition phase with
- the functions to delegate UI events to the client side application
- the subscription points (event emitters, signals) to receive updates from the client side application
- render functions for its nested parts
So the lifecycle of these components is fairly simple:
- Something renders the component's DOM subtree with a segregated render function (or it appears as part of the DOM from the initial HTML parse).
- The registry runs the component's
connectedCallback()
at which point the instance caches critical DOM references and establishes its connection (subscribes to application events) to the client side application. - During its lifetime the component delegates UI events to the application and modifies the DOM in response to events/signals from the application. During this time it may render other components within its subtree which may in turn connect to the application themselves.
- The registry runs the component's
disconnectedCallback()
so the component disconnects (unsubscribes) from the application events and releases any resources that it may have acquired.
Hypothetically a Web Component can go through multiple connectCallback
/disconnectCallback
cycles. If that is likely to happen to any particular component then that must be reflected in its design.
One aspect this demonstration doesn't touch on is client side routing. Here /
simply is the todo list (for the particular browser as tracked by the __session
cookie); “new” todos are POST
ed to /api/todos
and “toggles” and “removes” are POST
ed to /api/todos/{id}
.
This suggests the following sequence of events:
- An UI event is delegated by a component to the application.
- If the UI event only affects extended state, the application simply makes the necessary volatile changes. Extended state is not persisted, nor reconstituted when the client is loaded from the identical URL. Any other UI event will effect a route change. Persisting a new state under the current route is handled like a route change.
- Based on the route change the application determines which additional data needs to be acquired (or what actions need to be taken).
- After the additional data has been received (or any necessary actions completed), the application will issue any resulting application events to existing components.
- Some components will simply update their current contents. Others will render new components, perhaps replacing existing ones.
- Existing disconnected components will dispose of themselves while new components connect to the client application to receive further updates later.
- After completing, the UI is ready for the next UI Event (and the application is ready to accept a server side event that will change the UI).
This also suggests a strong coupling between route management and the client side application (this is why Michel Weststrate replaced React Router with manual routing in his 2017 React + MobX Bookshop demo (presentation, article)). However a strict separation of the client application from the Web APIs is desirable from the microtesting perspective (hence the separation of app/browser.js
from app/index.js
).
This demo doesn't really use much of the Web Component API. In fact it doesn't even need that API. The qsa-observer
variant (increasing the minified bundle by 1.5 kB) implements exactly the same demo without Web Components; qsa-observer
itself is based on Mutation Observer
.
But we're really not dealing with components in colloquial sense anymore, are we?
- The definition of the markdown (and CSS rulesets) is now strictly a server concern
- The client components depend on templates under the purview of the server that have to be included on the page otherwise the component can't render any nested components.
- (Whole) component rendering is separate from component behaviour; in fact a component does not render itself at all:
- It “connects” after it's DOM subtree is rendered from elsewhere
- After that it updates when told by the application.
Entering Generation 3 server and client need to work together more closely to deliver better and faster UX. Here Web Components are at a distinct disadvantage because they are firmly stuck in Generation 2. Sure, Lit will eventually support SSR but now we are no longer just using the platform and we are forced to run JS on the server anyway (perhaps with a slow or incomplete server side emulation of DOM).
There is no one web platform but in fact it's a wide spectrum of innumerable combinations of client device capabilities, network conditions and server platforms/technologies. This has been especially true since mobile reset everything.
Faced with a similar situation, needing to maximize the performance yielded from commodity hardware found in gaming consoles in 2009 the gaming industry started to move from OO to Data-Oriented Design and embracing the Entity, Component, System (ECS).
Object-oriented development is good at providing a human oriented representation of the problem in the source code, but bad at providing a machine representation of the solution. It is bad at providing a framework for creating an optimal solution, so the question remains: why are game developers still using object-oriented techniques to develop games? It's possible it's not about better design, but instead, making it easier to change the code. It's common knowledge that game developers are constantly changing code to match the natural evolution of the design of the game, right up until launch. Does object-oriented development provide a good way of making maintenance and modification simpler or safer?
From: Data-Oriented Design: Mapping the problem
Of course that approach won't work in web development as there is no one machine to run the clients or the servers on. But which trade offs are being made should always be given serious consideration. Meanwhile the reported developer convenience of React-style components really hasn't resulted in the desired trickle-down UX while the cost to UX has been reported again and again (though to some extend the "ecosystem" is to blame as well).
Web development's pre-occupation with components could simply be bad for creating an optimal solution for client browsers. Browsers were designed for pages, not components. So for an optimal end user experience use all performant browser features to maximum effect. That includes creating DOM from server rendered HTML whenever reasonable rather than running JavaScript to create it and using CSS that is available before JavaScript even has a chance to run, both of which can proceed to parse and layout before or in parallel to JavaScript downloading, parsing and executing.
From that perspective components should ideally vanish at run time. If we already have to settle for running JavaScript on the server to support SSR then it's reasonable to choose a framework that is performant in that regard (e.g. by rendering directly to (HTML) string rather than through a DOM emulation; MarkoJS is probably one of the best in this regard).
SolidJS has performant SSR, is supported by Astro (and will feature a broader set of capabilities with SolidStart). More importantly because it's a state library that happens to render it readily supports the segregated UI approach.
To be clear, the WC and qsa-observer
variants don't have any client side state beyond the todo IDs that are stored in DOM data attributes. However, that lean, handcrafted approach doesn't offer any other exploitable features.
Thanks to Solid's client side signals and stores, dependencies no longer have to explicitly subscribe to change; under a tracking scope change subscription and propagation is automatic. Its practice of read/write segregation makes it a lot easier to use and grant mutability more responsibly.
This is what the SolidJS variant's core app looks like:
// file: src/app/app.ts
import { availableStatus, type AvailableStatus } from './available-status';
import { createEffect, createSignal, createResource } from 'solid-js';
import { createStore, reconcile } from 'solid-js/store';
import type { NewTodo, Todo, TodoActions, ToggleTodo } from './types';
function findTodoById(todos: Todo[], id: string) {
for (let i = 0; i < todos.length; i += 1) if (todos[i].id === id) return i;
return -1;
}
function findByIndex(todos: Todo[], index: number) {
for (let i = todos.length - 1; i > -1; i -= 1)
if (todos[i].index <= index) return i + 1;
return 0;
}
function makeApp(actions: TodoActions, initialState: Todo[]) {
let items = initialState;
const [todos, setTodos] = createStore(items);
const [status, setStatus] = createSignal<AvailableStatus>(
availableStatus.UNAVAILABLE
);
const readyStatus = () => setStatus(availableStatus.READY);
const waitStatus = () => setStatus(availableStatus.WAIT);
const [addNew, setAddNew] = createSignal<NewTodo>();
const [addedNew] = createResource(addNew, actions.addTodo);
createEffect(function addNewTodo() {
const todo = addedNew();
if (!todo) return;
const i = findByIndex(items, todo.index);
items.splice(i, 0, todo);
setTodos(reconcile(items));
});
const [removeId, setRemoveId] = createSignal<string>();
const [removedById] = createResource(removeId, actions.removeTodo);
createEffect(function removeFromTodos() {
const id = removedById();
if (typeof id !== 'string') return;
const i = findTodoById(items, id);
if (i < 0) return;
items.splice(i, 1);
setTodos(reconcile(items));
});
const [toggle, setToggle] = createSignal<ToggleTodo>();
const [toggled] = createResource(toggle, actions.toggleTodo);
createEffect(function toggleTodo() {
const todo = toggled();
if (!todo) return;
const i = findTodoById(items, todo.id);
if (i < 0 || items[i].completed === todo.completed) return;
items[i] = todo;
setTodos(reconcile(items));
});
createEffect(function adjustAvailableStatus() {
if (addedNew.loading || removedById.loading || toggled.loading)
waitStatus();
else readyStatus();
});
const addTodo = (title: string) => {
setAddNew({ title });
};
const removeTodo = (id: string) => {
setRemoveId(id);
};
const toggleTodo = (toggle: ToggleTodo) => {
setToggle(toggle);
};
const start = () => {
if (status() !== availableStatus.UNAVAILABLE) return;
readyStatus();
};
return {
todos,
addTodo,
removeTodo,
toggleTodo,
status,
start,
};
}
export { makeApp };
- The todo items are exposed via a store. Note how only the readonly
todos: Store<Todo[]>
is exposed to the outside while thesetTodos: SetStoreFunction<Todo[]>
is only used within theaddNewTodo
andremoveFromTodos
effects. - The
AvailableStatus
is exposed via theAccessor
of a signal. TheSetter
is incorporated into the internal convenience functionsreadyStatus
andwaitStatus
. addedNew
's resource is driven by theaddNew
accessor. The idea is to request a new todo from the server whenever a new (i.e. different)NewTodo
is set with thesetAddNew
setter (via the exposedaddTodo
function). Once the resource is loaded theaddNewTodo
effect is executed.findByIndex
finds the insertion position inside the ordered array, inserting the new todo withsplice()
among the pre-existing ones. Finallyreconcile
is used to ensure that thesetTodos
update is as fine-grained as possible (minimizing any upstream changes to the DOM).removedById
's resource is driven by theremoveId
accessor. When a new todo ID appears onremoveId
(via the exposedremoveTodo
function) the resource triggers theremoveTodo
action. When the delete has been confirmed the specified todo is removed from the client sidetodos
inside theremoveFromTodos
effect.toggled
's resource is driven by thetoggle
accessor. When a newToggleTodo
appears ontoggle
(via the exposedtoggleTodo
function) the resource triggers thetoggleTodo
action. When the action has been confirmed the latest todo is replaced intodos
within thetoggleTodo
effect.- The
adjustAvailableStatus
effect executeswaitStatus
whenever one ofaddedNewTodo
,removedById
ortoggled
is loading. Once all of them are not loadingreadyStatus
is executed. - The wrapping
removeTodo
andtoggleTodo
functions simply demonstrate how the app retains ultimate control over its own signalSetter
s.
The Astro page:
---
// file: src/pages/index.astro
import Base from '../layouts/base.astro';
import { App, makeResumeState } from '../app/ui/app';
import { selectTodos } from './todos-store';
const resumeState = makeResumeState(await selectTodos(Astro.locals.sessionId));
const title = `Astro "solid-js: zero to hero" Todos`;
const todosApiHref = '/api/todos';
---
<Base {title}>
<main>
<h3>{title}</h3>
<br />
<h1>To do</h1>
<App client:load {todosApiHref} {resumeState} />
</main>
</Base>
The SolidJS App
is composed into the static portion of the page as a framework component. The client:load
client directive ensures that the app is hydrated client side. makeResumeState
transforms (in this case it doesn't do anything) the server side todos into client state (resumeState
) which is then used for:
- the server side render
- client side hydration (i.e. it's included in the rendered page).
todoApiHref
is also passed as a prop to configure the (addTodo
, removeTodo
, toggleTodo
) actions.
The ui/app.tsx
component:
// file: src/app/ui/app.tsx
import { ConduitProvider } from './conduit';
import { TodoNew } from './todo-new';
import { TodoList } from './todo-list';
import type { AppProps, Todo } from '../types';
function makeResumeState(todos: Todo[]) {
// i.e. transform the inputs to the
// client application's initial state.
// In this case there is nothing to do.
return todos;
}
function App(props: AppProps) {
return (
<ConduitProvider {...props}>
<TodoNew />
<TodoList />
</ConduitProvider>
);
}
export { App, makeResumeState };
App
composes the Conduit
context with the niladic TodoNew
and TodoList
UI components.
It should be noted that the Conduit
value will never change once the reactive graph has been established. App-bound data propagates via any functions that are exposed on the Conduit
while component-bound data propagates along reactive signals and stores on the Conduit
. The Conduit
aggregates any information needed on the app-component highway in order to avoid Context Hell.
The tradeoff is the introduction of niladic UI components (i.e. in the app/ui
directory) that act as a gateways for the props-only (context-free) components (under app/ui/components
). These UI components are simple wrappers that narrow the Conduit
interface (and apply transformations if necessary) to the props for the component(s) they delegate to; The represent the boundary between the application logic and the UI logic.
The Conduit
context:
// file: src/app/ui/conduit.tsx
import { createContext, useContext } from 'solid-js';
import { isServer } from 'solid-js/web';
import { makeTodoActions } from '../browser';
import { makeApp } from '../app';
import type { Context, ParentProps } from 'solid-js';
import type { AppProps, ConduitContent } from '../types';
const ConduitContext: Context<ConduitContent | undefined> = createContext();
function ConduitProvider(props: ParentProps<AppProps>) {
const actions = makeTodoActions(props.todosApiHref);
const app = makeApp(actions, props.resumeState);
const conduit = {
addTodo: app.addTodo,
removeTodo: app.removeTodo,
status: app.status,
todos: app.todos,
toggleTodo: app.toggleTodo,
};
if (!isServer) {
// let hydration finish
setTimeout(app.start);
}
return (
<ConduitContext.Provider value={conduit}>
{props.children}
</ConduitContext.Provider>
);
}
function useConduit() {
const conduit = useContext(ConduitContext);
if (!conduit) throw Error('Conduit is not instantiated yet');
return conduit;
}
export { ConduitProvider, useConduit };
The provider assembles the src/app.ts
and places any pertinent app properties on the Conduit
value, exposing it to the entire component tree.
app/ui/todo-list.tsx
is an example of a UI component that taps into the Conduit
context.
// file: src/app/ui/todo-list.tsx
import { useConduit } from './conduit';
import { TodoContent } from './components/todo-content';
import { TodoList as Component } from './components/todo-list';
function TodoList() {
const conduit = useConduit();
return (
<Component
removeTodo={conduit.removeTodo}
toggleTodo={conduit.toggleTodo}
renderContent={TodoContent}
status={conduit.status}
todos={conduit.todos}
/>
);
}
export { TodoList };
It supplies app/ui/components/todo-list.tsx
with the necessary props from Conduit
and applies TodoContent
as the renderContent
render prop.
Finally the app/ui/components/todo-list.tsx
leaf component:
// file: src/app/ui/components/todo-list.tsx
import { For } from 'solid-js';
import { availableStatus, type AvailableStatus } from '../../available-status';
import type { Accessor, JSX } from 'solid-js';
import type { Todo, ToggleTodo } from '../../types';
type Props = {
removeTodo: (id: string) => void;
toggleTodo: (toggle: ToggleTodo) => void;
renderContent: (props: { todo: Todo }) => JSX.Element;
status: Accessor<AvailableStatus>;
todos: Todo[];
};
function TodoList(props: Props) {
// Demonstrate event delegation
const handleEvent = (event: Event) => {
if (event.type != 'click') return;
if (event.target instanceof HTMLButtonElement) {
const parent = event.target.parentElement;
if (!parent) return;
const checkbox = parent.querySelector('input[type="checkbox"]');
if (!(checkbox instanceof HTMLInputElement && checkbox.id)) return;
props.removeTodo(checkbox.id);
return;
}
if (event.target instanceof HTMLInputElement) {
const toggle = {
id: event.target.id,
force: event.target.checked,
};
props.toggleTodo(toggle);
return;
}
};
return () => {
const [disabled, disabledAsString, disabledAsClass]: [
boolean,
'true' | 'false',
string,
] =
props.status() !== availableStatus.READY
? [true, 'true', 'js:c-todo-list--disabled']
: [false, 'false', ''];
return (
<ul
onClick={handleEvent}
class={disabledAsClass}
aria-disabled={disabledAsString}
>
<For each={props.todos}>
{(todo) => (
<li class="c-todo-list__item">
<input
id={todo.id}
type="checkbox"
checked={todo.completed}
disabled={disabled}
/>
{props.renderContent({ todo })}
<button aria-disabled={disabledAsString}>❌</button>
</li>
)}
</For>
</ul>
);
};
}
export { TodoList };
Leaf components have (potentially) two jobs:
- Propagate UI events to the client side app.
TodoList
handles theclick
s on the toggle checkbox or remove button on any one of its todo items. It consequently dispatches the necessary event information to the app with thetoggleTodo
andremoveTodo
props. Ideally it shouldn't modifiy the UI on it's own accord. Even for “optimistic UI”, the app should be likely handling the “optimistic” part. - Project relevant application events to the UI.
TodoList
renders todos accessible via thetodos
store accessor prop. Whenevertodos
propagates any changes,TodoList
updates the list view reactively.
In terms of the JavaScript payload:
building for production...
✓ 15 modules transformed.
dist/client/_astro/client.3f951a76.js 0.86 kB │ gzip: 0.55 kB
dist/client/_astro/app.18d605d3.js 8.90 kB │ gzip: 3.66 kB
dist/client/_astro/web.b010d0bc.js 14.90 kB │ gzip: 6.00 kB
Including a framework does increase the baseline cost but it should scale pretty well and should not escalate to React proportions.
Qwik (now supported inside Astro) also has promise with some caveats:
- Its component and serialization boundaries may have some runtime impact. Also optimal partitioning of any particular application can easily become a non-trivial problem. Only broader use will show whether these concerns will bear out.
- "Streaming the application in chunks" is a blessing that could easily turn into a curse if it is used as a license to let client applications grow arbitrarily large, eventually overwhelming some client devices. An application with a strict JavaScript budget may decide much earlier to adopt a Multi-(Applet-Page) design which could result in better UX for devices of more constrained capabilities.
For the sake of demonstration lets factor out the todo item content from todos-view
. A Web Component isn't necessary as there is no behaviour associated with the content. However we need to render the content separately from the <li>
:
---
// file: src/components/todo-item.astro
import type { Todo } from '../types';
interface Props {
todo?: Todo;
}
const todo = Astro.props.todo;
const [todoId, title] = todo ? [todo.id, todo.title] : ['', ''];
---
<label class="js:c-todo-content" for={todoId}>{title}</label>
The todo's id
is now stored in the label
's for
attribute linking it to the sibling input
's id
. The class name js:c-todo-content
is also added to make it easy to select the root of the content. As this will be included with the templates, the component has to be able to render a blank variant of itself.
The content is removed from todo-item.astro
:
---
// file: src/components/todo-item.astro
import type { Todo } from '../types';
interface Props {
todo?: Todo;
}
const todo = Astro.props.todo;
const [todoId, index, checked] = todo ?
[todo.id, String(todo.index), todo.completed ? '' : undefined] :
['', '', undefined];
---
<li class="c-todos-view__item" data-index={index}>
<input type="checkbox" checked={checked} id={todoId} /><slot /><button
>❌</button>
</li>
The content has been replaced with a slot
. Now todos-view.astro
is responsible for providing the content:
---
// file: src/components/todos-view.astro
import type { Todo } from '../types';
import TodoContent from './todo-content.astro';
import TodoItem from './todo-item.astro';
interface Props {
title: string;
todoItems: Todo[];
}
const { title, todoItems } = Astro.props;
---
<todos-view>
<h3>{title}</h3>
<br />
<h1>To do</h1>
<form>
<input
name="todo-title"
type="text"
placeholder="Add a new to do"
class="js:c-todos-view__title"
/>
<button class="js:c-todos-view__new">✅</button>
</form>
<ul class="js:c-todos-view__list">
{
todoItems.map((todo) => (
<TodoItem {todo}><TodoContent {todo} /></TodoItem>
))
}
</ul>
</todos-view>
The content blank has to be included with the page templates:
---
// file: src/templates/main-templates.astro
import TodoItem from '../components/todo-item.astro';
import TodoContent from '../components/todo-content.astro';
---
<template id="template-todo-item">
<TodoItem />
</template>
<template id="template-todo-content">
<TodoContent />
</template>
The client side module creates the support capabilities that todos-view
can delegate to:
// @ts-check
// file: src/client/components/todo-content.js
/** @typedef {import('../index').Todo} Todo */
const NAME = 'todo-content';
const TEMPLATE_CONTENT_ID = 'template-todo-content';
const SELECTOR_ROOT = '.js\\:c-todo-content';
/** @returns {() => HTMLLabelElement} */
function makeCloneContent() {
const template = document.getElementById(TEMPLATE_CONTENT_ID);
if (!(template instanceof HTMLTemplateElement))
throw Error(`${TEMPLATE_CONTENT_ID} template not found`);
const root = template.content.firstElementChild;
if (!(root instanceof HTMLLabelElement))
throw new Error(`Unexpected ${TEMPLATE_CONTENT_ID} template root`);
return function cloneContent() {
return /** @type {HTMLLabelElement} */ (root.cloneNode(true));
};
}
/** @param {ReturnType<typeof makeCloneContent>} cloneContent
* @param {Todo} todo
* @returns {HTMLLabelElement}
*/
function fillContent(cloneContent, todo) {
const root = cloneContent();
root.htmlFor = todo.id;
if (todo.title) root.appendChild(new Text(todo.title));
return root;
}
/** @type {import('../types').FromTodoContent} */
function fromContent(root) {
if (!(root instanceof HTMLLabelElement)) return [];
const id = root.htmlFor ?? '';
if (id.length < 1) return [];
const text = root.lastChild;
const title = text && text instanceof Text ? text.nodeValue ?? '' : '';
return [id, title];
}
// There is no behavior associated with this content
// so a Web Component isn't necessary
//
function makeSupport() {
const cloneContent = makeCloneContent();
/** @type {(todo: Todo) => HTMLElement} */
const render = (todo) => fillContent(cloneContent, todo);
return {
render,
fromContent,
};
}
export { NAME, SELECTOR_ROOT, makeSupport };
SELECTOR_ROOT
is exported to make it easier for todos-view
to locate the content inside a <li>
when it needs to extract information from it. fromContent()
can then extract the todo's ID and title when it is given the content root. makeSupport()
returns an object with fromContent
and render
. makeSupport()
gives the entry script control when the support functions are created. In this case makeSupport()
should only be called once the DOM has been fully parsed as makeCloneContent
accesses the template-todo-content
from the page.
The entry script is responsible for injecting the support functions into todos-view
:
// @ts-check
// file: src/client/entry.js
import { makeTodoActions } from './app/browser';
import { makeApp } from './app/index';
import * as todoContent from './components/todo-content';
import * as todosView from './components/todos-view';
function assembleApp() {
const actions = makeTodoActions('/api/todos');
return makeApp({
addTodo: actions.addTodo,
removeTodo: actions.removeTodo,
toggleTodo: actions.toggleTodo,
});
}
/** @param { ReturnType<typeof makeApp> } app
* @returns { void }
*/
function hookupUI(app) {
const itemSupport = todoContent.makeSupport();
customElements.define(
todosView.NAME,
todosView.makeClass({
content: {
render: itemSupport.render,
from: itemSupport.fromContent,
selector: todoContent.SELECTOR_ROOT,
},
addTodo: app.addTodo,
removeTodo: app.removeTodo,
toggleTodo: app.toggleTodo,
subscribeTodoEvent: app.subscribeTodoEvent,
})
);
}
hookupUI(assembleApp());
The new content
object provides todos-view
with the tools to
- find the content in a
<li>
(content.selector
) - extract the
id
andtitle
from the content (content.from
) - render the content from a
todo
(content.render
)
This leads the following updates:
// file: src/components/todos-view.js
// …
/** @param {FromTodoContent} fromContent
* @param {string} contentSelector
* @param {HTMLUListElement} list
* @returns {ItemCollection}
*/
function fromUL(fromContent, contentSelector, list) {
const items = list.children;
/** @type {ItemCollection} */
const binders = [];
for (let i = 0; i < items.length; i += 1) {
const root = items.item(i);
if (!(root instanceof HTMLLIElement)) continue;
const content = root.querySelector(contentSelector);
if (!(content instanceof HTMLElement)) continue;
const [id] = fromContent(content);
if (id === undefined) continue;
const value = root.dataset['index'];
const index = value ? parseInt(value, 10) : NaN;
if (Number.isNaN(index)) continue;
const completed = root.querySelector(SELECTOR_CHECKBOX);
if (!(completed instanceof HTMLInputElement)) continue;
const remove = root.querySelector(SELECTOR_REMOVE);
if (!(remove instanceof HTMLButtonElement)) continue;
binders.push(makeItemBinder(root, completed, remove, id, index));
}
return binders.sort(byIndexAsc);
}
fromUL()
is passed contentSelector
to help find the selector root and fromContent
to extract the information from it.
// file: src/components/todos-view.js
// …
/** @param {ReturnType<typeof makeCloneBlankItem>} cloneBlankItem
* @param {TodoRender} contentRender
* @param {Todo} todo
* @returns {[root: HTMLLIElement, binder: ItemBinder]}
*/
function fillItem(cloneBlankItem, contentRender, todo) {
const root = cloneBlankItem();
const checkbox = root.querySelector(SELECTOR_CHECKBOX);
const remove = root.querySelector(SELECTOR_REMOVE);
if (
!(
checkbox instanceof HTMLInputElement &&
remove instanceof HTMLButtonElement
)
)
throw new Error('Unexpected <li> shape for todo');
const content = contentRender(todo);
root.dataset['index'] = String(todo.index);
checkbox.checked = todo.completed;
checkbox.id = todo.id;
remove.before(content);
const binder = makeItemBinder(root, checkbox, remove, todo.id, todo.index);
return [root, binder];
}
fillItem()
is passed contentRender
to render the content right before
the remove button.
The first step is to extract TodoNew
from TodosView
. In this circumstance TodoNew
doesn't render anything client side but in order to clearly demarcate the boundaries the todo-new
Astro component is extracted:
---
// file: src/components/todo-new.astro
---
<form is="todo-new">
<input
name="todo-title"
type="text"
placeholder="Add a new to do"
class="js:c-todo-new__title"
/>
<button class="js:c-todo-new__submit">✅</button>
</form>
While not strictly necessary we'll make this a customized built-in element so that we can just use the is
attribute on the <form>
tag. But this also implies that it won't work on Safari without basing the Web Component on a ponyfill (like builtin-elements
). As we are not accessing anything specific to HTMLFormElement
inside the Web Component, TodoNew
can be just as easily implemented with an autonomous custom element by wrapping the markup in a <todo-new>
tag.
The next step is to add the new responsibility to TodoNew
which first requires a refinement of the app interface.
Updating the TodoView
Astro component to use TodoNew
:
---
// file: src/components/todos-view.astro
import type { Todo } from '../types';
import TodoNew from './todo-new.astro';
import TodoContent from './todo-content.astro';
import TodoItem from './todo-item.astro';
interface Props {
title: string;
todoItems: Todo[];
}
const { title, todoItems } = Astro.props;
---
<todos-view>
<h3>{title}</h3>
<br />
<h1>To do</h1>
<TodoNew />
<ul class="js:c-todos-view__list">
{
todoItems.map((todo) => (
<TodoItem {todo}>
<TodoContent {todo} />
</TodoItem>
))
}
</ul>
</todos-view>
On the client side the relevant capabilties are moved to todo-new.js
:
// @ts-check
// file: src/client/components/todo-new.js
/** @typedef {import('../app').AddTodo} AddTodo */
const NAME = 'todo-new';
const SELECTOR_TITLE = '.js\\:c-todo-new__title';
const SELECTOR_NEW = '.js\\:c-todo-new__submit';
/** @typedef {object} Binder
* @property {HTMLFormElement} root
* @property {HTMLInputElement} title
* @property {HTMLButtonElement} submit
* @property {(this: Binder, event: Event) => void} handleEvent
*/
/** @param {{
* addTodo: AddTodo
* }} dependencies
*/
function makeDefinition({ addTodo }) {
/** @param {HTMLInputElement} title
*/
async function dispatchAddTodo(title) {
await addTodo(title.value);
title.value = '';
}
/** @this Binder
* @param {Event} event
*/
function handleEvent(event) {
if (event.type === 'click' && event.target === this.submit) {
event.preventDefault();
if (this.title.value.length < 1) return;
dispatchAddTodo(this.title);
return;
}
}
class TodoNew extends HTMLFormElement {
/** @type {Binder | undefined} */
binder;
constructor() {
super();
}
connectedCallback() {
const title = this.querySelector(SELECTOR_TITLE);
if (!(title instanceof HTMLInputElement))
throw new Error('Unable to bind to "title" input');
const submit = this.querySelector(SELECTOR_NEW);
if (!(submit instanceof HTMLButtonElement))
throw new Error('Unable to bind to submit button');
/** @type {Binder} */
const binder = {
root: this,
title,
submit,
handleEvent,
};
binder.submit.addEventListener('click', binder);
this.binder = binder;
}
disconnectedCallback() {
if (!this.binder) return;
const binder = this.binder;
this.binder = undefined;
binder.submit.removeEventListener('click', binder);
}
}
return {
name: NAME,
constructor: TodoNew,
options: { extends: 'form' },
};
}
export { makeDefinition };
… so it can be removed from TodosView
:
// file: src/components/todos-view.js
// …
/** @param {{
* content: {
* render: TodoRender;
* from: FromTodoContent;
* selector: string;
* };
* removeTodo: RemoveTodo;
* toggleTodo: ToggleTodo;
* subscribeTodoEvent: SubscribeTodoEvent;
* }} dependencies
*/
function makeDefinition({
content,
removeTodo,
toggleTodo,
subscribeTodoEvent,
}) {
const cloneBlankItem = makeCloneBlankItem();
/** @this Binder
* @param {Event} event
*/
function handleEvent(event) {
if (event.type === 'click') {
// Toggle/Remove Todo
dispatchIntent(toggleTodo, removeTodo, this.items, event.target);
return;
}
}
class TodosView extends HTMLElement {
/** @type {Binder | undefined} */
binder;
constructor() {
super();
}
connectedCallback() {
const list = this.querySelector(SELECTOR_LIST);
if (!(list instanceof HTMLUListElement))
throw new Error('Unable to bind to todo list');
/** @type {Binder} */
const binder = {
root: this,
list,
items: fromUL(content.from, content.selector, list),
handleEvent,
unsubscribeTodoEvent: undefined,
};
binder.unsubscribeTodoEvent = subscribeTodoEvent(
makeTodoNotify(cloneBlankItem, content.render, binder)
);
binder.list.addEventListener('click', binder);
this.binder = binder;
}
disconnectedCallback() {
if (!this.binder) return;
const binder = this.binder;
this.binder = undefined;
binder.list.removeEventListener('click', binder);
binder.unsubscribeTodoEvent?.();
}
}
return {
name: NAME,
constructor: TodosView,
};
}
// …
Note how makeDefinition()
no longer needs access to the addTodo()
dispatch from app. Making the necessary adjustments in the entry point:
// @ts-check
// file: src/client/entry.js
import { makeTodoActions } from './app/browser';
import { makeApp } from './app/index';
import { define } from './components/registry';
import * as todoNew from './components/todo-new';
import * as todoContent from './components/todo-content';
import * as todosView from './components/todos-view';
function assembleApp() {
const actions = makeTodoActions('/api/todos');
return makeApp({
addTodo: actions.addTodo,
removeTodo: actions.removeTodo,
toggleTodo: actions.toggleTodo,
});
}
/** @param { ReturnType<typeof makeApp> } app
* @returns { void }
*/
function hookupUI(app) {
const itemContent = todoContent.makeSupport();
define(todoNew.makeDefinition({
addTodo: app.addTodo,
}));
define(todosView.makeDefinition({
content: {
render: itemContent.render,
from: itemContent.fromContent,
selector: itemContent.selectorRoot,
},
removeTodo: app.removeTodo,
toggleTodo: app.toggleTodo,
subscribeTodoEvent: app.subscribeTodoEvent,
}));
}
hookupUI(assembleApp());
Now addTodo
is injected into TodoNew
. Note how TodosView
and TodoNew
are not coupled to one another but rather to the API contract of the app.
As at this point TodosView
no longer manages new todos; it's responsibility (and scope of control) are better described by TodoList
. Going forward these are the objectives:
- turn
todos-view
intotodo-list
- add the responsibility of the wait/busy indicator to
todo-new
- render both the
TodoList
andTodoNew
Astro components indisabled
mode (to only be activated client side) - add an
AvailableStatus
to the client side app that client components can subscribe to - modify
TodoNew
andTodoList
to subscribe to theAvailableStatus
Starting on the server side:
---
// file: src/components/todo-new.astro
---
<form is="todo-new">
<input
name="todo-title"
type="text"
placeholder="Add a new to do"
class="js:c-todo-new__title js:c-todo-new--disabled"
/>
<button
class="c-todo-new__submit js:c-todo-new__submit js:c-todo-new--disabled"
aria-disabled="true">✅</button
>
</form>
TodoNew
has all the modifications for the component to start up in disabled mode until the client side app signals a ready
AvailableStatus
.
---
// file: src/components/todo-item.astro
import type { Todo } from '../types';
interface Props {
todo?: Todo;
}
const todo = Astro.props.todo;
const [todoId, index, checked, disabled] = todo
? [
todo.id,
String(todo.index),
todo.completed ? 'checked' : undefined,
true,
]
: ['', '', undefined, false];
---
<li class="c-todo-list__item" data-index={index}>
<input type="checkbox" {checked} id={todoId} {disabled} /><slot /><button
aria-disabled={disabled ? 'true' : 'false'}>❌</button
>
</li>
TodoItem
renders disabled
for server side rendering and enabled for the inclusion as a template.
Note: There are reasons to favour
aria-disabled
overdisabled
on buttons.
---
// file: src/components/todo-list.astro
import type { Todo } from '../types';
import TodoContent from './todo-content.astro';
import TodoItem from './todo-item.astro';
interface Props {
todos: Todo[];
}
const { todos } = Astro.props;
---
<ul
is="todo-list"
class="js:c-todo-list--disabled"
aria-disabled="true"
>
{
todos.map((todo) => (
<TodoItem {todo}>
<TodoContent {todo} />
</TodoItem>
))
}
</ul>
Again a customized built-in element is used just to dispense with the wrapping <todo-list>
tag and it is rendered as its disabled
variant.
---
// file: src/pages/index.astro
import Base from '../layouts/base.astro';
import MainTemplates from '../templates/main-templates.astro';
import TodoNew from '../components/todo-new.astro';
import TodoList from '../components/todo-list.astro';
import { selectTodos } from './todos-store';
const todos = await selectTodos(Astro.locals.sessionId);
const title = `Astro "WC: zero to hero" Todos`;
---
<Base {title}>
<main>
<h3>{title}</h3>
<br />
<h1>To do</h1>
<TodoNew />
<TodoList {todos} />
</main>
<MainTemplates />
{
/* <script is:inline id="resume-data" type="application/json" set:html={JSON.stringify(todos)} /> */
}
</Base>
The headings are now just static parts of the page while the TodoNew
and TodoList
Astro components are responsible for rendering the component content that will allow the Web Components to mount correctly.
Currently todo new
, toggle
, and update
happen too quickly on localhost
:
// file: src/lib/delay.ts
function makeDelay<T>(ms = 300) {
return function delay(value: T) {
return ms < 1
? value
: new Promise((resolve, _reject) => {
setTimeout(() => resolve(value), ms);
});
};
}
export { makeDelay };
This function creates an identity function that delays its input by the specified milliseconds.
// file: src/pages/api/todos/index.ts
import { makeDelay } from '../../../lib/delay';
// …
const todo = await appendTodo(context.locals.sessionId, title).then(
makeDelay()
);
// …
// file: src/pages/api/todos/[id].ts
import { makeDelay } from '../../../lib/delay';
// …
if (intent === 'remove')
return remove(context.locals.sessionId, todoId).then(makeDelay());
// Optional `force` field
const force = data.get('force');
return toggle(
context.locals.sessionId,
todoId,
force === 'true' ? true : force === 'false' ? false : undefined
).then(makeDelay());
// …
On the client side the app needs to publish AvailableStatus
:
// @ts-check
// file: src/client/app/available-status.js
const availableStatus = /** @type {const} */ ({
UNAVAILABLE: -1,
WAIT: 0,
READY: 1,
});
export { availableStatus };
// @ts-check
// file: src/client/app/index.js
import { availableStatus } from './available-status';
import { Multicast } from '../lib/multicast.js';
/** @typedef {import('../index').Todo} Todo */
// Types implemented for UI
/** @typedef {import('../app').AvailableStatus} AvailableStatus */
/** @typedef {import('../app').SubscribeStatus} SubscribeStatus */
/** @typedef {import('../app').AddTodo} AddTodo */
/** @typedef {import('../app').RemoveTodo} RemoveTodo */
/** @typedef {import('../app').ToggleTodo} ToggleTodo */
/** @typedef {object} Platform
* @property {import('./types').AddTodo} addTodo
* @property {import('./types').RemoveTodo} removeTodo
* @property {import('./types').ToggleTodo} toggleTodo
*/
/** @param { Platform } platform
*/
function makeApp(platform) {
/** @type {AvailableStatus} */
let status = availableStatus.UNAVAILABLE;
/** @type {Multicast<import('../app').AvailableStatus>} */
const available = new Multicast();
/** @type {SubscribeStatus} */
const subscribeStatus = (sink) => {
const unsubscribe = available.add(sink);
sink(status);
return unsubscribe;
};
const readyStatus = () => {
status = availableStatus.READY;
available.send(status);
};
const waitStatus = () => {
status = availableStatus.WAIT;
available.send(status);
};
/** @type {Multicast<import('../app').TodoEvent>} */
const todoEvents = new Multicast();
/** @type { AddTodo } */
const addTodo = async (title) => {
waitStatus();
try {
const todo = await platform.addTodo(title);
todoEvents.send({ kind: 'todo-new', todo });
} finally {
readyStatus();
}
};
/** @type { RemoveTodo } */
const removeTodo = async (id) => {
waitStatus();
try {
const removed = await platform.removeTodo(id);
if (!removed) return;
todoEvents.send({ kind: 'todo-remove', id });
} finally {
readyStatus();
}
};
/** @type { ToggleTodo } */
const toggleTodo = async (id, force) => {
waitStatus();
try {
const todo = await platform.toggleTodo(id, force);
todoEvents.send({ kind: 'todo-toggle', id, completed: todo.completed });
} finally {
readyStatus();
}
};
const start = () => {
if (status !== availableStatus.UNAVAILABLE) return;
readyStatus();
};
return {
addTodo,
removeTodo,
toggleTodo,
start,
subscribeTodoEvent: todoEvents.add,
subscribeStatus,
};
}
export { makeApp };
The app's AvailableStatus
is initialized to UNAVAILABLE
and isn't advanced to READY
until start()
is run. This way none of the components will receive the READY
status until all the necessary functionality has been wired up. subscribeStatus
wraps Multicast.add()
by immediately sending the current status
to the newly registered sink
. The convenience functions readyStatus()
and waitStatus()
set the status
and broadcast the change to the listeners.
addTodo()
, removeTodo()
, and toggleTodo
now start by switching to WAIT
and not going back the READY
until the asynchronous operation completes. readyStatus()
is invoked inside of a finally
block in case the operation throws an exception (which may not be warranted in some cases).
Both start
and subscribeStatus
are added to the returned API object.
// @ts-check
// file: src/client/entry.js
import { makeTodoActions } from './app/browser';
import { makeApp } from './app/index';
import { define } from './components/registry';
import * as todoNew from './components/todo-new';
import * as todoContent from './components/todo-content';
import * as todoList from './components/todo-list';
function assembleApp() {
const actions = makeTodoActions('/api/todos');
return makeApp({
addTodo: actions.addTodo,
removeTodo: actions.removeTodo,
toggleTodo: actions.toggleTodo,
});
}
/** @param { ReturnType<typeof makeApp> } app
* @returns { void }
*/
function hookupUI(app) {
const itemContent = todoContent.makeSupport();
define(todoNew.makeDefinition({
addTodo: app.addTodo,
subscribeStatus: app.subscribeStatus,
}));
define(todoList.makeDefinition({
content: {
render: itemContent.render,
from: itemContent.fromContent,
selector: itemContent.selectorRoot,
},
removeTodo: app.removeTodo,
toggleTodo: app.toggleTodo,
subscribeStatus: app.subscribeStatus,
subscribeTodoEvent: app.subscribeTodoEvent,
}));
}
const app = assembleApp();
hookupUI(app);
app.start();
hookupUI()
supplies both TodoNew
and TodoList
with the subscribeStatus()
access and finally invokes app.start()
when everything is wired up.
// @ts-check
// file: src/client/components/todo-new.js
import { availableStatus } from '../app/available-status';
/** @typedef {import('../app').AddTodo} AddTodo */
/** @typedef {import('../app').AvailableStatus} AvailableStatus */
/** @typedef {import('../app').SubscribeStatus} SubscribeStatus */
const NAME = 'todo-new';
const SELECTOR_TITLE = '.js\\:c-todo-new__title';
const SELECTOR_NEW = '.js\\:c-todo-new__submit';
const MODIFIER_DISABLED = 'js:c-todo-new--disabled';
const MODIFIER_WAIT = 'js:c-todo-new--wait';
/** @typedef {object} Binder
* @property {HTMLFormElement} root
* @property {HTMLInputElement} title
* @property {HTMLButtonElement} submit
* @property {boolean} disabled
* @property {(this: Binder, event: Event) => void} handleEvent
* @property {(() => void) | undefined} unsubscribeStatus
*/
/** @param {Binder} binder
* @param {AvailableStatus} status
*/
function onAvailable(binder, status) {
const [disabled, wait] =
status === availableStatus.READY
? [false, false]
: status === availableStatus.WAIT
? [true, true]
: [true, false];
binder.submit.classList.toggle(MODIFIER_WAIT, wait);
binder.disabled = disabled;
binder.submit.classList.toggle(MODIFIER_DISABLED, disabled);
binder.submit.setAttribute('aria-disabled', String(disabled));
binder.title.classList.toggle(MODIFIER_DISABLED, disabled);
}
/** @param {{
* addTodo: AddTodo;
* subscribeStatus: SubscribeStatus;
* }} dependencies
*/
function makeDefinition({ addTodo, subscribeStatus }) {
// …
class TodoNew extends HTMLFormElement {
/** @type {Binder | undefined} */
binder;
constructor() {
super();
}
connectedCallback() {
// …
/** @type {Binder} */
const binder = {
root: this,
title,
submit,
disabled: submit.classList.contains(MODIFIER_DISABLED),
handleEvent,
unsubscribeStatus: undefined,
};
binder.submit.addEventListener('click', binder);
binder.unsubscribeStatus = subscribeStatus((status) =>
onAvailable(binder, status)
);
this.binder = binder;
}
disconnectedCallback() {
if (!this.binder) return;
const binder = this.binder;
this.binder = undefined;
binder.submit.removeEventListener('click', binder);
binder.unsubscribeStatus?.();
}
}
return {
name: NAME,
constructor: TodoNew,
options: { extends: 'form' },
};
}
export { makeDefinition };
In onAvailable
both UNAVAILABLE
and WAIT
result in disabled but only WAIT
results in wait (which activates the spinner). The presence of MODIFIER_DISABLED
on the remove button's classList
is used to initialize the new disabled
property on Binder
.
// @ts-check
// file: src/client/components/todo-list.js
import { availableStatus } from '../app/available-status';
// …
/** @returns {() => HTMLLIElement} */
function makeCloneBlankItem() {
const template = document.getElementById(TEMPLATE_ITEM_ID);
if (!(template instanceof HTMLTemplateElement))
throw Error(`${TEMPLATE_ITEM_ID} template not found`);
const root = template.content.firstElementChild;
if (!(root instanceof HTMLLIElement))
throw new Error(`Unexpected ${TEMPLATE_ITEM_ID} template root`);
return function cloneBlankItem() {
return /** @type {HTMLLIElement} */ (root.cloneNode(true));
};
}
// …
/** @typedef {object} Binder
* @property {HTMLUListElement} root
* @property {boolean} disabled
* @property {ItemCollection} items
* @property {(this: Binder, event: Event) => void} handleEvent
* @property {(() => void) | undefined} unsubscribeStatus
* @property {(() => void) | undefined} unsubscribeTodoEvent
*/
// …
/** @param {Binder} binder
* @param {AvailableStatus} status
*/
function onAvailable(binder, status) {
const disabled = status !== availableStatus.READY;
const value = disabled ? 'true' : 'false';
binder.disabled = disabled;
binder.root.classList.toggle(MODIFIER_DISABLED, disabled);
binder.root.setAttribute('aria-disabled', value);
for (let i = 0; i < binder.items.length; i += 1) {
const item = binder.items[i];
item.completed.disabled = disabled;
item.remove.setAttribute('aria-disabled', value);
}
}
/** @param {{
* content: {
* render: TodoRender;
* from: FromTodoContent;
* selector: string;
* };
* removeTodo: RemoveTodo;
* toggleTodo: ToggleTodo;
* subscribeStatus: SubscribeStatus;
* subscribeTodoEvent: SubscribeTodoEvent;
* }} dependencies
*/
function makeDefinition({
content,
removeTodo,
toggleTodo,
subscribeStatus,
subscribeTodoEvent,
}) {
const cloneBlankItem = makeCloneBlankItem();
/** @this Binder
* @param {Event} event
*/
function handleEvent(event) {
if (this.disabled) return;
if (event.type === 'click') {
// Toggle/Remove Todo
dispatchIntent(toggleTodo, removeTodo, this.items, event.target);
return;
}
}
class TodoList extends HTMLUListElement {
/** @type {Binder | undefined} */
binder;
constructor() {
super();
}
connectedCallback() {
/** @type {Binder} */
const binder = {
root: this,
disabled: this.classList.contains(MODIFIER_DISABLED),
items: fromUL(content.from, content.selector, this),
handleEvent,
unsubscribeStatus: undefined,
unsubscribeTodoEvent: undefined,
};
binder.unsubscribeStatus = subscribeStatus((status) =>
onAvailable(binder, status)
);
binder.unsubscribeTodoEvent = subscribeTodoEvent(
makeTodoNotify(cloneBlankItem, content.render, binder)
);
this.addEventListener('click', binder);
this.binder = binder;
}
disconnectedCallback() {
if (!this.binder) return;
const binder = this.binder;
this.binder = undefined;
binder.root.removeEventListener('click', binder);
binder.unsubscribeStatus?.();
binder.unsubscribeTodoEvent?.();
}
}
return {
name: NAME,
constructor: TodoList,
options: { extends: 'ul' },
};
}
export { makeDefinition };
The disabled
property is stored on TodoList
's Binder
. onAvailable()
only concerns itself with disabled (not wait) and sets the list items functionality accordingly. handleEvent()
now discards events that are received in the disabled state and the initial disabled state is derived from the presence of MODIFIER_DISABLED
on the <ul>
classList
.
From a recent article:
HTML web components encourage a mindset of augmentation instead.
This is about progressive enhancement, i.e. rendering HTML on the server, letting the browser build the DOM without JS and then handing the completed DOM over to JavaScript to augment. A more appropriate name for progressively enhanced elements would have been Custom Elements—a term already reserved to distinguish between customized built-in elements and autonomous custom elements.
Customized build-in elements are the closest to the notion of progressively enhanced elements but WebKit has no intent of supporting them (though this can be mitigated with builtin-elements
).
This is perhaps why Web Component tutorials primarily focus on autonomous custom elements. Consequently Web Component tutorials (and proponents) seem to focus on using autonomous custom elements for implementing fully client-side rendered UI components, serving as an alternative to framework components.
However:
Rich Harris, the author of Svelte, made the claim that “Frameworks aren’t there to organize your code, but to organize your mind”. I feel this way about Components. There are always going to be boundaries and modules to package up to keep code encapsulated and re-usable. Those boundaries are always going to have a cost, so I believe we are best served to optimize for those boundaries rather than introducing our own.
Source: The Real Cost of UI Components (2019)
- Browsers load pages, not components (i.e. browsers don't benefit from components, while being incredibly efficient at transforming HTML to a DOM-tree and styling the page based on CSS rulesets).
- Natural boundaries of visual design, DOM subtrees, and application capability units don't necessarily coincide. Typical UI component boundaries may be convenient in terms collocation but often encompass too many responsibilities making them too coarse-grained to be cohesive; other times UI component boundaries are too fine-grained, leading to inappropriate intimacy.
- Proximity of entities within the UI tree doesn't necessarily correlate with the needed inter-entity communication patterns.
I.e. component-orientation is largely about developer convenience and enabling speculative reuse rather than producing a high (UX) value end product.
From the HTML specification 4.4.6 The ul
element:
- Zero or more
li
and script-supporting elements.
and 4.4.8 The li
element:
i.e. the <li>
tags should be direct children to the <ul>
tag.
Inspecting the example's DOM tree one finds that the <ul>
element's direct children aren't <li>
elements but <to-do-item>
elements which later in their shadowroot
contain the <li>
element. This could be avoided with customized built-in elements and the is
attribute (…but Safari…; ponyfill). Of course, technically there never was any HTML involved, as everything is just rendered by the Web Component's JavaScript.
However the phrasing of the HTML spec strongly suggests that the <li>
element is tightly coupled to the list that contains it. So in terms of boundaries the list and its items should be managed by the same entity; it's only the content of the <li>
that may need to be managed separately; of the content the “completed” checkbox and “remove” button still belong to list management. So the <label>
containing the todo title is the only item content left (without any behaviour/interactivity). Given the coupling in the names (to-do-app
, to-do-item
) I decided to just collapse the two into todos-view
(though later: Factoring Out TodoContent).
A component that could be factored out is a "new todo input" which could also double as a busy indicator (see: Factoring Out TodoNew). This way todo-view
could focus on removing unwanted items from the list, adding new items (arriving from the server) to the list and (un)completing existing items.
So what are Web Components good for? When including shadow DOM
and declarative shadow DOM it's suggestive of “<iframe>
Enterprise Edition”, i.e. a more refined means for deploying third party content (mostly ads) to be included in affilated content sites/applications.
Built-in custom elements are extremely useful for augmenting existing HTML elements but WebKit's position makes it necessary to base those on a ponyfill.
I really, really wanted to like Web Components, being part of the platform and all. Started with HowTo Components (2017, repo, some explainers) and continued with the Web Components in Action MEAP (2019, gists: 1, 2, 3, 4, 5, 6). At least by the end of WCA there was a suggestion of using an eventbus
to enable communication patterns among a page's Web Components that weren't coupled to their position in the DOM tree or whatever other Web Component happend to create its instance (and by extension its attributes). However it was still fully immersed in the SPA mindset of full on client-side rendering.
It was during this time that Andy Bell suggested in A progressive disclosure component that a Web Component could simply take responsibility of the existing light DOM tree under it. Heydon Pickering expanded on it later in Eschewing Shadow DOM.
The history of Web Components goes back to 2011 and then the CSR focus was understandable given that web servers were often slow to render and web content was largely consumed on fat-core desktop computers. And while WC's purview was restricted to the browser it should have been feasible to develop a cross-platform templating language to support the specification. That templating language could have been natively supported by browsers while being constrained to be easily implementable in any server side language with string concatenation and without requiring any form of DOM emulation.
With the first release of Next.js in (2016) the writing was on the wall that CSR wasn't enough even for component-oriented architectures. In 2018 @popeindustries/lit-html-server appeared on npm for WC SSR (later to be included in the Stack Overflow PWA Demo), so the shortfall was becoming extremely apparent.
However one idea that didn't seem to catch on was settling on using <template>
elements on the server rendered page to eliminate server and client template duplication, instead forcing the use of JS on the server to run JS Web Components for rendering as the preferred solution. This is likely due to the fact that delegating rendering markup to the server entirely removes the convenient “everything and the kitchen sink” collocation of component-orientation. Framework components have created expectations that vanilla Web Components simply cannot meet, while not all "components" need to be DOM elements (Web Components Aren’t Components).
There are plenty of competent Web Component analyses around, some of which are:
- Why I don't use Web Components (2019)
- Maybe Web Components are not the future? (2020)
- The failed promise of Web Components (2020)
- About Web Components (2021)
Aside from the lack of accomodation of WC SSR in the official spec, my other pet peeve is the way (Web) Component properties are often used (which was popularized by React).
Attributes in static markup make sense. They express which specific variation of a generic element should be instantiated during creation of the live element. From a class-oriented perspective attributes are like constructor arguments (also constructor injection). But before Web Components, Element
s were programmatically created with document.createElement()
which provided no way of passing constructor arguments.
Here any configuration has to occur after creation via properties (also setter injection; properties are just glorified setters (and getters); even back in 2003 it was clear that getters and setters are evil).
With the existance of element properties it is even forgivable to use properties for some mild document interactivity/automation.
But then React popularized the notion of props
(short for properties). They appear in the class
constructor, so they're constructor arguments right? Not exactly. They're just the values React was given to first render this particular component instance. props
also exist on the class
instance (this
) but really isn't owned by it (from the instance perspective props
are read-only) but by React as it adjusts props
whenever something passes new props
by rendering a ReactElement
to update the component instance. So in this way props
is a "setter" strictly for React's benefit.
This enabled the now entrenched CSR ownership composition (rather than children
) where state is advanced by modifying the props of nested (ownee) components. With "functions as components" (function components, not functional components) the role of props
has been clarified but the practice of component communication via props
remains.
What is usually glossed over is that the position of components in the component tree is related to the visual layout of the page which doesn't necessarily lead to the proximity of components that need to communicate with one another for the application to work.
These inter-component communication requirements are often satisfied via external state referenced via context.
Ownership-based (or nested) composition tends to also surface in the design of many Web Components. One issue is that the creation values (attributes) are limited to being strings for vanilla WCs. WC-based libraries will often work around this by providing library specific tagged templates which create the WC instance behind the scenes to then later provide that instance with any non-string values via its instance properties. However the fundamental issue remains. The "actor" rendering the component isn't necessary the component (or components) that needs to communicate with it during its lifetime within the application.
That is why in this demo components don't communicate with one another. And while Web Components are class
-based they are unabled to accept any constructor arguments. But thanks to the wonderful weirdness of JavaScript we can declare classes at runtime! That way Web Component classes are not created until the necessary dependencies have been passed to the supporting module. Now this sequence of operations is followed:
- the client side application is resumed with the
resume-data
found on the page - with the application primed, its services can be passed to create the necessary Web Component classes (e.g.
makeDefinition()
) - once the Web Component class is registered it can bind to the existing sites on the DOM but connects directly to the application services to
- receive updates during its lifetime from the application
- delegate user interactions for interpretation by the application
Note how component rendering has now been completely separated from component updates. So while one WC can render another nested WC it doesn't retain any "ownership" over it. A fresh component instance immediately registers itself with the application which takes ownership.
Component-orientation often talks about global state—here we just call it the application. And there is nothing evil global about it. During the UI definition phase each component module/factory is injected with exactly the dependencies it needs; nothing less, nothing more. And it's the application that handles all the inter-component communication, so lack of component proximity within the page layout (leading to prop drilling) isn't an issue.