Vite
Editing a component normally costs a full page reload. The Vite plugin gives component modules an accept boundary, so an edit replaces just that component’s subtree and leaves the rest of the page — scroll position, open panels, sibling state — exactly where it was.
import { defineConfig } from "vite";import elementsKit from "elements-kit/integrations/vite";
export default defineConfig({ plugins: [elementsKit()],});Using Astro? The Astro integration registers this for you — don’t add it twice.
Setup
The plugin needs the JSX transform pointed at elements-kit, which is the same tsconfig.json you already need for JSX:
{ "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "elements-kit" }}That is the whole setup. The plugin is dev-only (apply: "serve") — production builds and SSR transforms emit nothing, so no HMR code ships.
What gets swapped
In dev the compiler imports elements-kit/jsx-dev-runtime, which renders every component through a signal holding its current implementation. An edit re-points that signal, one effect re-runs, and the old subtree is disposed and rebuilt.
The unit is the component, not the page or the mount root. Edit a child and its parent’s signals keep their values:
// Panel.tsx — editing this swaps Panel onlyexport function Panel({ rows }: Props<{ rows: string[] }>) { const open = signal(false); return <ul>{() => rows().map((r) => <Row label={r} />)}</ul>;}A swapped component re-runs, so its own state resets. Anything that must survive an edit goes in a module the edit doesn’t invalidate:
// transcript.ts — not re-evaluated when Panel.tsx changesimport { signal } from "elements-kit/signals";export const turns = signal<Turn[]>([]);Components the module doesn’t export — Row above — have no name to match against, so they rebuild when their parent does rather than on their own.
Mount through JSX
A component only gets a boundary if the JSX runtime rendered it. Calling it yourself bypasses the runtime entirely:
// before — no boundary, edits reload the pagedocument.getElementById("app")!.appendChild(new App().render());
// after — App swaps in placerender(document.getElementById("app")!, () => <App />);What still reloads
Falling back to a reload is always correct, never silent staleness — a module that can’t be swapped calls import.meta.hot.invalidate() and Vite propagates normally.
- Modules that render no JSX. The plugin keys off the runtime import the JSX transform adds, so a plain
.tsmodule has nothing to mark it. vite.config.tsand anything else outside the module graph.
See also
- Astro — islands, and the HMR behaviour specific to them
- Components — function and class components
- JSX & Elements — what the runtime builds