# Use signals, context, tasks and the virtualizer

> How the four Lit ecosystem libraries behave across a hot patch, with the working shape and caveat for each.

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.

## The rule

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.

## Signals

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.

```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);
```

```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.

## Context

`@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.

```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');
```

```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.

## Tasks

`@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.

```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;
};
```

```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.

## Virtualizer

`<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.

```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.

## Related

[How HMR works](/concepts/how-hmr-works/)Why instance state survives and module scope does not.

[Keep state across edits](/guides/hmr/)The defaults, the three settings, and the on-page indicator.
