Dynamic imports

The import and export statements from the earlier chapters are the static form. Their syntax is deliberately rigid, and that rigidity buys you something valuable: tools can read your module graph without running your code.

Two rules fall out of that design.

Rule one: you can’t compute the module path at runtime. The path has to be a plain string literal sitting right there in the source, not the result of a function call.

import ... from getModuleName(); // Error, only from "string" is allowed

Rule two: an import statement can’t live inside a condition or any other block. It sits at the top level of the module, always.

if(...) {
  import ...; // Error, not allowed!
}

{
  import ...; // Error, we can't put import in any block
}

Why the strictness? Static import/export form the skeleton of your program. Because that skeleton is fixed and visible before anything executes, build tools can walk it: bundlers can gather every module into one file, and a bundler can drop exports that nobody imports. That last trick has a name — tree-shaking — and it only works because the import structure can’t shift at runtime.

Static (analyzable)
// visible before running
import {play} from ‘./sound.js’;
✓ bundler can trace it
✓ unused exports removable
Computed path
// only known at runtime
importfrom getName();
✗ not allowed as a statement
→ use import() instead
Static imports are readable before the code runs; a runtime-computed path would be opaque to tools.

So how do you load a module on demand — say, only when the user clicks a button, or only after you’ve figured out which language file you need? That’s exactly the gap import() fills.

The import() expression

import(module) takes a module path and returns a promise. When the module finishes loading, the promise resolves to a module object — an object whose properties are all the module’s exports. You can call it from anywhere: inside a function, inside a branch, inside an event handler.

let modulePath = prompt("Which module to load?");

import(modulePath)
  .then(obj => <module object>)
  .catch(err => <loading error, e.g. if no such module>)

Since it’s a promise, await works too. Inside an async function you can write let module = await import(modulePath) and read the result on the next line as if it were synchronous.

import(‘./sound.js’)
Promise
pending → fulfilled
module object
{
play: ƒ,
stop: ƒ
}
import() returns a promise; it resolves to a module object holding every export.

Take a module sound.js with two named exports:

// 📁 sound.js
export function play() {
  alert(`Playing`);
}

export function stop() {
  alert(`Stopped`);
}

You can pull those exports straight out of the resolved module object with destructuring:

let {play, stop} = await import('./sound.js');

play();
stop();

The names on the left of the destructuring match the export names. Nothing exotic — the module object behaves like any other object here.

Reaching the default export

A default export shows up under the key default. That’s true even though the export default syntax never mentions a name.

// 📁 sound.js
export default function() {
  alert("Module loaded (export default)!");
}

Because default is a reserved word, you can’t write obj.default and then treat default as a variable — but reading it as a property is fine, and destructuring lets you rename it on the way out:

let obj = await import('./sound.js');
let ready = obj.default;
// or, in one line: let {default: ready} = await import('./sound.js');

ready();

Here’s the whole thing running. The real sound.js would be a separate file; this demo stands in for it with a tiny loader so you can watch the promise resolve into a module object and then call what comes back. Click the button to trigger the load on demand.

interactiveimport() resolving to a module object

When to reach for it

Static imports are the default — they’re clearer and they let tools optimize your bundle. Dynamic import() earns its place when when you load matters:

  • Code-splitting. Keep a heavy feature (a chart library, a rich text editor) out of the initial download and fetch it the moment the user actually needs it. Bundlers turn each import() into a separate chunk automatically.
  • Conditional loading. Load one of several modules based on a runtime value — the user’s locale, a feature flag, the device.
  • Optional or best-effort code. Wrap the call in try...catch (or .catch) so a failed load degrades gracefully instead of breaking the page.

Conditional loading is where the runtime-computed path really shines. Pick a locale below and only the matching greeting “module” gets loaded — the path is built from your choice, something a static import could never do.

interactiveConditional loading by locale

The two aren’t rivals. Reach for static imports to describe your app’s fixed structure, and drop in import() at the points where loading has to wait for a decision the running program makes.