The engine course left you with a working page and no architecture: local variables holding the current page number, a renderPage() called by hand, and every control knowing how to query the database. This lesson moves all of that state into one module and leaves the page rendering by hand — deliberately. The half that remains is the argument for lesson 2. Checkpoint: ui-01.

If you did not take the engine course, clone the companion repository and check out engine-09. That is exactly where this lesson begins.

A module is a store

There is no store class, no provider, no context, and no framework. signal() creates a reactive cell; a module that exports some is a store:

import { signal, computed } from '@lit-labs/signals';

export const dateRange = signal<DateRange>({ startDate: DATE_MIN, endDate: DATE_MAX });
export const selectedFruits = signal<string[]>([]);
export const pageSize = signal(10);
export const currentPage = signal(1);
export const sortColumn = signal<SortColumn>('date');
export const sortDirection = signal<SortDirection>('asc');
export const tableView = signal<TableView>('transactions');

/** Flipped by the bootstrap task once DuckDB is initialized and seeded. */
export const dbReady = signal(false);

/** Latest query result; null until the first query lands. */
export const queryResult = signal<QueryResult | null>(null);

@lit-labs/signals is Lit’s packaging of the TC39 signals proposal — a language-level proposal, not a framework feature, which is why the store below has no Lit in it at all.

The split matters. The first seven are inputs — things a user changes. The last two are outcomes — things the system reports. Every query in the app is a function of the inputs; everything on screen is a function of the outcomes.

Derived values are not stored

computed() takes a function and caches its result until something it read changes:

export const tableData = computed<TableData>(() => {
    const result = queryResult.get();
    const size = pageSize.get();
    const totalRows = result?.totalRows ?? 0;
    return {
        headers: ["Date", "Item", "Amount"],
        rows: result?.rows ?? [],
        currentPage: currentPage.get(),
        totalPages: Math.max(1, Math.ceil(totalRows / size)),
        pageSize: size,
    };
});

export const stats = computed<Stats>(() => {
    const { startDate, endDate } = dateRange.get();
    const days = Math.ceil(
        (new Date(endDate).getTime() - new Date(startDate).getTime()) / (1000 * 60 * 60 * 24)
    );
    return {
        totalRecords: queryResult.get()?.totalRows ?? 0,
        dateRangeDays: days,
        itemCount: chartData.get().length,
    };
});

Nothing subscribes and nothing is assigned. Reading a signal inside a computed registers the dependency; that is the entire mechanism.

totalPages is the example worth dwelling on. It is not state. Storing it would mean recalculating it whenever rows or page size changed, and eventually forgetting one of those places — the classic bug where a footer says “Page 3 of 7” while the table shows the last page. As a computed it cannot go stale, because there is nowhere for it to be wrong.

initialLoading is a computed too, and it is a single expression:

export const initialLoading = computed(() => queryResult.get() === null);

No isLoading flag to set in two places and forget in a third. Loading is “no result yet”.

Actions are the only way in

The signals are exported and technically writable from anywhere. The discipline is that nothing calls .set() except this file:

export function setDateRange(startDate: string, endDate: string): void {
    dateRange.set({ startDate, endDate });
    currentPage.set(1);
}

export function setFruitSelection(fruits: string[]): void {
    selectedFruits.set([...fruits]);
    currentPage.set(1);
}

/** Same column toggles direction; a new column starts ascending. */
export function setSort(column: SortColumn): void {
    if (sortColumn.get() === column) {
        sortDirection.set(sortDirection.get() === 'asc' ? 'desc' : 'asc');
    } else {
        sortColumn.set(column);
        sortDirection.set('asc');
    }
    currentPage.set(1);
}

Look at what those three have in common: currentPage.set(1).

Filtering while on page 40 of a result that now has 3 pages shows an empty table. The fix is one line, and the question is where it lives. Put it in the event handlers and every present and future call site has to remember it. Put it in the action and it cannot be forgotten, because there is no other way to change a filter.

setFruitSelection copies its input with [...fruits] — a signal holding an array the caller can still mutate is a signal that changes without notifying anyone.

One imperative function, still

bootstrap.ts wraps the engine course’s startup sequence and writes its results into the store:

export async function bootstrapDb(): Promise<BootstrapResult> {
    const metrics = await db.init();
    initMetrics.set(metrics);

    const seeded = metrics.existingRows === 0;
    if (seeded) {
        seedMetrics.set(await db.seedRandom(ITEMS, DATE_MIN, DATE_MAX, SEED_ROWS));
    } else {
        seedMetrics.set(null);
    }

    storageInfo.set(await db.getStorageInfo());
    dbReady.set(true);
    return { metrics, seeded };
}

dbReady last, on purpose — it is the gate everything else waits on.

The scaffolding now reads computeds and calls actions, and still ends every handler the same way:

document.getElementById('next')!.addEventListener('click', () => { nextPage(); void refresh(); });

That void refresh() is the leftover. The store already knows the page changed; something should be watching it. Writing the same call at the end of five handlers is a to-do list you maintain by hand, and the next lesson deletes every one of them.

Build it

File Action What goes in it
package.json modify Add @lit-labs/signals
src/types.ts modify DateRange and QueryResult — the two shapes the store assembles
src/state/explorer.ts write Input signals, outcome signals, computeds, and every action
src/state/bootstrap.ts write bootstrapDb and reseedDb, writing metrics into the store
src/index.ts modify Read computeds, call actions, keep the manual refresh() for now
index.html modify A summary line and a summary-view toggle button

Done when: the page still works exactly as it did, no local variable holds filter or page state, and the pivot query runs only when the summary view is active.

Answer key: engine-09...ui-01.

Challenge: prove the page reset

Filter to a single item, page to the last page, then clear the filter — and note what page you land on. Now remove currentPage.set(1) from setFruitSelection and do it again. The empty table you get is the bug this action prevents, and having caused it once is the reason you will put that line in the next store you write.

Next lesson: the watcher that deletes every void refresh() on the page.