DEV_NET_CORE
GET_STARTED
ReactJavaScript fundamentals

Modules and import/export behavior

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:

Code
// math.js
export function add(a, b) {
  return a + b;
}

export const PI = 3.14159;
Code
// 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:

Code
// UserCard.jsx
export function UserCard({ user }) {
  return (
    <article>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
    </article>
  );
}
Code
// 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.

Code
export const API_BASE_URL = "/api";

export function formatUserName(user) {
  return `${user.firstName} ${user.lastName}`;
}
Code
import { API_BASE_URL, formatUserName } from "./users.js";

Named exports are useful when a module exposes multiple related values:

Code
// 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:

Code
import { formatUserName as formatName } from "./users.js";

console.log(formatName(user));

Default Exports

A default export is the single primary export from a module.

Code
// Button.jsx
export default function Button({ children }) {
  return <button>{children}</button>;
}
Code
// Toolbar.jsx
import Button from "./Button.jsx";

The importing file chooses the local name:

Code
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:

Code
export function parsePrice(value) {
  return Number(value);
}

export function formatPrice(value) {
  return `$${value.toFixed(2)}`;
}
Code
import { parsePrice, formatPrice } from "./price.js";

Default export:

Code
export default function ProductPage() {
  return <main>Products</main>;
}
Code
import ProductPage from "./ProductPage.jsx";

Common interview comparison:

Export styleBest forTrade-off
Named exportShared utilities, hooks, constants, multiple valuesImport name must match or be aliased
Default exportOne primary value such as a page or lazy componentImport name can drift across files
Mixed exportsPrimary value plus helpersCan make module API less obvious if overused

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.

Code
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:

Code
// Often fails in browser-native modules without an import map or bundler.
import { Button } from "components/Button";

Safer relative form:

Code
import { Button } from "./components/Button.js";

Common React project form with a configured alias:

Code
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:

Code
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:

Code
if (shouldLoadAdmin) {
  import { AdminPanel } from "./AdminPanel.jsx";
}

Use dynamic import() for conditional loading:

Code
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.

Code
async function loadFormatter(locale) {
  const module = await import(`./formatters/${locale}.js`);
  return module.formatDate;
}

In React, dynamic import is commonly used with React.lazy:

Code
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.

Code
const ProfilePage = lazy(() => import("./ProfilePage.jsx"));

This works naturally when ProfilePage.jsx has a default export:

Code
export default function ProfilePage() {
  return <main>Profile</main>;
}

If the file uses a named export:

Code
export function ProfilePage() {
  return <main>Profile</main>;
}

you can adapt it:

Code
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:

Code
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:

Code
import { formatDate, parseDate } from "./dateUtils.js";

Use namespace imports when the grouping itself is meaningful:

Code
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.

Code
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:

Code
// 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:

Code
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.

Code
// counter.js
export let count = 0;

export function increment() {
  count += 1;
}
Code
// app.js
import { count, increment } from "./counter.js";

console.log(count); // 0
increment();
console.log(count); // 1

Invalid:

Code
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.

Code
// config.js
export const settings = {
  theme: "light",
};
Code
// 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.

Code
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.
  • useEffect for synchronization with external systems.
  • App initialization code for deliberate startup work.

Avoid hidden side effects in utility modules:

Code
// Risky: importing this file changes global behavior immediately.
window.addEventListener("resize", handleResize);

export function getWindowSize() {
  return { width: window.innerWidth, height: window.innerHeight };
}

Better:

Code
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.

Code
// a.js
import { b } from "./b.js";

export const a = "A";
export const fromB = b;
Code
// 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.js exports 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:

  • undefined imported 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:

Code
const express = require("express");

module.exports = function createServer() {
  return express();
};

Modern React source code usually uses ES modules:

Code
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:

Code
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.

Code
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:

Code
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.

Code
// math.js
export function add(a, b) {
  return a + b;
}

export function multiply(a, b) {
  return a * b;
}
Code
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:

Code
import "@/features";

This import may run many modules for their side effects, making it harder to remove unused code.

Better pattern:

Code
import { CheckoutPage } from "@/features/checkout";

Organizing Modules in React Applications

A React feature folder might expose a small public API:

Code
features/
  users/
    api/
      fetchUsers.ts
    components/
      UserCard.tsx
      UserList.tsx
    hooks/
      useUsers.ts
    index.ts
Code
// 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:

Code
import { UserList } from "@/features/users";

But files inside the same feature can import directly:

Code
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 utils folder 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.lazy expects 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 type for TypeScript-only dependencies.
  • Keep public APIs small and stable.
  • Treat modules as architecture boundaries, not just file separators.

Interview Practice

PreviousClosures and lexical scopeNext UpPromises and asynchronous JavaScript