The diff for this lesson is mostly deletions. Every document.getElementById,
the manual render pass, the repaint watcher, and the handlers that call actions
— gone. What replaces them is one component and an entry file with nothing in it
but imports. The page will briefly show less than it did, which is what
rebuilding on a real foundation looks like. Checkpoint:
ui-03.
Registration is an import
import { startQueryRunner } from './state/query-runner.js';
import './components/db-status.js';
startQueryRunner();
That is the whole entry file. The import is not for a value — it is for the side
effect of @customElement('db-status') running, which calls
customElements.define. Import a component module and its tag works; the
browser upgrades every matching element already in the document.
This is why the page paints before any data exists. The HTML is static, the elements are inert until their definitions arrive, and DuckDB’s 34 MB download is happening on a different thread the whole time. There is no render-blocking JavaScript bundle deciding whether a header may appear.
A task renders its own states
db-status owns the bootstrap, and it does it with @lit/task:
@customElement('db-status')
export class DbStatus extends LitElement {
private _init = new Task(this, {
args: () => [] as const,
task: bootstrapDb,
});
render(): TemplateResult {
return this._init.render({
pending: () => html`<p role="status">Loading DuckDB…</p>`,
complete: (result) => html`<p role="status">${this._summary(result)}</p>`,
error: (e) => html`<p class="error" role="alert">Database failed to initialize: ${String(e)}</p>`,
}) as TemplateResult;
}
}
Task runs an async function and gives you three render branches. What it
removes is the state machine everyone writes by hand — loading, error,
data, three fields to keep consistent, and the race where a fast failure
leaves a spinner up forever.
The two role attributes are not decoration. role="status" announces politely
when the text changes; role="alert" interrupts. A sighted user sees the status
line change; without these, a screen reader user gets nothing at all.
The task is also where dbReady gets flipped, which starts the query runner
working. The chain is: component upgrades → task runs → bootstrap completes →
dbReady becomes true → runner’s watcher fires → queries run → queryResult
lands. Nothing polls, and nothing is ordered by hand.
Reading a signal is subscribing
db-status does not read the store, but every component after it does, and the
mechanism is worth stating before it appears:
export class StatCard extends SignalWatcher(LitElement) {
render() {
const { totalRecords } = stats.get(); // ← this is the subscription
return html`<dd>${totalRecords.toLocaleString()}</dd>`;
}
}
SignalWatcher is a mixin that wraps the component’s render in the same watcher
dance from lesson 2 — read a signal during render and the component re-renders
when it changes. No subscribe, no useSelector, no cleanup. Which is exactly
why the repaint watcher you wrote by hand last lesson is deleted here: twelve
components are about to need that behaviour, and none of them will implement it.
Styles that cannot leak
Each component ships CSS in its shadow root, so a rule in one cannot affect another. That is a guarantee, not a convention — which raises the obvious question of how anything gets themed.
Custom properties are the answer: they inherit through shadow boundaries.
Tokens defined on :root in the global stylesheet reach every component, while
selectors do not. So the global sheet keeps exactly three jobs:
@layer reset, tokens, base, layout, components, interactions;
Reset, tokens, page layout. Everything about how a table or a chart looks moves
inside its component. shared-styles.ts covers what genuinely repeats —
export const baseStyles = css` /* focus rings, reduced motion, scrollbars */ `;
export const skeletonStyles = css` /* the shimmer placeholder */ `;
export const visuallyHiddenStyles = css` /* screen-reader-only text */ `;
— composed into a component’s static styles array. These exist because of
encapsulation: a global :focus-visible rule cannot reach inside a shadow root,
so cross-cutting concerns must be shipped rather than inherited. That is the
tax, and it is smaller than the bugs it prevents.
What you lose, briefly
Deleting the scaffolding deletes the tables it rendered. After this lesson the page is a header, a status line, and an intro paragraph — the transactions table, the totals, the pivot, and the console are all gone.
They come back as components, one lesson at a time: stat cards next, then
filters, charts, the table in lesson 9, the console in lesson 10. The queries
never stopped running; the runner is still filling queryResult on every
change, with nothing reading it yet. Watching the data reappear piece by piece
is the clearest possible demonstration that the store and the interface are
genuinely separate.
Build it
| File | Action | What goes in it |
|---|---|---|
package.json |
modify | Add lit and @lit/task |
src/components/shared-styles.ts |
write | The composable style fragments |
src/components/db-status.ts |
write | The @lit/task bootstrap component with its three render branches |
index.html |
modify | Replace the scaffolding with the app shell: header, header-tools, main, intro |
src/index.ts |
modify | Delete everything except the runner call and the component import |
src/style.css |
modify | Cascade layers, design tokens, page layout only |
Done when: the header and intro paragraph render immediately on load, and
the status line moves from Loading DuckDB… to Seeded 20,000 rows → saved to OPFS (or Restored… on a reload) without any other JavaScript running on the
page.
Answer key: ui-02...ui-03.
Challenge: break the task on purpose
Make bootstrapDb throw on its first line and reload. The error branch renders,
the shell stays up, and nothing else on the page is affected — compare that with
what an exception during the old imperative bootstrap did. Then throttle your
network to Slow 3G in DevTools and reload to watch the pending branch for
several seconds, which is the state most users will actually see once.
Next lesson: three numbers, one component, and the skeletons that stand in for them.