Skip to main content

Command Palette

Search for a command to run...

JavaScript Modules

Your app.js is getting out of hand

Published
6 min read
JavaScript Modules

Here's a situation that happens on basically every team that doesn't use modules. Aditya is writing the auth logic, Saumya is building the user profile page, and Manas is working on the cart. They're all on the same app. Without modules, everyone's code goes into one file: app.js. It works fine at first. Then it's 600 lines. Then 1,400. At 3,000 lines, people start writing new code at the bottom just to avoid scrolling.

But line count is not actually the problem. The problem is that all that code shares one global scope. Aditya declares var user for his login flow. Saumya also declares var user for the profile component. JavaScript doesn't throw an error. It just replaces one with the other, silently, based on load order. Aditya's login function now reads Manas' cart user. No warning. You find out at 2am when something explodes in production.

This isn't a bug in anyone's code. It's what happens when three people share a single room with no walls. Any variable can reach any other variable. You lose track fast.

// Aditya's auth section 
var user = "Aditya";

// Saumya's profile section overwrites Aditya's user, no error 
var user = "Saumya";

// Manas' cart section overwrites Saumya's. login() now gets this. 
var user = "Manas"; function addToCart() { /* ... */ }

// Good luck figuring out which user you're reading on line 847.

What is a Module ?

A module is just a file that owns its own scope. Whatever you write in it stays in it. Other files cannot read your variables, call your functions, or accidentally overwrite anything inside unless you specifically open a door with export. And if another file wants something, it has to ask with import.

That's the whole model. JavaScript has had this built in since 2015. No extra tools. Two keywords. Each file becomes its own room with a lock on the door.

You end up with small files that each own their own piece of logic, and connect to each other through the specific things they choose to share. When something breaks, you know exactly whose room to check.

Exporting: What You Choose to Share

By default, nothing leaves a module file. You have to opt things out with export. There are two ways to do it and they work differently: named exports and default exports.

Named exports let you export multiple things from one file. You put export in front of each one, and they each keep their name. Default exports are for when the file is really just one thing, you get one per file, and whoever imports it can name it whatever they want locally.

// Each one is named and exported separately
export function add(a, b)      { return a + b; }
export function multiply(a, b) { return a * b; }
export const PI = 3.14159;
// This file's whole job is logging. One thing, one export.
export default function log(msg) {
  const ts = new Date().toLocaleTimeString();
  console.log(`[\({ts}] \){msg}`);
}

Importing: Asking For What You Need

Imports go at the top of the file, always. JavaScript reads them all first before executing anything, it builds a map of what depends on what before running a single line of your logic.

Named imports use curly braces and the exact name. Default imports don't need curly braces, and you can name them whatever makes sense locally. You can also rename a named import with as if it clashes with something.

// Named import, must match the exported name exactly
import { add, multiply, PI } from './utils/math.js';

// Default import, call it whatever you like locally
import log from './utils/logger.js';

// Rename a named import to avoid a local conflict
import { add as addNums } from './utils/math.js';

log(`2 + 3 = ${add(2, 3)}`);   // [10:32:01] 2 + 3 = 5

Named vs Default

Named and default exports look similar but they behave differently on the import side. Named imports need curly braces and the exact name. Default imports take no curly braces and you pick the local name yourself. Mixing them up is the most common beginner mistake and the error message is not always obvious.

The rule most teams use: if a file exports multiple things, use named. If a file is about one single thing a React component, a class, a config factory use default. That split tends to make codebases easier to read at a glance.

Named Exports Default Exports
Curly braces on import No curly braces
Name must match exactly (or use as) Caller picks any local name
Many per file, good for utility collections One per file, for the main thing
Tree-shakeable: bundlers drop what you don't import Not tree-shakeable, whole thing is imported

If bundle size matters to your project, prefer named exports for utility code. Bundlers like Vite and Rollup can drop named exports nobody imports, they can't do that with defaults, since the whole default is treated as one unit.

How Files Connect to Each Other

Once you split code into modules, a structure forms on its own. Utility files get imported by feature files. Feature files get imported by the entry point. Nothing in a utility knows anything about the feature files using it. Nothing in the entry point knows how the features are implemented internally.

When a bug shows up, you trace the arrow. Something wrong in the cart? Start at cart.js. It imports from api.js and utils/format.js, so the bug is in one of those three places, not scattered across 3,000 lines of a single file.

Isolation is Key

Here's a user card feature spread across four files. Read it and notice what each file does not know. utils/format.js has no idea there's a user object involved, it just handles strings. api/users.js knows nothing about how the card looks. main.js has two lines and still renders everything correctly.

// utils/format.js
// only job is formatting
export function formatName(user) {
  return `\({user.firstName} \){user.lastName}`;
}
export function formatDate(str) {
  return new Date(str).toLocaleDateString('en-IN');
}
// api/users.js
// only fetch a user
export async function fetchUser(id) {
  const res = await fetch(`/api/users/${id}`);
  if (!res.ok) throw new Error("User not found");
  return res.json();
}
// components/userCard.js
import { fetchUser } from '../api/users.js';
import { formatName, formatDate } from '../utils/format.js';

// just builds a card
export default async function renderUserCard(userId) {
  const user = await fetchUser(userId);
  return `
    <div class="card">
      <h2>${formatName(user)}</h2>
      <p>Joined: ${formatDate(user.createdAt)}</p>
    </div>`;
}
// main.js
import renderUserCard from './components/userCard.js';

// responsible to render things correctly
document.getElementById("app").innerHTML = await renderUserCard(42);

If the API changes how it returns user data, you update api/users.js. If the date format needs to change, you update utils/format.js. Neither change touches the other files. That's the payoff.

The Objective of This Approach

Simply JavaScript

Part 8 of 25

JavaScript is a quirky language. To master it, one should know to avoid its hidden traps along with its logic. This series showcase my journey through JS: the pain points, the breakthroughs, and the coding standards that I adopted from my mentors.

Up next

The Developer's Guide to Flattening Arrays in JavaScript

Stop Wrestling Nested Data