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.
What survives an edit
Section titled “What survives an edit”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
#privatefields onto the new one before it first renders. Properties the template binds keep their new values. Seehmr.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.
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.
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.
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.
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.
import {defineConfig} from 'vite';import {litPlugin} from '@oddsquad/vite-plugin-lit';
export default defineConfig({ plugins: [litPlugin()], plugins: [litPlugin({hmr: {reconnect: true}})],});Read the indicator
Section titled “Read the indicator”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.


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.


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.
import {defineConfig} from 'vite';import {litPlugin} from '@oddsquad/vite-plugin-lit';
export default defineConfig({ plugins: [litPlugin()], plugins: [litPlugin({hmr: {indicator: {count: true}}})],});