Skip to content

Keep state across edits

Keep focus, input text, scroll position and child state while you edit a component. This page covers the defaults and the three settings worth changing; how the patching works is in Concepts.

A hot patch is an edit applied to the running page without a reload. The plugin updates the canonical class in place: the class the browser registered first. Every live instance keeps running.

Across an edit to a component module, these survive:

  • DOM identity and state. Focus, text selection, scroll offsets and uncontrolled input values stay where they were. Only the part of the template you changed is rebuilt.
  • Reactive properties and @state. Snapshotted before the patch, then restored through the new accessors.
  • Child components. Untouched unless their own module changed. When a parent edit changes the template literal a child sits in, Lit re-creates the child element, and the plugin copies the old child’s reactive properties and #private fields onto the new one before it first renders. Properties the template binds keep their new values. See hmr.childState.
  • Adopted stylesheets. Constructed sheets stay the same objects, so nothing is re-parsed and nothing is re-adopted.
  • Controllers holding instance state. Task results and context subscriptions survive. The ecosystem guide has the per-library detail.

The playground fixture below is the shape to test against. Edit the header literal on the marked line: the list keeps its DOM identity and the input keeps focus, value and selection.

src/hmr-siblings.ts
import {LitElement, css, html} from 'lit';
import {customElement} from 'lit/decorators.js';
/**
* The key fixture: `render()` composes two separate `html` literals plus an
* `<input>` in the outer template. Editing the header literal must rebuild
* only the header part — the list keeps DOM identity and the input keeps
* focus, value, and selection.
*/
@customElement('hmr-siblings')
export class HmrSiblings extends LitElement {
static override styles = css`
input:focus {
outline: 2px solid dodgerblue;
}
.badge {
font-size: 0.8em;
color: var(--muted, #666);
}
`;
private renders = 0;
private renderHeader() {
return html`<header id="header"><h2>Siblings: HELLO</h2></header>`;
}
private renderList() {
return html`
<ul id="list">
<li>alpha</li>
<li>beta</li>
<li>gamma</li>
</ul>
`;
}
override render() {
return html`
${this.renderHeader()} ${this.renderList()}
<input id="text" placeholder="type here" />
<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}`;
}
}
}

Keep shared state out of the edited module

Section titled “Keep shared state out of the edited module”

One thing does not survive: module-level state inside the module you edited. A const at module scope is re-created when that module re-executes.

Put anything that must outlive an edit in a module of its own. Signals, context keys and caches all qualify. That module holds no component, so a component edit never re-executes it.

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

The component imports the object rather than creating it, so the value and its subscribers ride through every patch.

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

Choose what happens on an incompatible edit (onIncompatible)

Section titled “Choose what happens on an incompatible edit (onIncompatible)”

Some component shapes cannot be updated in place. That is an incompatible patch, and the plugin detects each case deterministically rather than guessing. Limitations lists every one with its reason.

onIncompatible decides what happens next. The default, 'reload', reloads the page: you lose the page’s state, but you never look at a half-patched component.

vite.config.ts
import {defineConfig} from 'vite';
import {litPlugin} from '@oddsquad/vite-plugin-lit';
export default defineConfig({
plugins: [litPlugin()],
plugins: [litPlugin({hmr: {onIncompatible: 'warn'}})],
});

'warn' logs to the console and leaves the page alone. Reach for it while you work on a component you already know cannot be patched, and reload by hand when you are ready.

Re-run connectedCallback after a patch (reconnect)

Section titled “Re-run connectedCallback after a patch (reconnect)”

reconnect cycles disconnectedCallback() and connectedCallback() on live instances after every patch. It is off by default.

Off is right for most work. Template interning means reusing the same template object for identical template text, so Lit sees an unchanged template as unchanged. That keeps a patch small. Cycling is observable by contrast: controllers tear down and set up again, event listeners re-register, and animations restart.

Turn it on when a component does real work in connectedCallback() that you want re-run on every edit.

vite.config.ts
import {defineConfig} from 'vite';
import {litPlugin} from '@oddsquad/vite-plugin-lit';
export default defineConfig({
plugins: [litPlugin()],
plugins: [litPlugin({hmr: {reconnect: true}})],
});

A small indicator sits in the corner of the page and pulses on each update. It separates a successful patch from a silent no-op without the console.

A green pulse means a component re-rendered.

The indicator pulsing green after a component edit; the count reads 1.The indicator pulsing green after a component edit; the count reads 1.

A calmer cyan pulse means something updated without re-rendering a component. In practice that is a shared stylesheet, one CSSStyleSheet object adopted by many shadow roots, swapped in place.

The indicator pulsing cyan after a stylesheet swap; the count has not moved.The indicator pulsing cyan after a stylesheet swap; the count has not moved.

Only green pulses advance the count, and a full reload resets it. Each row below is an edit you can make in the playground.

Playground edit Delivery Re-renders? Counts? Pulse
hmr-vsheet.css ?css-sheet shared sheet no no info (cyan)
hmr-shared.css ?raw → shared sheet no no info (cyan)
hmr-utility-sheet.css ?url → shared sheet no no info (cyan)
hmr-linked-css.css ?hmr-url <link> yes yes success (green)
hmr-import-css.css ?hmr-url @import yes yes success (green)
hmr-css-url.css ?url + devCacheBust() yes yes success (green)
hmr-raw-css.css ?raw static styles yes yes success (green)
any *.ts component component module yes yes success (green)
main.ts not self-accepting full reload resets —

The last row is a module that never tells Vite it can apply its own updates. The edit walks up to a full reload instead of stopping there.

Set hmr.indicator to false to remove the indicator; it is forced off when HMR itself is off. hmr.indicator.count adds the running total next to the dot and raises its idle opacity so the number stays readable.

vite.config.ts
import {defineConfig} from 'vite';
import {litPlugin} from '@oddsquad/vite-plugin-lit';
export default defineConfig({
plugins: [litPlugin()],
plugins: [litPlugin({hmr: {indicator: {count: true}}})],
});