Web Rendering
Full rendering contract for the reference web app’s rendering stack
(examples/web/use-store.ts):
one root render(), per-binding subscriptions via useStore, a computed
view-model when a branch needs several atoms, repeat() for lists, cache()
for route views, and rootPart.setConnected(false) for teardown.
Core rule
Section titled “Core rule”Call render() once on the app root. Do not subscribe to stores with effect() (or similar) to re-run the whole template. Bind dynamic text with useStore($store) or useStore($store, select) so each binding subscribes via store.listen() and updates only that DOM node through setValue().
Inside directives, use listen(), not subscribe(), so the initial value comes from render() and you do not get a redundant first callback.
Consumer footgun: useStore is a directive, not a value
Section titled “Consumer footgun: useStore is a directive, not a value”useStore is a lit-html AsyncDirective and is only valid as a direct template expression:
html`${useStore($x)}`;html`${useStore($x, (v) => renderLabel(v))}`;lit-html evaluates the directive inside the directive lifecycle and replaces the returned marker with the live value. Do not assign the result to a variable and dereference it:
// ❌ Wrong — `v` is a directive marker, not the store valueconst v = useStore($x);return html`${v}`;
// ✅ Right — direct template expressionreturn html`${useStore($x)}`;Dereferencing the marker crashes (Cannot read properties of undefined) or silently breaks the branch. To bind several atoms and read fields, do not compose useStore calls — derive one computed view-model and consume it with a single binding:
const $vm = computed([$user, $session], (user, session) => ({ name: user.name, online: session.online,}));
return html`${useStore($vm, (vm) => body(vm))}`;The same applies to any directive wrapper you add (useMapKey, …): it is a marker for lit-html, not a value.
Store patterns (nanostores)
Section titled “Store patterns (nanostores)”atom— single value;set/get.map— object;setKeyso listeners can target keys (pair withlistenKeyswhen you add auseMapKey-style directive).computed— derived, read-only, glitch-free; use for values derived from other stores so subscribers can depend on the minimum.batched— derived value recomputed once per microtask when several deps change together.onMount— run when the first listener appears; return cleanup when the last leaves (good for fetch, intervals). Subscriptions fromuseStorecount as listeners.
Use repeat() from lit-html/directives/repeat.js with a stable key function so reorder reuses DOM and only changed rows update. Often wrapped in useStore($items, (items) => repeat(...)).
Routing
Section titled “Routing”Router store: examples/web/router.ts. In the shell template, use useStore($router, (page) => cache(routeView(page))) and wrap each route’s subtree in cache() from lit-html/directives/cache.js so DOM is preserved across transitions and async directives are paused correctly. Use getPagePath($router, 'routeName') for <a href> so the router’s document click handler can intercept in-app navigation.
Memory: AsyncDirective and teardown
Section titled “Memory: AsyncDirective and teardown”Standalone lit-html does not know when you remove the container from the document. Before discarding the rendered tree, get the RootPart returned from render() and call setConnected(false) so useStore (and similar) run disconnected() and unsubscribe. cache() handles subtree lifecycle for cached route views.
When a full re-render is unavoidable
Section titled “When a full re-render is unavoidable”If a whole section must re-render from store updates, batch with queueMicrotask so multiple store events in one tick produce one render() pass.
Optional: map key directive
Section titled “Optional: map key directive”For map stores, listenKeys($store, ['name', ...], cb) avoids firing on unrelated keys. A small useMapKey directive (same idea as useStore, but listenKeys instead of listen) is useful when you add map-backed UI; defer until then.
Rules summary
Section titled “Rules summary”| Practice | Why |
|---|---|
One root render(), directives for updates | Avoids full template re-evaluation |
listen() in directives | No duplicate initial push |
computed / batched / listenKeys | Minimal subscriptions, one update per tick where needed |
repeat + keys | DOM reuse for lists |
cache for route views | Preserves DOM and directive lifecycle |
rootPart.setConnected(false) before removal | Prevents subscription leaks |