Lazy loading
A big component you don't need right away — a chart, a rich editor, a rarely-opened
dialog — doesn't have to be in your first download. AsyncLoad loads it only when it
is first rendered, so the page starts light.
<AsyncLoad
lazy={() => import("./HeavyPanel")}
namedExport="HeavyPanel"
loadedProps={{ note: "hello" }}
onLoading={<p>loading…</p>}
errorFallback={({ error, retry }) => (
<p>Could not load it. <button onclick={retry}>retry</button></p>
)}
/>
open the network tab — the chunk is preloaded from <head> and fetched once
Loaded at 8:18:04 PM.fetched on demand
It is an ordinary component written as a tag — lazy is just a function that returns
a promise (a dynamic import()), and onLoading is what to show until it arrives.
The pieces
lazy— a function returningimport("…").namedExport— which export to use; defaults to the module'sdefault.loadedProps— the props for the component being loaded. They go here, kept apart fromAsyncLoad's own attributes (lazy,onLoading) so the two can't be confused.onLoading— shown while the module downloads.errorFallback— a node, or a function given{ error, retry, attempt }. It plays the same role as anErrorBoundaryfallback — a failure UI with a way back — though the fields are named for a load:erroris whatever the import rejected with,retryreally does re-attempt the download, andattemptcounts the tries (1on the first failure).
Unmounting while it is still loading is safe — nothing gets written into a component that is gone.
Your bundler has to split the code (important)
AsyncLoad defers the module; producing a separate downloadable chunk is your
bundler's job. With Vite that is automatic. With esbuild, use
--splitting --outdir=… (not --outfile) — and for the server build too, or the
lazy import is left in the output and fails to load.
On a prerendered page the server can even render a lazy component straight into the
HTML, and a preload hint lets its chunk download in parallel with the main bundle.
Those details live with server rendering.
Two lazies that look the same
The loaded module is cached, and the cache key defaults to the SOURCE of the lazy
function — right for the usual () => import("./Thing"), because two different
imports read differently. A factory breaks that:
const make = (path: string) => () => import(path);
<AsyncLoad lazy={make("./Dashboard")} onLoading={<i />} errorFallback={<i />} />
<AsyncLoad lazy={make("./Settings")} onLoading={<i />} errorFallback={<i />} />
Both functions stringify to () => import(path) — the value each closed over is not
part of the source — so they share one cache entry. The first module loads and the
second never even asks for its own: it reads the entry the first one filled and
renders Dashboard where Settings was written. Nothing fails, and nothing is
logged.
Give them their own identity when the lazy is built rather than written:
<AsyncLoad cacheKey="./Dashboard" lazy={make("./Dashboard")} onLoading={<i />} errorFallback={<i />} />
<AsyncLoad cacheKey="./Settings" lazy={make("./Settings")} onLoading={<i />} errorFallback={<i />} />
The same applies to a route table that builds its lazies from a list — which is the common way to meet this.
Next
- Examples — every feature as a running component.