Skip to content

Use signals, context, tasks and the virtualizer

Components built with @lit-labs/signals, @lit/context, @lit/task or @lit-labs/virtualizer keep their state across an edit when shared objects live in their own module. Each section shows the working shape and its one caveat.

State that lives on the instance survives a hot patch, an edit applied to the running page without a reload. State that lives in the edited module’s scope does not, because the module re-executes.

So every shared object gets its own module: the signal, the context key, the API client. A component edit never re-executes those modules, and the caveats below mostly disappear.

The signals html and svg tags are interned like the core ones, and the SignalWatcher mixin’s regenerated class chain is re-parented during a patch. Per-instance signals and signals imported from other modules both keep their value and their reactivity.

src/shared-signal.ts
import {signal} from '@lit-labs/signals';
/**
* Lives in its own non-component module on purpose: this module never
* self-accepts, so editing the components that consume it does not
* re-execute it — the signal object (and its value) survive HMR updates.
* A module-level signal declared inside an edited component module would
* be re-created (fresh value) on every update; that's inherent to module
* re-execution.
*/
export const sharedCounter = signal(0);
src/hmr-signals.ts
import {LitElement, css} from 'lit';
import {customElement} from 'lit/decorators.js';
import {SignalWatcher, html, signal} from '@lit-labs/signals';
import {sharedCounter} from './shared-signal.js';
/**
* Signals surface: the `SignalWatcher` mixin regenerates its intermediate
* class on every module evaluation (exercising prototype re-parenting), the
* signals `html` tag must intern like the core one, and both the shared
* (separate-module) signal and the per-instance signal must keep value and
* reactivity across a hot patch.
*/
@customElement('hmr-signal-counter')
export class HmrSignalCounter extends SignalWatcher(LitElement) {
static override styles = css`
.badge {
font-size: 0.8em;
color: var(--muted, #666);
}
`;
private local = signal(0);
private renders = 0;
override render() {
return html`
<h2>Signals: HELLO</h2>
<button id="signal-increment" @click=${this.incrementShared}>
Signal count: ${sharedCounter}
</button>
<button id="local-increment" @click=${this.incrementLocal}>
Local count: ${this.local}
</button>
<span class="badge" id="badge">renders: 0</span>
`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
const badge = this.renderRoot.querySelector('#badge');
if (badge !== null) {
badge.textContent = `renders: ${this.renders}`;
}
}
private incrementShared() {
sharedCounter.set(sharedCounter.get() + 1);
}
private incrementLocal() {
this.local.set(this.local.get() + 1);
}
}
/**
* Second watcher of the same shared signal — must keep mirroring updates
* after either component is hot-patched.
*/
@customElement('hmr-signal-mirror')
export class HmrSignalMirror extends SignalWatcher(LitElement) {
private renders = 0;
override render() {
return html`<p id="mirror">Mirror: ${sharedCounter}</p>`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
}
}

The caveat is module scope, not signals. A module-level signal() declared inside an edited component module is re-created with its initial value.

@lit/context works, including the experimental-decorator @provide and @consume forms. During a patch the runtime re-enrolls live instances with the new class. It then restores the provided value through the new accessors, so subscribed consumers keep their value and a live subscription.

src/context-def.ts
import {createContext} from '@lit/context';
/**
* Context key in its own non-component module. Two HMR safety notes:
*
* - A string key is identity-by-value (`createContext('x') ===
* createContext('x')`), so even a re-executed `createContext` call still
* matches existing providers. A `Symbol()` key would not.
* - Living here, the module never self-accepts, so component edits don't
* re-execute it at all (same pattern as the shared signals).
*/
export const counterContext = createContext<number>('hmr-counter-context');
src/hmr-context.ts
import {LitElement, css, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {consume, provide} from '@lit/context';
import {counterContext} from './context-def.js';
/**
* Context API surface: a provider whose `@provide` value is also `@state`
* (bumping it pushes to subscribed consumers), and a consumer nested in
* its shadow DOM. The consumer sits in its own template literal so
* provider-header edits never rebuild the consumer element; the
* ContextProvider/ContextConsumer controllers live on the (untouched)
* instances and must keep working after either class is hot-patched.
*/
@customElement('hmr-ctx-provider')
export class HmrCtxProvider extends LitElement {
static override styles = css`
.badge {
font-size: 0.8em;
color: var(--muted, #666);
}
`;
@provide({context: counterContext})
@state()
private counter = 0;
private renders = 0;
private renderHeader() {
return html`<h2>Context: HELLO</h2>`;
}
override render() {
return html`
${this.renderHeader()}
<button id="provide-increment" @click=${this.increment}>
Provided: ${this.counter}
</button>
<hmr-ctx-consumer></hmr-ctx-consumer>
<span class="badge" id="badge">renders: 0</span>
`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
const badge = this.renderRoot.querySelector('#badge');
if (badge !== null) {
badge.textContent = `renders: ${this.renders}`;
}
}
private increment() {
this.counter++;
}
}
@customElement('hmr-ctx-consumer')
export class HmrCtxConsumer extends LitElement {
static override styles = css`
#consumed {
font-weight: 600;
}
`;
@consume({context: counterContext, subscribe: true})
@state()
private counter?: number;
private renders = 0;
override render() {
return html`<p id="consumed">Consumed: ${this.counter}</p>`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
}
}

The caveat is the key. Prefer a string key: createContext('my-context') is identity-by-value, so a re-executed call still matches existing providers. A Symbol() key is not, and would orphan them.

@lit/task needs no special handling. The Task controller and its completed value are instance state, so a patch preserves both. The patch re-evaluates args(), which are shallow-equal, so the task stays COMPLETE and does not re-fetch. Args-driven re-runs keep working afterwards.

src/fake-api.ts
export interface User {
id: number;
name: string;
bio: string;
}
const USERS: User[] = [
{
id: 1,
name: 'Ada Lovelace',
bio: 'wrote the first program before computers existed',
},
{
id: 2,
name: 'Grace Hopper',
bio: 'found the first actual bug (it was a moth)',
},
{
id: 3,
name: 'Katherine Johnson',
bio: 'computed orbital trajectories by hand',
},
{
id: 4,
name: 'Margaret Hamilton',
bio: 'named software engineering, landed Apollo 11',
},
];
export const USER_COUNT = USERS.length;
declare global {
interface Window {
__fakeFetches: number;
}
}
window.__fakeFetches = 0;
export const fakeFetchUser = async (
id: number,
signal?: AbortSignal
): Promise<User> => {
window.__fakeFetches++;
await new Promise((resolve) => setTimeout(resolve, 300));
if (signal?.aborted) {
throw new Error('aborted');
}
const user = USERS.find((u) => u.id === id);
if (user === undefined) {
throw new Error(`no such user: ${id}`);
}
return user;
};
src/hmr-task.ts
import {LitElement, css, html} from 'lit';
import {customElement, state} from 'lit/decorators.js';
import {Task} from '@lit/task';
import {USER_COUNT, fakeFetchUser} from './fake-api.js';
/**
* `@lit/task` surface: an async Task (fake fetch with latency) driven by an
* `@state` arg. The Task controller and its completed value live on the
* instance, so a hot patch must neither lose the loaded content nor trigger
* a re-fetch (the patch's requestUpdate re-runs `args()`, which are
* shallow-equal — Task stays COMPLETE). Args-driven re-runs must still work
* afterwards.
*
* Note: the task function itself is captured by the controller at
* construction — editing its *body* affects only future instances, like any
* instance-captured state. Edit templates to see HMR; reload to swap fetch
* logic.
*/
@customElement('hmr-task')
export class HmrTask extends LitElement {
static override styles = css`
#pending {
color: var(--muted, #666);
font-style: italic;
}
#error {
color: #e63946;
}
.badge {
font-size: 0.8em;
color: var(--muted, #666);
}
`;
@state()
private userId = 1;
private userTask = new Task(this, {
task: ([id], {signal}) => fakeFetchUser(id, signal),
args: () => [this.userId] as const,
});
private renders = 0;
private renderHeader() {
return html`<h2>Task: HELLO</h2>`;
}
override render() {
return html`
${this.renderHeader()}
<button id="next-user" @click=${this.nextUser}>next user</button>
<div id="result">
${this.userTask.render({
pending: () =>
html`<p id="pending">fetching user ${this.userId}…</p>`,
complete: (user) =>
html`<p id="user"><strong>${user.name}</strong> — ${user.bio}</p>`,
error: (e) => html`<p id="error">${String(e)}</p>`,
})}
</div>
<span class="badge" id="badge">renders: 0</span>
`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
const badge = this.renderRoot.querySelector('#badge');
if (badge !== null) {
badge.textContent = `renders: ${this.renders}`;
}
}
private nextUser() {
this.userId = (this.userId % USER_COUNT) + 1;
}
}

The caveat is the task function. The controller captures it at construction, so editing its body only affects future instances. Reload the page to swap fetch logic on live ones.

<lit-virtualizer> holds its layout and scroll state on the element. Keep it in its own template literal and a header edit never rebuilds it, so the scroll offset and the visible window both survive.

src/hmr-virtualizer.ts
import {LitElement, css, html} from 'lit';
import {customElement} from 'lit/decorators.js';
import '@lit-labs/virtualizer';
/**
* Virtualized-list surface. `<lit-virtualizer scroller>` keeps its layout
* and scroll state on the element instance, and it sits in its own template
* literal — so a header edit never rebuilds it and the scroll offset
* survives the patch. The ITEMS array and the renderItem closure are
* re-created on re-execution (new identities, equal content): the
* virtualizer reflows over equal content, which must not move the scroller.
*/
const ITEMS: ReadonlyArray<{index: number; label: string}> = Array.from(
{length: 1000},
(_, index) => ({index, label: `Row ${index}`})
);
@customElement('hmr-virtualizer')
export class HmrVirtualizer extends LitElement {
static override styles = css`
lit-virtualizer {
height: 180px;
border: 1px solid var(--card-border, #ccc);
border-radius: 4px;
}
.row {
display: block;
width: 100%;
padding: 4px 8px;
box-sizing: border-box;
}
.row:nth-child(even) {
background: rgba(127, 127, 127, 0.08);
}
.badge {
font-size: 0.8em;
color: var(--muted, #666);
}
`;
private renders = 0;
private renderHeader() {
return html`<h2>Virtualizer: HELLO</h2>`;
}
private renderList() {
return html`
<lit-virtualizer
id="list"
scroller
.items=${ITEMS}
.renderItem=${(item: {index: number; label: string}) =>
html`<span class="row" data-index=${item.index}>${item.label}</span>`}
></lit-virtualizer>
`;
}
override render() {
return html`
${this.renderHeader()} ${this.renderList()}
<span class="badge" id="badge">renders: 0</span>
`;
}
override updated() {
this.renders++;
this.setAttribute('data-renders', String(this.renders));
const badge = this.renderRoot.querySelector('#badge');
if (badge !== null) {
badge.textContent = `renders: ${this.renders}`;
}
}
}

Row-template edits survive too. The renderItem arrow is an interpolation value, so its body is not part of the outer literal’s strings. Re-created items arrays with equal content reflow without moving the scroller.