Overview
JavaScript modules let developers split an application into files with explicit dependencies. A module can export values such as functions, components, constants, classes, objects, and types, and another module can import those values where they are needed.
In React applications, modules are everywhere:
- Components are imported into pages and layouts.
- Hooks are exported from shared files.
- Utility functions are reused across features.
- Route-level code can be lazy loaded with dynamic
import(). - TypeScript types can be imported and exported separately from runtime values.
- Bundlers use module structure to build dependency graphs, split code, and remove unused exports.
This topic matters in interviews because import/export behavior is often where JavaScript, React, TypeScript, and bundlers meet. A candidate who understands modules can explain why an import fails, why a circular dependency creates undefined, why a default export works differently from a named export, why a file with top-level side effects is risky, and why React.lazy expects a default export.
The practical goal is to organize React code into clear module boundaries, export stable public APIs, avoid confusing dependency cycles, and understand how the build tool interprets the dependency graph.
Core Concepts
What a JavaScript Module Is
A JavaScript module is a file that can explicitly export values and import values from other modules. In modern React projects, source files are usually ES modules:
// math.js
export function add(a, b) {
return a + b;
}
export const PI = 3.14159;
// app.js
import { add, PI } from "./math.js";
console.log(add(2, 3));
console.log(PI);
Modules help by making dependencies explicit. Instead of relying on globals or script load order, each file states what it needs.
In React, a component module commonly looks like this:
// UserCard.jsx
export function UserCard({ user }) {
return (
<article>
<h2>{user.name}</h2>
<p>{user.email}</p>
</article>
);
}
// UserList.jsx
import { UserCard } from "./UserCard.jsx";
export function UserList({ users }) {
return users.map((user) => <UserCard key={user.id} user={user} />);
}
Named Exports
A named export exports a value by its declared name or by an explicit export list.
export const API_BASE_URL = "/api";
export function formatUserName(user) {
return `${user.firstName} ${user.lastName}`;
}
import { API_BASE_URL, formatUserName } from "./users.js";
Named exports are useful when a module exposes multiple related values:
// userSelectors.js
export const selectUserById = (state, id) => state.users.byId[id];
export const selectAllUsers = (state) => state.users.allIds.map((id) => state.users.byId[id]);
export const selectActiveUsers = (state) => selectAllUsers(state).filter((user) => user.active);
Benefits of named exports:
- The import name must match the export name, which improves clarity.
- Refactoring tools can find usages easily.
- A module can expose several related values.
- Bundlers can more easily identify unused exports in many cases.
Named imports can be renamed locally:
import { formatUserName as formatName } from "./users.js";
console.log(formatName(user));
Default Exports
A default export is the single primary export from a module.
// Button.jsx
export default function Button({ children }) {
return <button>{children}</button>;
}
// Toolbar.jsx
import Button from "./Button.jsx";
The importing file chooses the local name:
import PrimaryButton from "./Button.jsx";
import AnyNameHere from "./Button.jsx";
This flexibility can be convenient, but it can also reduce consistency if different files import the same thing under different names. In team codebases, named exports often make large-scale refactoring and searching easier, while default exports are commonly used for route components, page components, or modules with one obvious main value.
Named vs Default Exports
Named and default exports solve different communication problems.
Named export:
export function parsePrice(value) {
return Number(value);
}
export function formatPrice(value) {
return `$${value.toFixed(2)}`;
}
import { parsePrice, formatPrice } from "./price.js";
Default export:
export default function ProductPage() {
return <main>Products</main>;
}
import ProductPage from "./ProductPage.jsx";
Common interview comparison:
Avoid treating default exports as automatically better. In React projects, consistency matters more than preference.
Import Paths and Module Specifiers
The string after from is the module specifier.
import { calculateTotal } from "../orders/calculateTotal.js";
import { useAuth } from "@/features/auth/useAuth";
import React from "react";
Common specifier types:
- Relative path:
./Button,../utils/date - Absolute or alias path configured by the project:
@/components/Button - Package name:
react,zod,date-fns - URL in some browser-native module scenarios
In browser-native ES modules, relative file imports usually need file extensions. In many React build setups, bundlers and TypeScript resolve extensionless imports such as ./Button. The behavior depends on the bundler, TypeScript configuration, and runtime.
Common mistake:
// Often fails in browser-native modules without an import map or bundler.
import { Button } from "components/Button";
Safer relative form:
import { Button } from "./components/Button.js";
Common React project form with a configured alias:
import { Button } from "@/components/Button";
An interview answer should connect module specifiers to the environment: browser, Node.js, TypeScript, Vite, Webpack, Next.js, or another framework.
Static Imports
Static imports are top-level declarations:
import { fetchUsers } from "./api/users.js";
export async function loadUsers() {
return fetchUsers();
}
Static imports have important properties:
- They must appear at the module top level.
- They are resolved before the importing module runs.
- They let tools build a dependency graph before runtime.
- They support tree-shaking and static analysis.
- They cannot be placed inside
if,for, or function bodies.
Invalid:
if (shouldLoadAdmin) {
import { AdminPanel } from "./AdminPanel.jsx";
}
Use dynamic import() for conditional loading:
if (shouldLoadAdmin) {
const module = await import("./AdminPanel.jsx");
const AdminPanel = module.default;
}
Dynamic Import
Dynamic import() is an expression that returns a Promise for the module namespace object.
async function loadFormatter(locale) {
const module = await import(`./formatters/${locale}.js`);
return module.formatDate;
}
In React, dynamic import is commonly used with React.lazy:
import { Suspense, lazy } from "react";
const SettingsPage = lazy(() => import("./SettingsPage.jsx"));
export function AppRoutes() {
return (
<Suspense fallback={<p>Loading...</p>}>
<SettingsPage />
</Suspense>
);
}
Dynamic import is useful for:
- Route-level code splitting.
- Loading admin-only screens only for authorized users.
- Loading large editors, charts, maps, or markdown previewers only when needed.
- Deferring rarely used logic.
Trade-offs:
- Adds async loading states.
- Can create request waterfalls if used carelessly.
- Requires error handling for failed chunk loads.
- May need framework-specific route splitting patterns.
React.lazy and Default Exports
React.lazy expects the dynamic import Promise to resolve to an object with a default export that is a valid React component.
const ProfilePage = lazy(() => import("./ProfilePage.jsx"));
This works naturally when ProfilePage.jsx has a default export:
export default function ProfilePage() {
return <main>Profile</main>;
}
If the file uses a named export:
export function ProfilePage() {
return <main>Profile</main>;
}
you can adapt it:
const ProfilePage = lazy(() =>
import("./ProfilePage.jsx").then((module) => ({
default: module.ProfilePage,
}))
);
In interviews, this often reveals whether a candidate understands both module namespace objects and React lazy loading.
Module Namespace Imports
A namespace import collects all named exports into an object-like namespace:
import * as dateUtils from "./dateUtils.js";
console.log(dateUtils.formatDate(new Date()));
console.log(dateUtils.parseDate("2026-06-19"));
Namespace imports can be useful for grouping utilities, but overuse can hide which functions are actually needed.
Prefer this when it improves clarity:
import { formatDate, parseDate } from "./dateUtils.js";
Use namespace imports when the grouping itself is meaningful:
import * as analytics from "@/platform/analytics";
analytics.trackPageView("checkout");
analytics.trackEvent("payment_started");
Re-exports and Barrel Files
A re-export forwards exports from another module.
export { Button } from "./Button.jsx";
export { Modal } from "./Modal.jsx";
export { TextField } from "./TextField.jsx";
A file that mainly re-exports from several files is often called a barrel file:
// components/index.js
export { Button } from "./Button.jsx";
export { Card } from "./Card.jsx";
export { Dialog } from "./Dialog.jsx";
Then consumers can import from one entry point:
import { Button, Card } from "@/components";
Benefits:
- Cleaner public API for a folder or package.
- Easier to hide internal file layout.
- Convenient imports for consumers.
Risks:
- Can accidentally create circular dependencies.
- Can pull in side-effectful modules earlier than expected.
- Can make dependency graphs harder to understand.
- Can slow development tooling in very large projects if every folder has broad barrels.
Good practice: use barrel files at deliberate boundaries, not automatically in every directory.
Live Bindings
ES module imports are live bindings. That means an imported binding reflects the current value exported by the exporting module. The importing file cannot reassign the imported binding, but if the exporter updates it, importers see the update.
// counter.js
export let count = 0;
export function increment() {
count += 1;
}
// app.js
import { count, increment } from "./counter.js";
console.log(count); // 0
increment();
console.log(count); // 1
Invalid:
import { count } from "./counter.js";
count = 10; // Error: imported bindings are read-only in the importing module
Important nuance: read-only binding does not mean deeply immutable object.
// config.js
export const settings = {
theme: "light",
};
// app.js
import { settings } from "./config.js";
settings.theme = "dark"; // The object can still be mutated.
For React apps, exporting mutable shared objects is usually risky because any module can change shared state outside React's state flow. Prefer functions, constants, factories, context, stores, or immutable data patterns.
Module Evaluation and Top-Level Code
A module's top-level code runs when the module is loaded and evaluated.
console.log("analytics module loaded");
export function track(eventName) {
console.log(eventName);
}
If another file imports this module, the top-level console.log runs during module evaluation.
Top-level side effects include:
- Registering global event listeners.
- Writing to
localStorage. - Starting timers.
- Calling APIs.
- Modifying globals.
- Mutating shared objects.
In React, prefer keeping side effects inside appropriate places:
- Event handlers for user actions.
useEffectfor synchronization with external systems.- App initialization code for deliberate startup work.
Avoid hidden side effects in utility modules:
// Risky: importing this file changes global behavior immediately.
window.addEventListener("resize", handleResize);
export function getWindowSize() {
return { width: window.innerWidth, height: window.innerHeight };
}
Better:
export function subscribeToWindowResize(handler) {
window.addEventListener("resize", handler);
return () => window.removeEventListener("resize", handler);
}
Circular Dependencies
A circular dependency happens when modules import each other directly or indirectly.
// a.js
import { b } from "./b.js";
export const a = "A";
export const fromB = b;
// b.js
import { a } from "./a.js";
export const b = "B";
export const fromA = a;
ES modules can support some cycles because bindings are live, but cycles can still fail if a module reads an imported binding before it is initialized.
React-specific examples:
components/index.jsexports everything, while a component imports from that same barrel.- A feature module imports a shared store, while the store imports feature-specific actions.
- A route file imports a component, and the component imports route configuration.
Symptoms:
undefinedimported values.- Runtime errors about accessing values before initialization.
- Tests passing individually but failing when run together.
- Hot module replacement behaving strangely.
Fixes:
- Extract shared types or constants to a third module.
- Move wiring code closer to the composition root.
- Avoid importing from a barrel inside files that the barrel itself exports.
- Separate low-level utilities from feature-level modules.
CommonJS Interop
Older Node.js packages often use CommonJS:
const express = require("express");
module.exports = function createServer() {
return express();
};
Modern React source code usually uses ES modules:
import React from "react";
export function App() {}
Bundlers and TypeScript often smooth over differences between CommonJS and ES modules, but interop can still create confusing default import behavior.
Examples of possible confusion:
import thing from "some-commonjs-package";
import * as thingNamespace from "some-commonjs-package";
const thingRequire = require("some-commonjs-package");
Depending on package format, TypeScript settings, and bundler behavior, these can produce different shapes. In interviews, the important point is not memorizing every interop rule. The important point is recognizing that CommonJS module.exports and ES module export default are different systems that tooling may bridge.
TypeScript Type Imports
React projects often separate runtime imports from type-only imports.
import type { User } from "./types";
import { fetchUser } from "./api";
export async function loadUser(id: string): Promise<User> {
return fetchUser(id);
}
import type communicates that the import is only needed for type checking and should not become a runtime dependency.
This matters because:
- It avoids accidental runtime imports.
- It helps bundlers and TypeScript emit cleaner output.
- It prevents side-effectful modules from being loaded just for types.
- It makes component APIs clearer.
Common pattern:
import type { ReactNode } from "react";
type CardProps = {
title: string;
children: ReactNode;
};
export function Card({ title, children }: CardProps) {
return (
<section>
<h2>{title}</h2>
{children}
</section>
);
}
Tree-Shaking and Side Effects
Tree-shaking is a bundler optimization that removes unused exports from the final bundle when it can safely prove they are unused.
// math.js
export function add(a, b) {
return a + b;
}
export function multiply(a, b) {
return a * b;
}
import { add } from "./math.js";
console.log(add(1, 2));
If multiply is unused, the bundler may remove it from the production output.
Tree-shaking works best when:
- Modules use static ES imports and exports.
- Modules avoid hidden top-level side effects.
- Imports are specific.
- Package metadata accurately describes side effects.
Poor pattern:
import "@/features";
This import may run many modules for their side effects, making it harder to remove unused code.
Better pattern:
import { CheckoutPage } from "@/features/checkout";
Organizing Modules in React Applications
A React feature folder might expose a small public API:
features/
users/
api/
fetchUsers.ts
components/
UserCard.tsx
UserList.tsx
hooks/
useUsers.ts
index.ts
// features/users/index.ts
export { UserList } from "./components/UserList";
export { useUsers } from "./hooks/useUsers";
export type { User } from "./types";
Other features import through the public API:
import { UserList } from "@/features/users";
But files inside the same feature can import directly:
import { UserCard } from "../components/UserCard";
Good module boundaries:
- Export what consumers need.
- Keep internal helpers internal.
- Avoid cross-feature imports into deep private files.
- Avoid one global
utilsfolder that mixes unrelated concerns. - Keep side effects explicit and close to where they are used.
Common Mistakes
Common mistakes include:
- Importing a named export as if it were default.
- Importing a default export with curly braces.
- Creating circular dependencies through barrel files.
- Putting expensive side effects at module top level.
- Exporting mutable shared objects and mutating them across files.
- Using dynamic imports for tiny code paths where the loading complexity is not worth it.
- Forgetting that
React.lazyexpects a default export. - Assuming TypeScript path aliases automatically work in every runtime tool.
- Mixing CommonJS and ES module syntax without understanding the build setup.
Best Practices
Use these rules of thumb:
- Prefer named exports for shared utilities, hooks, constants, and reusable components unless the team standard says otherwise.
- Use default exports deliberately for pages, route components, or modules with one obvious primary value.
- Keep module top-level code mostly declarative.
- Use dynamic import for meaningful code splitting, not as a default import style.
- Avoid importing from a barrel file inside a file exported by that same barrel.
- Use
import typefor TypeScript-only dependencies. - Keep public APIs small and stable.
- Treat modules as architecture boundaries, not just file separators.