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.