ES Modules & Imports
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
1// math.js2export const PI = 3.14; // named export3export function add(a, b) { return a + b; } // named export4export default function multiply(a, b) { return a * b; } // default export1// app.js2import multiply, { PI, add } from "./math.js";3import { add as sum } from "./math.js"; // rename4import * as math from "./math.js"; // everything as one object| Named | Default | |
|---|---|---|
| Per file | Many | Only one |
| Import name | Must match (or use as) | Any name you choose |
| Import syntax | With curly braces | Without braces |
| Easy to rename by mistake | No | Yes |
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.
1// counter.js2export let count = 0;3export function inc() { count++; }4// elsewhere: after inc(), the imported count is 12. 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.
1const button = document.querySelector("button");2button.onclick = async () => {3 const { heavyChart } = await import("./chart.js"); // downloaded only on click4 heavyChart();5};This is how code splitting works, and it is what React.lazy uses:
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.
1import { add } from "./math.js"; // PI and multiply can be dropped from the bundleHere Is What Breaks: Circular Imports
1a.js imports b.js2b.js imports a.jsWhen 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:
1import { multiply } from "./math.js"; // ✗ multiply is a default export, this is undefined2import multiply from "./math.js"; // ✓ES Modules vs CommonJS
| ES Modules | CommonJS | |
|---|---|---|
| Syntax | import / export | require() / module.exports |
| Loading | Static, can be analyzed | Dynamic, runs at call time |
| Async | Supports top-level await | No |
| Tree shaking | Yes | Hard |
| Where | Browsers, modern Node | Older 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.jsthat 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 import2Named export → many per file, import by name in braces3Default export → one per file, import with any name4Live bindings → importers see updates, cannot reassign5Modules run once and are cached6import() → dynamic, returns a promise, powers code splitting7Static imports → enable tree shaking8Circular imports → avoid by extracting shared codeThe next two topics are coding questions that tie together closures, timers and this: debounce, throttle and polyfills.