Skip to content

Build your first hot-reloading component

Build a small Lit app and watch its state survive every edit you make to it. Start here if the plugin is new to you; Installation is the short version for a project you already have. It takes about fifteen minutes and needs Node 20 or newer, an editor, and no knowledge of Vite plugins.

  1. Scaffold the app. The Lit and TypeScript template asks no questions.

    Terminal window
    npm create vite@latest my-lit-app -- --template lit-ts
    cd my-lit-app
    npm install
    npm run dev

    Open the URL Vite prints.

    Check: a page with the Lit and Vite logos and a button reading Count is 0. Click it and the number goes up.

  2. Feel the problem. Click the button three times, so it reads Count is 3. Then rename the label in the starter component.

    src/my-element.ts
    part="button"
    >
    Count is ${this.count}
    Clicked ${this.count} times
    </button>

    Check: the page reloads and the button reads Clicked 0 times. Your three clicks are gone.

    Nothing in that module tells Vite it can apply its own update, so the edit walks all the way up to a full page reload.

  3. Add the plugin.

    Terminal window
    npm install -D @oddsquad/vite-plugin-lit

    The template ships without a Vite config, so write one.

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

    indicator.count puts a running total next to the on-page indicator. Every step below reads it.

    Register the plugin’s ambient types in the same sitting. TypeScript’s types array replaces its default, so keep vite/client in the list.

    tsconfig.json
    "types": ["vite/client"],
    "types": ["vite/client", "@oddsquad/vite-plugin-lit/client"],

    Do it now rather than later: editing tsconfig.json forces a full reload, and from the next step on you have state on the page worth keeping.

    Stop the dev server and start it again. Vite does not pick up a config file that did not exist when it started.

    Terminal window
    npm run dev

    Check: a small indicator in the corner of the page, showing a 0.

    The indicator at rest in the corner of the page, showing 0.The indicator at rest in the corner of the page, showing 0.
  4. Make the same kind of edit, and keep your clicks. Click the button three times, then rename the label once more.

    src/my-element.ts
    Clicked ${this.count} times
    You clicked ${this.count} times

    Check: the label changes to You clicked 3 times. The page does not reload, the count stays at 3, and the indicator pulses green and goes from 0 to 1.

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

    That is a hot patch: an edit applied to the running page without a reload.

  5. Give it state worth losing. One number is a thin test. Replace src/my-element.ts with a component that holds a list, a counter and an input.

    src/my-element.ts
    import {LitElement, css, html} from 'lit';
    import {customElement, state} from 'lit/decorators.js';
    @customElement('my-element')
    export class MyElement extends LitElement {
    static styles = css`
    :host {
    display: block;
    max-width: 32rem;
    margin: 3rem auto;
    font-family: system-ui, sans-serif;
    }
    input:focus {
    outline: 2px solid currentColor;
    }
    `;
    @state() private count = 0;
    @state() private notes: string[] = [];
    private renderHeader() {
    return html`<h1>Notebook</h1>`;
    }
    private renderList() {
    return html`<ul>
    ${this.notes.map((note) => html`<li>${note}</li>`)}
    </ul>`;
    }
    render() {
    return html`
    ${this.renderHeader()}
    <input placeholder="Type a note, press Enter" @keydown=${this.onKey} />
    ${this.renderList()}
    <button @click=${this.onClick}>Clicked ${this.count} times</button>
    `;
    }
    private onClick() {
    this.count++;
    }
    private onKey(event: KeyboardEvent) {
    const input = event.target as HTMLInputElement;
    if (event.key === 'Enter' && input.value) {
    this.notes = [...this.notes, input.value];
    input.value = '';
    }
    }
    }
    declare global {
    interface HTMLElementTagNameMap {
    'my-element': MyElement;
    }
    }

    Take the template’s placeholder content out of the page. Leave the <script> in the head where it is.

    index.html
    <body>
    <my-element>
    <h1>Get started</h1>
    </my-element>
    <my-element></my-element>
    </body>

    Reload the page once. notes is a new reactive property, and reactive properties get their initial value when an instance is constructed, so the instance already on the page does not have one; a reload starts clean.

    Add two notes, click the button twice, then type half a third note and leave the caret in the input. Now edit the heading, and nothing else.

    src/my-element.ts
    private renderHeader() {
    return html`<h1>Notebook</h1>`;
    return html`<h1>My notes</h1>`;
    }

    Check: the heading reads My notes. Both notes are still listed, the button still reads Clicked 2 times, your half-typed third note is still in the input, and the caret is still where you left it.

    renderHeader() and renderList() are separate html literals, and only the header’s text changed. That is template interning: reusing the same template object for identical template text, so Lit sees an unchanged template as unchanged and rebuilds only the header.

  6. Hot-swap a stylesheet. Write a stylesheet the component can adopt.

    src/theme.css
    .card {
    list-style: none;
    margin-bottom: 0.5rem;
    padding: 0.5rem 0.75rem;
    border: 2px solid dodgerblue;
    border-radius: 6px;
    }

    Import it with ?css-sheet and put it at the front of static styles. The query hands you a CSSStyleSheet object, and static styles takes an array, so the component’s own rules stack on top of it. Give each note the card class while you are there.

    src/my-element.ts
    import {LitElement, css, html} from 'lit';
    import {customElement, state} from 'lit/decorators.js';
    import theme from './theme.css?css-sheet';
    @customElement('my-element')
    export class MyElement extends LitElement {
    static styles = [
    theme,
    css`
    :host {
    display: block;
    max-width: 32rem;
    margin: 3rem auto;
    font-family: system-ui, sans-serif;
    }
    input:focus {
    outline: 2px solid currentColor;
    }
    `,
    ];
    @state() private count = 0;
    @state() private notes: string[] = [];
    private renderHeader() {
    return html`<h1>My notes</h1>`;
    }
    private renderList() {
    return html`<ul>
    ${this.notes.map((note) => html`<li class="card">${note}</li>`)}
    </ul>`;
    }
    render() {
    return html`
    ${this.renderHeader()}
    <input placeholder="Type a note, press Enter" @keydown=${this.onKey} />
    ${this.renderList()}
    <button @click=${this.onClick}>Clicked ${this.count} times</button>
    `;
    }
    private onClick() {
    this.count++;
    }
    private onKey(event: KeyboardEvent) {
    const input = event.target as HTMLInputElement;
    if (event.key === 'Enter' && input.value) {
    this.notes = [...this.notes, input.value];
    input.value = '';
    }
    }
    }
    declare global {
    interface HTMLElementTagNameMap {
    'my-element': MyElement;
    }
    }

    That is a component edit, so saving it gives you a green pulse and the count goes up. Now change one word in the stylesheet.

    src/theme.css
    border: 2px solid dodgerblue;
    border: 2px solid tomato;

    Check: the note borders turn red. The indicator pulses cyan, and the count does not move.

    The indicator pulsing cyan after the theme.css edit; the count has not moved.The indicator pulsing cyan after the theme.css edit; the count has not moved.

    Cyan means a shared stylesheet was swapped in place. One CSSStyleSheet object is adopted by every shadow root that imported it, and replacing its rules restyles them all without re-rendering a component.

  7. See what a reload looks like. Change the page title.

    index.html
    <title>my-lit-app</title>
    <title>My notes</title>

    Check: the page reloads. The notes are gone, the input is empty, and the indicator is back to 0.

    index.html is not a module, so there is nothing to patch. A few component shapes cannot be patched either, standard accessor decorators among them. Limitations names each one, what you see when it fires, and what to write instead. If you would rather keep the page up and reload by hand, hmr.onIncompatible has a 'warn' mode; the HMR guide covers it.

You have a component whose list, counter, input text and caret ride through every edit, and a stylesheet that restyles the whole app without re-rendering anything. These three pages take it further.