On this page

ES Modules & Imports

4 min read

Every React file starts with import lines. Knowing how modules really work helps you avoid circular imports, understand tree shaking, and answer the "default vs named export" question.

Definition

An ES module is a file with its own private scope. It shares things with other files using export, and receives them using import. Nothing leaks to the global scope unless you export it.

Analogy: each file is a room with its own door. Only items you place at the door with export can be picked up by someone else with import.

Named and Default Exports

js
1// math.js
2export const PI = 3.14; // named export
3export function add(a, b) { return a + b; } // named export
4export default function multiply(a, b) { return a * b; } // default export
js
1// app.js
2import multiply, { PI, add } from "./math.js";
3import { add as sum } from "./math.js"; // rename
4import * as math from "./math.js"; // everything as one object
NamedDefault
Per fileManyOnly one
Import nameMust match (or use as)Any name you choose
Import syntaxWith curly bracesWithout braces
Easy to rename by mistakeNoYes

Many teams prefer named exports because the name stays consistent across the project, and editors can auto-import them. Frameworks like Next.js require default exports for pages, so you will use both.

Key Behaviors

1. Imports are live bindings, not copies. If the exporting module changes its variable, importers see the new value. But importers cannot reassign it.

js
1// counter.js
2export let count = 0;
3export function inc() { count++; }
4// elsewhere: after inc(), the imported count is 1

2. Modules run once. The first import runs the file and caches the result. Every other import gets the same instance. That is why a module can hold shared state, like a single database client.

3. Imports are hoisted and static. They are resolved before the code runs, and must be at the top level, not inside an if. This allows bundlers to analyze them.

4. Modules are in strict mode automatically, and top-level this is undefined.

Dynamic Import

When you need to load a module later, use import(). It returns a promise.

js
1const button = document.querySelector("button");
2button.onclick = async () => {
3 const { heavyChart } = await import("./chart.js"); // downloaded only on click
4 heavyChart();
5};

This is how code splitting works, and it is what React.lazy uses:

jsx
1const Settings = React.lazy(() => import("./Settings"));

Tree Shaking

Because imports are static, a bundler can see which exports are never used and remove them from the final bundle. This works best with named exports and packages that mark themselves side-effect free.

js
1import { add } from "./math.js"; // PI and multiply can be dropped from the bundle

Here Is What Breaks: Circular Imports

1a.js imports b.js
2b.js imports a.js

When two modules import each other, one of them runs while the other is only partly loaded, so you can read undefined or hit a TDZ error. Fix it by moving the shared code into a third file, or by restructuring who depends on whom.

Another common mistake is a wrong import style:

js
1import { multiply } from "./math.js"; // ✗ multiply is a default export, this is undefined
2import multiply from "./math.js"; // ✓

ES Modules vs CommonJS

ES ModulesCommonJS
Syntaximport / exportrequire() / module.exports
LoadingStatic, can be analyzedDynamic, runs at call time
AsyncSupports top-level awaitNo
Tree shakingYesHard
WhereBrowsers, modern NodeOlder Node

In Node, a file is ESM when it uses .mjs or when package.json has "type": "module".

In React

  • One component per file, exported as default or named. Be consistent across the project.
  • A barrel file (index.js that re-exports many files) is convenient, but can hurt tree shaking and cause circular imports if overused.
  • Side-effect imports like import "./styles.css" run the file without taking anything from it.

Summary

1Module → a file with its own scope, shares through export, receives through import
2Named export → many per file, import by name in braces
3Default export → one per file, import with any name
4Live bindings → importers see updates, cannot reassign
5Modules run once and are cached
6import() → dynamic, returns a promise, powers code splitting
7Static imports → enable tree shaking
8Circular imports → avoid by extracting shared code

The next two topics are coding questions that tie together closures, timers and this: debounce, throttle and polyfills.

Next TopicImplement Debounce & Throttle