Chapter 24 — ES Modules and Tooling Basics
Section 1 — Why one big script gets messy
When you first learn JavaScript, it feels natural to put everything in one file. The page is small, the code is short, and you can see it all on one screen. A button handler sits next to a price calculation, which sits next to a bit of code that changes a heading. At that size, one file works fine, and there is nothing wrong with starting that way. The trouble begins when the page grows, because growth is not just more lines. It is more ideas competing for your attention, more names to remember, and more places where a small change can have a surprising side effect somewhere else.
Imagine a shop page that starts with a simple total. Later you add a discount, then a delivery charge, then a form check, then a menu that opens and closes, then a list that loads from a server. If all of that lives in one file, finding the discount code means scrolling past the menu code, the form code and the list code. You lose time just looking. Worse, you start to be afraid to change anything, because you cannot be sure what else depends on the part you are touching. A change that should take five minutes takes an hour, because you must read the whole file first to feel safe.
Names cause a second problem. In one long file, every variable and function shares the same space. If the menu code uses a variable called open and the discount code also uses open for something different, one of them will overwrite the other, or the code will behave in a way that is hard to explain. You can work around this with longer, stranger names, but that is a patch, not a solution. The file becomes a list of names like menuOpenStateForHeader and discountOpenFlag, and reading it feels like reading a puzzle rather than a plan.
A third problem is reuse. Suppose you write a good price-formatting function in the shop page and later want the same formatting on a checkout page. With one big file, you cannot take just that function. You must copy it by hand, and now there are two copies to keep in step. When the formatting rule changes, you must remember both places, and sooner or later you will forget one. Copying feels quick, but it creates a quiet debt that grows every time the code is copied again.
Splitting code into smaller files, each with one clear job, solves all three problems at once. One file formats prices, another works out totals, another handles the menu. Each file is short enough to read in one sitting, its names only need to make sense inside that file, and another page can reuse a file without copying its code. This is the idea behind modules, and the rest of this chapter shows how to use them well, starting with what a module actually is.
Check: Why does one very long script file become hard to work with?
Section 2 — What a module is
A module is a single JavaScript file that keeps its own code separate and chooses what to share. Think of it as a small workshop. Inside the workshop there are tools, materials and half-finished jobs, but a visitor cannot just walk in and grab everything. The workshop lends out only the tools it deliberately puts on the counter. Everything else stays private, where it cannot be disturbed by accident. A module works the same way: code inside the file is private by default, and only the values marked for sharing can be used by other files.
This privacy is the point, not a restriction to work around. When you open a module, you can see its whole job without wondering whether some other file is quietly changing its variables. When you use a module from another file, you know exactly what you are allowed to use, because the module lists its shared pieces at the top or beside each definition. That list is a promise. It says, in effect, these are the parts I will support, and the rest is my own business and may change. Good modules keep that promise small and clear.
A module usually does one job. A price module formats prices and works out totals. A validation module checks form input and returns clear messages. A menu module opens and closes a menu and nothing else. This focus matters, because a module with one job is easy to name, easy to test and easy to explain to a teammate. If you cannot describe a module's job in one short sentence, it is probably doing too much, and splitting it will help. You will see this idea again later in the chapter, when we talk about keeping modules small.
Modules also make a project easier to navigate. Instead of one enormous file, you have a folder of small files with honest names. A new developer joining the project can guess where the price code lives, open that file, and understand it without reading the menu code as well. The project becomes a map, not a maze. The folder structure itself becomes documentation, because the names and grouping tell the story of how the app is built.
Here is a tiny example. A file called format.js might contain a function that turns a number into a price string, and nothing else. Another file can ask for that function and use it, without learning anything about how the rest of the shop works. That separation is what lets large front-end projects stay manageable. One file, one job, a clear list of shared pieces, and private details kept private: that is a module, and it is the building block for everything else in this chapter.
Check: In your own words, what is a module?
Section 3 — Scripts used to share everything
Before modules became the normal way to write browser JavaScript, scripts worked very differently. You loaded each file with its own script tag in the HTML, one after another, and every file shared the same global space. The global space is like a single shared table in a busy workshop. Anyone can put a tool on the table, anyone can pick one up, and anyone can accidentally move someone else's tool. There were no walls between files, so a variable declared in one file was visible in every file loaded after it.
That shared space made order matter far too much. If file B used a function from file A, file A's script tag had to come first in the HTML. Swap the tags, or forget one, and the page broke with an error about something being undefined. On a small page with two scripts, this was easy to manage. On a larger page with ten or twenty scripts, the HTML became a fragile list that had to be kept in exactly the right order, and nobody quite remembered why a particular order was needed. Adding a new script meant guessing where it belonged in the list, and a wrong guess broke something that had been working.
Name clashes were the other constant danger. Suppose one script used a variable called count to track items in a basket, and another script, written by a different person or copied from an example, also used count to track slides in a carousel. Because both lived in the same global space, the second count overwrote the first, or the two pieces of code took turns changing the same value. The basket showed the wrong number, or the carousel jumped, and neither piece of code looked wrong on its own. The bug lived in the interaction between them, which made it hard to find.
Developers invented workarounds. They wrapped code in functions to hide variables, they used very long and specific names, and they built tools that joined many files into one before shipping. Those workarounds helped, but they were patches over a missing idea: files should have their own private space by default. Modules provide that idea directly in the language. Each module file gets its own scope, its own names, and a clear way to share only what it chooses. Understanding the old shared-everything model matters, because it explains why modules were invented and why their privacy is so valuable. That history also explains why modern tools still care about order and naming: they are solving, in a systematic way, problems that the shared global space created for every project that grew beyond a handful of files. Learning that history helps you appreciate why privacy by default was such an important change for larger projects.
Check: Why could two old-style script files clash with each other?
Section 4 — Modules have their own scope
Scope is the set of places in code where a name can be seen and used. In a module, the top-level scope belongs to that module alone. A variable declared at the top of one module file is not visible in another module file, even if both files are loaded by the same page. This is a sharp change from classic scripts, where a top-level variable became global and visible everywhere. With modules, privacy is the default, and sharing is a deliberate choice made with an export.
A simple example makes this concrete. Imagine two modules in a shop project. One module works out the basket total and declares a variable called total. Another module works out a delivery estimate and also declares a variable called total. In the old shared model, the second declaration would interfere with the first. In the module model, each total lives in its own file, and neither can see the other. Both modules work correctly, and neither author needed to know about the other's choice of name. That freedom is a practical gift: you can pick the clearest name for the job in front of you, without checking every other file first.
This separation also changes how you read code. When you open a module and see a variable, you know it was defined in that file or imported into it, because there is no other way for it to appear. You do not need to search the whole project for a stray global assignment. If a value comes from elsewhere, the import line at the top of the file tells you where. The file becomes self-describing: its imports list what it depends on, its own code shows what it does, and its exports list what it offers to others.
There is one exception worth knowing about, so it does not surprise you. Code can still deliberately create a global by assigning to a property on the global object, but that is a choice you make on purpose, not something that happens by accident when you declare a variable. Ordinary declarations in a module stay private. Keep it that way. If two modules need to share a value, export it from one and import it into the other, rather than smuggling it through the global space. That explicit path is easier to follow, easier to test, and easier to remove later when the design changes. When you debug a module, start by listing its imports and its own declarations. If a name is not on either list, it cannot be used there, and that simple check often reveals a missing import or a typo immediately. That clarity is especially valuable in a large file, where a stray global assignment would otherwise be almost impossible to spot.
Check: If two modules both declare a variable called total, do they clash? Why not?
Section 5 — Exporting a named value
Exporting is how a module puts a value on its counter for others to borrow. The keyword is export, and you write it in front of the thing you want to share. For example, in a file called format.js, you might write a function that turns a number into a readable price and mark it for sharing in the same line. That one keyword changes the function from private to available. Other files can then import it by name, while everything else in the file stays private and untouched.
Here is the example in full, kept deliberately small so the pattern is clear:
export function formatPrice(amount) {
return "$" + amount.toFixed(2);
}
The function itself is ordinary JavaScript. It takes a number, fixes it to two decimal places, and adds a currency symbol. What makes it a module export is only the word export at the start. You can export more than one thing from the same file by writing export in front of each one. A formatting module might export a price formatter, a date formatter and a helper that shortens long text, each with its own export line. A reader scanning the file can see at a glance which pieces are public, because each one is marked.
You can also export a value after defining it, using a list at the bottom of the file. Both styles are valid, and teams usually pick one and stick to it for consistency. The list style looks like this: define the functions normally, then write a single line naming the ones to share. This can be helpful when a file exports several things, because the list acts as a small table of contents. Whichever style you choose, keep the exported set small. Every export is a promise to keep that piece working for other files, so exporting everything just in case makes the promise harder to keep.
A good habit is to export the finished, useful pieces and keep the helpers private. If your formatting function uses a small internal helper to pad a number, that helper does not need to be exported. Keeping it private means you can change or replace it later without worrying about other files that might have started using it. Exports are the public face of a module. Design that face with care, and the inside of the module stays free to improve. Try this habit while learning: write the export, then immediately write a tiny importer that uses it. That short loop proves the sharing works before the module grows more complicated. Keep the public list short, and the module will stay easy to use, easy to test, and easy to change later.
Check: What keyword makes a function available to other modules?
Section 6 — Importing a named value
Importing is the other half of sharing. Once a module exports a value by name, another file can bring that value in with an import statement, written at the top of the file. The statement names the value in curly braces, says which module it comes from, and gives a path to that module's file. For example, a page script that needs to format prices would write import { formatPrice } from "./format.js"; near the top, and from that point on it can call formatPrice(10) as if the function had been written in the same file. The import line is also documentation: anyone opening the file can see, before reading any logic, exactly which outside pieces this file depends on.
The name inside the braces must match the exported name exactly, including capital letters, because JavaScript names are case-sensitive. If the module exports formatPrice and you try to import formatprice or FormatPrice, the browser will report that the name was not exported, even though the names look almost the same to a human eye. This strictness is helpful once you get used to it, because it catches spelling mistakes early instead of letting the code run with the wrong value or with nothing at all. When an import fails, the first thing to check is always spelling, including capitals, and the second is the file path, which we will cover in the next sections.
You can import several named values from the same module in one statement, separated by commas inside the braces. For instance, import { formatPrice, formatDate } from "./format.js"; brings in both functions at once. This keeps the top of the file tidy and makes the dependency clear in one place, rather than scattering several import lines for the same file. If a file imports from many modules, group the imports sensibly, perhaps one line per module, so the list remains easy to scan.
Imported values behave like local constants in the importing file: you can use them, pass them around and call them, but you should not try to reassign them. The module that exported them owns them, and treating imports as read-only keeps the relationship honest. If you need a changed version of a value, create a new local variable based on it, rather than overwriting the import. This rule prevents a confusing situation where one file changes a shared value and another file sees a different value from the one it expected. Imports are a way to borrow, not a way to take over.
A practical tip for beginners is to write the import and the use together while learning. First write the export in one file, then immediately write a tiny second file that imports it and calls it with a known value, and check the result in the console or on the page. That small loop, export then import then use, builds the habit faster than reading about imports alone. Once it feels natural, you will start to design files around what they export, which is exactly the mindset that makes larger projects manageable. A module is not just a file; it is a file with a clear doorway in and a clear doorway out, and the import statement is the doorway in.
Check: Why must the imported name match the exported name exactly?
Section 7 — The file path matters
Module imports do not work by file name alone. They work by path, which is a set of directions telling the browser where to find the module file, starting from the file that contains the import. This matters because a project can contain many files with similar names in different folders, and the path is what removes the ambiguity. A path that starts with ./ means "in the same folder as this file". A path that starts with ../ means "go up one folder, then follow the rest of the path from there". A path that starts with / means "from the root of the website", which is a different starting point and behaves differently when the site is served from a subfolder, so beginners should prefer relative paths until they understand the difference.
Consider a small project. The entry file lives in a folder called js, and the formatting module lives beside it, also in js. From the entry file, the import path is ./format.js, because the module is in the same folder. Now suppose the formatting module moves into a subfolder called js/helpers. From the entry file, the path becomes ./helpers/format.js, because you must go into the helpers folder first. If instead the entry file were in the root and the module were in js, the path would be ./js/format.js. The same module, the same name, but a different path in each case, because the path is always relative to the importer. That is why copying an import line from one file to another without adjusting the path is a common source of errors.
Paths also explain why moving files can break a project even when no code changes. If you reorganise folders, every import that pointed at a moved file must be updated to the new path, or the browser will report that it cannot find the module. Tools can help with this, and later chapters discuss them, but the underlying idea is simple and worth learning by hand first. When an import fails, open the importing file, look at the path, and ask yourself: starting from this file's folder, and following these directions step by step, do I arrive at a real file? If the answer is no, the path is wrong, and fixing the path fixes the import. This mental walk through folders is one of the most useful debugging habits you can build.
It also helps to keep folder structures shallow and sensible while you are learning. A folder or two, with clear names, is easier to reason about than a deep tree. As projects grow, folders will deepen naturally, and the path rules stay exactly the same: ./ for here, ../ for up one level, and further segments for folders to enter. You can chain ../ segments, for example ../../shared/format.js to go up two levels, but if you find yourself chaining many levels, that is a hint that the files may belong closer together or that a shared folder needs a clearer place in the project. Paths are directions, and good directions are short and unambiguous.
Check: What does ./ at the start of an import path mean?
Section 8 — You must include the file extension
In browser modules, an import path must include the file extension, almost always .js. This surprises many beginners, because other tools and languages often let you leave the extension off and work out the file for you. The browser does not do that. When you write import { formatPrice } from "./format";, the browser asks the server for a file literally called format, with no extension. The server does not find such a file, or it returns something unexpected, and the import fails. The fix is simple and absolute: always write ./format.js, with the extension, every time.
This rule exists because browsers treat module paths as web addresses, not as guesses. A web address points at a specific resource, and the browser fetches exactly what the address says. It does not try adding extensions, searching folders, or guessing which file you meant. That strictness is consistent with how the web works elsewhere: a link to a page or an image also points at an exact address. Once you see imports as web addresses, the extension rule stops feeling arbitrary and starts feeling like part of the same system. It also means the path you write must match the real file name on disk, including capital letters on systems where file names are case-sensitive.
Some build tools relax this rule during development, which can create a confusing trap. You might write imports without extensions, see them work through a tool, and then be surprised when the same code fails in a plain browser setup. Learning the strict browser rule first protects you from that surprise. If you later use a tool that allows shorter paths, you will understand that the tool is adding convenience on top of the browser's rules, not that the browser's rules have changed. Writing the extension always, even when a tool would forgive its absence, is a cheap habit that keeps your code portable between setups.
There is one more detail to check when an import fails despite the extension being present. Make sure the extension is correct for the file you are importing. A JavaScript module is .js, not .html or .txt, and a typo in the extension is just as fatal as leaving it off. Also check that the file itself is a JavaScript file and not, for example, a page that happens to contain a script. The browser will fetch the address you give it and then try to treat the result as a module. If the result is not JavaScript, the error message will say so, and the fix is to point at the right file. Extension, spelling, and path, checked together, solve most import failures. If you ever switch to a tool that forgives a missing extension, keep writing it anyway. The habit costs nothing, and it keeps your code working if the tool is later removed or replaced.
Check: Why does from "./format" fail in a browser module?
Section 9 — Default exports
A default export is a module's main thing. While named exports share several values by name, a default export shares one primary value without requiring the importer to use its original name. You mark it with the words export default, followed by the value, which is often a function. For example, a file called greet.js whose whole job is greeting might contain export default function greet(name) { return "Hello, " + name; }. The file has one clear purpose, and the default export says so directly: this is what this file is for.
Importing a default export looks different, and the difference is the point. You write the import without curly braces: import greet from "./greet.js";. Because there are no braces, you are free to choose any local name for the imported value. You could import it as greet, or as sayHello, or as welcome, and the code would work the same way, because the default export is identified by being the default, not by its name. That freedom is convenient, but it also means a reader cannot tell from the import line alone what the original name was. For that reason, most teams keep the local name close to the original, and use default exports mainly for files with a single obvious purpose.
Default exports are a good fit for certain shapes of module. A file that defines one component, one main class, or one central function is a natural candidate. A file that provides a toolbox of small helpers is not, because helpers are better shared as named exports, where each name tells you what you are getting. If you find yourself giving a file a default export and also many named exports, pause and ask whether the file is really doing two jobs. Often it is, and splitting it into a main file and a helpers file makes both clearer.
There is also a subtle reading cost to default exports that beginners should know about. When you search a project for where a named export is used, you can search for its exact name. When you search for a default export's uses, you cannot, because every importer may have chosen a different local name. That makes refactoring a little harder and code review a little slower. None of this makes default exports wrong. It makes them a choice with trade-offs, like most choices in programming. Use them where a module truly has one main thing, name the import sensibly, and prefer named exports when a module offers several equally important pieces. When you review a file that uses a default export, check that the local name still describes the value well. A clear local name makes the import line read like a sentence, not a puzzle.
Check: How does importing a default export look different from importing a named export?
Section 10 — One default per module
A module may have only one default export, and the reason is built into the meaning of the word default. The default is the main thing a module offers when you import it without naming anything specific. If a module could have two defaults, the browser would have no way to know which one you meant when you wrote an import without braces. The rule is therefore not an arbitrary limit; it is what makes the syntax work at all. One module, one default, no ambiguity.
This does not limit what a module can share. A module can have one default export and as many named exports as it needs, side by side. For example, a shopping module might default-export a function that creates a basket, while also named-exporting helpers for formatting prices and checking stock. An importer can then take the default and any named pieces it needs in a single statement, using both styles together. The default says "here is the main idea", and the named exports say "here are the useful extras". Together they describe the module's public face completely.
Beginners sometimes try to work around the one-default rule by making everything a default, or by splitting a file just to get a second default. Both are signs that the design needs a second look. If two values feel equally like "the main thing", they probably belong in two modules, or they should both be named exports, where neither claims to be primary. Named exports are honest about equality: each piece has a name, and the importer picks by name. Defaults are honest about primacy: one piece stands out, and the rest support it. Choosing between them is a design decision, not a syntax puzzle.
The error message when you break this rule is usually clear, and the fix is to decide which value is truly the default and convert the others to named exports, or to split the file. That decision often improves the code, because it forces you to state what the module is really for. A module with a confused default is a module with a confused purpose, and fixing the export usually fixes the purpose too. Remember the simple test: if you had to describe the module in one sentence, would that sentence name one thing? If yes, that thing is a good default. If no, named exports are the better choice, and the module may be doing more than one job. Before choosing a default, write the one-sentence description of the module. If two values compete for that sentence, the module is probably trying to do two jobs, and splitting it will clarify both. That clarity of purpose is worth the small effort of deciding, because it shapes how the whole module will be used.
Check: Can a module have two default exports? Why not?
Section 11 — Renaming an import
Sometimes the name a module exports does not fit well in the file that imports it. Perhaps the name is too long for the way you use it, too generic for the context, or it clashes with a name you already have in your file. JavaScript lets you rename an import at the point where you bring it in, without changing the original module at all. The syntax uses the word as inside the braces: import { formatPrice as money } from "./format.js";. After that line, you call the function as money(10), and the original module is completely unaffected. Only your local name has changed.
Renaming is most useful when a clash would otherwise occur. Suppose your file already has a local function called formatPrice for a different purpose, perhaps formatting a price in a different currency or style, and you also need the imported one. Without renaming, the two would collide and one would shadow the other, leading to subtle bugs. By importing the outside function as formatPriceFromShop or a similarly clear name, you keep both visible and distinct. The code becomes honest about which function is which, and a reader does not need to guess. The same trick helps when you import similarly named helpers from two different modules, such as a validate function from a form module and a validate function from a payment module. Rename one or both, and the file stays readable.
Renaming also helps with readability in a narrower sense. A module might export a function with a very precise but long name, such as formatPriceForDisplayInPounds. In a file where that function is called many times, a shorter local name can make the code easier to scan, provided the shorter name is still clear. The danger is the opposite: renaming to a vague name like f or doIt destroys the meaning the original author carefully chose. A good rename keeps the meaning and shortens the form, or adds context the original name lacked. If a future reader cannot tell what money does without hunting for the import line, the rename has gone too far.
There is a habit worth building here. When you rename, choose a name that would still make sense if someone read only the code that uses it. Test this by covering the import line and reading a call site: does money(10) make sense in context? In a shop file, probably yes. In a general file, a fuller name might be better. Renaming is a local decision, so different files can import the same export under different names, each suited to its own context. That flexibility is a strength, but it also means you should not assume that a name in one file matches the name in another. When tracing a function across files, follow the import lines, not the local names. The export name is the stable identity; the local name is just how one file chooses to address it.
Check: How would you import formatPrice under the local name money?
Section 12 — Importing everything as one object
There is another way to import from a module: bring in everything it exports, gathered under a single namespace object. The syntax is import * as format from "./format.js";. The star means "everything", and format becomes an object whose properties are the module's exports. To call the price function, you write format.formatPrice(10). The namespace object is not a copy of the module; it is a view onto the module's exports, organised under one name that you choose.
This style is handy when a module offers many small helpers that belong together. Instead of a long import line listing ten names, you have one short line, and every use in the file is clearly marked with the namespace: format. at the start tells the reader immediately that the function comes from the formatting module. That prefix acts like a surname, grouping related calls and preventing clashes. If your file also has a local function called formatPrice, there is no conflict, because the imported one is always addressed as format.formatPrice. For utility modules with a family of related functions, this clarity is genuinely valuable.
The cost is verbosity and a little loss of precision. Every call becomes longer, and a reader cannot tell from the import line alone which exports the file actually uses, because the line says "everything" rather than listing the few that matter. That matters when you are trying to understand a file quickly, or when you are checking whether a particular export is still needed anywhere. A long namespace prefix repeated many times can also make code feel heavy, especially for a function called in a tight calculation. For those reasons, many developers reserve the star import for cases where most of the module's exports are used, or where the namespace prefix itself adds meaning.
There is also a detail about default exports to note. When you import everything as a namespace, the default export, if there is one, appears as a property called default on the namespace object. So you would write format.default() to call it, which looks odd and is rarely the clearest choice. If a module's main value is its default export, a direct default import usually reads better. Use the star import where it helps: for a toolbox module whose members are used as a group, where the prefix improves clarity, and where the extra length is a fair price for the organisation it provides. As with renaming, the test is readability. If the namespace makes the code easier to follow, keep it. If it only makes lines longer, list the named imports instead. A namespace import also helps during a review, because every call carries its origin with it. The reviewer can see at a glance which calls belong to the imported module and which belong to the file itself.
Check: When might import * as be useful, and when might it be too much?
Section 13 — Re-exporting from a barrel file
A barrel file is a module whose main job is to re-export pieces from other modules, so that importers can take what they need from one central place. The syntax is compact: export { formatPrice } from "./format.js"; in the barrel file makes formatPrice available to anyone who imports from the barrel, without the barrel needing to define the function itself. It is a forwarding address, not a new home. A feature folder might have a barrel that gathers its display helper, its data helper and its validation helper, so the rest of the app imports from the folder's barrel instead of remembering three separate paths.
Barrels can make an app feel tidier, especially when a feature has several small files that are usually used together. A new developer can open the barrel and see the feature's public face in one place, rather than hunting through files to work out what is available. They also make refactoring easier in one respect: if a helper moves from one file to another inside the feature, only the barrel's re-export line needs to change, while importers that use the barrel keep working. That layer of indirection is the barrel's main benefit, and it is real, but it is also its main risk, because indirection always hides something.
The risk is that a barrel can hide where code actually lives. When an import fails or a function behaves unexpectedly, a developer tracing through a barrel must take an extra step to find the real definition. If barrels re-export from other barrels, that chain can grow, and tracing becomes a small expedition. Large barrels that export everything from everywhere are worse: they suggest a project has no clear boundaries, and they make it hard to tell which parts of the app depend on which other parts. A barrel should be small, deliberate and limited to one feature or one coherent group. It should gather pieces that genuinely belong together, not act as a dumping ground for the whole project.
A good rule is to start without barrels and add one only when you feel a repeated pain, such as several files importing the same three helpers from the same three paths. At that point, a barrel solves a real problem and its scope is obvious. If you cannot explain what a barrel is for in one sentence, it probably should not exist yet. Used sparingly, barrels are a neat organising tool. Used everywhere, they become another layer to understand, and the simplicity they promised quietly disappears behind the forwarding addresses. Document a barrel with one line saying which feature it gathers and why. That note stops a future reader from adding unrelated exports to it simply because it is a convenient central place.
Check: What job does a re-exporting file do?
Section 14 — How to load a module in HTML
A module file does not run on its own just because it exists in your project. The HTML page must load it, and it must load it as a module, not as a classic script. The way to do that is the type attribute on the script tag: <script type="module" src="js/main.js"></script>. That single attribute changes how the browser treats the file. Without it, the browser treats the file as a classic script, and any import or export statement inside it will cause an error, because classic scripts do not understand module syntax. With it, the browser knows to build the import graph, fetch dependencies, and run the code as a module.
The src attribute points at the entry module, usually a file called main.js or similar, which then imports everything else the page needs. You do not need a script tag for every module file. You need one for the entry point, and the import statements inside that file, and inside the files it imports, take care of the rest. This is a useful mental model: the HTML loads the front door, and the modules themselves open the interior doors. If a module is never imported, directly or indirectly, from the entry point, it will not run, no matter how correct it is. That can be confusing when you create a new file and wonder why nothing happens, but the fix is always the same: make sure something in the chain imports it.
Where you place the script tag matters less for modules than for classic scripts, for reasons the next section explains, but the usual habit is to put it at the end of the body or in the head, and to keep it consistent across pages. What matters more is the path in src, which follows the same rules as any other resource path in HTML: it is relative to the HTML file, not to the JavaScript file. That distinction catches beginners out, because import paths inside JavaScript are relative to the JavaScript file, while the script tag's src is relative to the page. Keep the two straight, and many loading errors disappear.
Finally, remember that module scripts are subject to the same security rules as other fetched resources, which is why a local server matters, as covered later. For now, the essential checklist is short: one script tag, type="module", a correct src pointing at the entry file, and imports that chain from there. Get those right, and the browser will do the rest, fetching and ordering your modules before running them. If a page loads nothing from a module, check the entry point first. A module that is not imported from the entry chain will sit unused, however correct its code may be.
Check: What attribute turns a script tag into a module script?
Section 15 — Modules wait for the page
Module scripts behave like deferred scripts, which means the browser does not run them immediately when it encounters the script tag. Instead, it fetches the module and its dependencies, continues reading the HTML, and only runs the module code after the whole document has been parsed. This is a deliberate design choice, and it solves a problem that classic scripts faced. With a classic script placed in the head, code that tried to find an element on the page would fail, because that element had not been read yet. Developers worked around this by placing scripts at the end of the body or by waiting for a load event. Modules remove most of that worry by waiting automatically.
The practical benefit is that module code can usually find page elements straight away. If your entry module contains a line that selects a button by its identifier, that button will already exist in the document by the time the module runs, because parsing finished first. You do not need to wrap your startup code in a special event handler just to be safe, although you still might for other reasons, such as waiting for data. This makes entry modules cleaner to read: they can start by selecting elements, attaching listeners and setting initial state, in a straightforward order, without a layer of waiting code around everything.
Order still matters, but in a helpful way. Modules run in dependency order, after parsing, so a module that imports another module runs after the imported module's top-level code has run. The browser works out this order from the import graph, rather than from the order of script tags in the HTML, which is one less fragile list to maintain. If you have two module script tags in a page, they run in the order they appear, but each still waits for parsing, so neither can accidentally run before the document is ready.
There is a limit to this convenience worth noting. Deferred execution means module code does not run during parsing, so it cannot affect the very first rendering of the page in the way an early classic script might. For most front-end work, that is fine, and it is actually better for performance, because parsing is not blocked. The key takeaway is simple: trust the deferral, write your startup code directly, and if an element is missing, check that the identifier matches and that the element is really in the HTML, rather than assuming the script ran too early. With modules, timing is rarely the culprit. This automatic waiting also improves the first paint, because parsing is not blocked by module execution. The page can be read and rendered in order, and the code runs once the document is ready.
Check: Why can module code usually find page elements straight away?
Section 16 — Modules run in strict mode
Strict mode is a stricter set of JavaScript rules that catches common mistakes and forbids certain sloppy habits. In a classic script, you turn it on by writing a particular string at the top of the file. In a module, you do not need to do anything: modules always run in strict mode, automatically and completely. This is a deliberate part of the module design, because modules are meant for larger, more careful code, and strict mode helps that code fail loudly when it is wrong, rather than failing quietly later.
What does strict mode actually catch? The most common example is assigning to a variable that was never declared. In non-strict code, writing total = 10; without declaring total first would silently create a global variable, which could then interfere with other code in ways that are hard to trace. In strict mode, the same line throws an error immediately, pointing at the exact place where the mistake happened. That early, loud failure is a gift. It turns a bug that might surface hours later, in a different part of the page, into a bug you fix in seconds at the spot where you made it.
Strict mode also forbids several other risky patterns. It prevents duplicate parameter names in functions, stops code from deleting variables, and makes the value of this inside a plain function call undefined rather than silently pointing at the global object. Each of these rules exists because the loose behaviour caused real bugs in real projects. None of them changes what correct code does. If your code declares its variables, uses distinct parameter names and does not rely on accidental globals, strict mode will feel invisible. It only speaks up when something is already wrong, which is exactly when you want a tool to speak up.
For a beginner, the practical lesson is twofold. First, always declare variables with let or const before using them, in modules and everywhere else, so strict mode never needs to scold you. Second, when a module throws a strict-mode error, read it as helpful information, not as an obstacle. The error is telling you that your code relied on a habit that is unsafe, and fixing the habit makes the code more robust, not just more compliant. Modules give you this safety net for free. Use it, learn from its messages, and let it shape the careful habits that will serve you in every file you write. A useful exercise is to deliberately trigger a strict-mode error in a practice file, read the message, and fix the declaration. That small experiment teaches the rule far better than memorising a list of restrictions. Treat each strict-mode message as a lesson, not a nuisance, and your code will become more reliable as a result.
Check: What does strict mode do for you in a module?
Section 17 — Modules run once, even if imported twice
When the browser loads a module, it runs that module's top-level code exactly once, no matter how many other files import it. If three different files all import the same formatting module, the formatting module still runs once, and all three importers share the result. This is sometimes called the singleton behaviour of modules, and it is a key part of how they work. It means you can rely on a module to set itself up once, and every importer will see the same setup, without the setup running three times and causing duplicate work or duplicate side effects.
This behaviour is useful for shared resources. Suppose a module creates a settings object that holds the site's currency and language, or a module opens a connection object that other parts of the app will use. Running that setup once means every part of the app shares the same settings and the same connection, which is usually what you want. If the module ran again for each importer, each part would get its own separate settings object, and changing a setting in one part would not affect the others. The single-run rule keeps shared state genuinely shared, which is the whole point of having a shared module in the first place.
It also means you should keep top-level code in a module light and predictable, a theme that returns in a later section. Because top-level code runs once, on first import, any expensive work or surprising side effect in that code will happen exactly once, but at a moment controlled by the import graph, not by a function call you chose. If a module's top-level code writes to the page, starts a timer, or fetches data, that work will happen as soon as anything imports the module, which may be earlier than you intended. Better to define functions at the top level and let importers call them when they are ready, so the timing stays explicit.
A useful way to test your understanding is to imagine a module that logs a message at the top level. Import it from two files, open the page, and count the messages. You will see one, not two, because the browser caches the module after its first run and reuses it. That cache is per page load, so reloading the page runs the module again from scratch, which is correct: a fresh page is a fresh run. Within one page load, however, one module means one run, one set of top-level values, and one shared result for every importer. Design with that in mind, and shared modules become a reliable foundation rather than a source of surprises. If a shared module needs resetting between tests, provide a function that resets its state explicitly. That makes the reset visible and deliberate, instead of relying on reloading the whole page.
Check: If three files import the same module, how many times does that module's code run?
Section 18 — Top-level code runs on import
Top-level code is any code written directly in a module file, outside of any function. Definitions, such as function declarations and constant declarations, sit at the top level, but so does any executable statement, such as a calculation, a log message, or a call to a function. The rule to remember is that this executable top-level code runs when the module is first imported, not when an importer calls a function from the module. The import itself triggers the run. This is different from function code, which waits patiently until it is called, and the difference explains many beginner surprises.
Consider a module that defines a helper function and also, at the top level, writes a message to the console. The moment any file imports anything from that module, the message appears, even if the importing file never calls the helper. That is because the browser must run the module to know what it exports, and running it means running its top-level statements. The same applies to a module that calculates a value at the top level: the calculation happens on import, and the exported value is the result of that calculation. If the calculation depends on something that is not ready yet, such as an element that does not exist or data that has not arrived, the import-time calculation will fail or produce the wrong result, even though the function itself is perfectly fine.
The practical guidance follows directly. Keep top-level code to definitions and simple, safe initial values. Define functions, declare constants with values you already know, and create objects that do not depend on the page or on network data. Move anything that does work, touches the page, or fetches data into a function that the importer calls explicitly, at the moment when that work is actually wanted. This keeps the timing of your code in your hands, rather than handing it to the import graph. A module that only defines things on import is predictable. A module that acts on import is a module that can surprise you.
There is a related habit for entry modules. An entry module is meant to act: it selects elements, attaches listeners and starts the app. Its top-level code is therefore expected to run, because the entry module is loaded by the HTML, not imported by another module for its exports. The distinction is between a library module, which should define and wait, and an entry module, which should start. Keeping those roles clear makes the timing of your whole app easier to reason about, and it keeps import-time surprises out of the library modules that many files share. To keep timing clear, let library modules define and entry modules act. When those roles blur, import order starts to matter in ways that are hard to predict or explain.
Check: When does top-level code in a module run?
Section 19 — The import graph
When a module imports other modules, and those modules import further modules, the browser builds a structure called the import graph, or dependency graph. Picture it as a map: each module is a point, and each import is an arrow from the importer to the imported module. The entry module sits at the start, with arrows to everything it needs directly, and those modules have arrows to what they need, and so on, until you reach modules that import nothing. The browser follows this map before running any code, fetching every module in the graph and working out the order in which they must run.
The order matters, and the graph is how the browser gets it right. A module must run after the modules it depends on, because its top-level code might use values from those modules. The browser therefore sorts the graph so that dependencies come first, working from the leaves inward to the entry point. You do not need to perform this sorting yourself, and you should not try to control it with script tag order, as you would have with classic scripts. The graph is the source of truth, and it is built from the import statements themselves, which means the order is always consistent with what the code actually needs. If you change an import, the graph changes, and the order updates automatically.
A clear graph is also a debugging aid. When a page fails, you can trace the graph from the entry module outward, asking at each step whether the import path is correct and whether the exported name exists. Tools in the browser can show you the network requests for each module, in graph order, which helps you spot a missing file or a circular path. A tangled graph, by contrast, is a warning sign. If every module imports every other module, the graph tells you the project has no clear layers, and a change in one place can ripple everywhere. A healthy graph looks more like a tree or a set of layers: entry at the top, features in the middle, and small shared helpers at the bottom, imported by many but importing little themselves.
Thinking in graphs also helps you design. Before writing a new module, ask what it will import and what will import it. If the answer is "everything, both ways", the design needs rethinking. If the answer is a short, sensible list, the module will fit neatly into the graph. Over time, you will start to see your project as a graph first and a collection of files second, and that view makes large projects feel manageable, because the map shows you exactly how the pieces connect. Sketch the graph for a small project on paper before you code it. Drawing the arrows often reveals a module that depends on too much, or a layer that is missing, before either becomes a bug.
Check: What is the import graph?
Section 20 — Circular imports cause trouble
A circular import happens when module A imports module B, and module B, directly or through a chain of other modules, imports module A back again. The import graph, which is normally a clean flow from entry to helpers, now contains a loop. The browser can handle simple circular imports without crashing, because it is designed to cope with them, but the values involved may not be ready when code runs. That unreadiness is the trouble, and it produces bugs that are confusing precisely because each file looks correct on its own.
To understand why, imagine module A defines a constant and module B imports it, while module B also defines a constant that module A imports. When the browser starts running this loop, it must pick a starting point, and whichever module runs first will try to use a value from the other module before that other module has finished running. The result can be an undefined value where a real value was expected, or a function that works sometimes and fails at other times depending on which import chain the browser followed first. The bug is timing-dependent, which makes it hard to reproduce and harder to explain to a teammate. The code is not wrong in the usual sense; the structure is wrong.
The fix is structural, not a clever workaround. Find the piece that both modules need and move it into a third module, which both A and B import. Now the graph has no loop: A and B both point at the shared module, and the shared module points at nothing in the loop. The shared piece might be a constant, a helper function, or a small data object. Whatever it is, extracting it breaks the circle and restores a clean flow, where dependencies run before the modules that use them. This is the same idea as moving a shared constant out of two files that both needed it, applied at the level of the graph.
Spotting circles early is a skill worth practising. If you are writing an import and realise the target module already imports the file you are writing, pause. That is a circle forming. Ask whether the shared piece can live elsewhere, or whether one of the two modules should be split. A project with no circular imports is easier to load, easier to test, and easier to reason about, because every module's dependencies are settled before it runs. When a circle does slip in, treat it as a design signal, not as a puzzle to patch with ordering tricks. Break the circle, and the confusing bug usually disappears with it. If a circle cannot be broken by extraction, consider whether the two modules should actually be one module. Sometimes a circle is a sign that a supposed boundary between files was never real.
Check: How do you break a circular import?
Section 21 — Why modules need a local server
One of the most common beginner experiences with modules goes like this: you write clean module code, double-click the HTML file to open it, and nothing works. The console shows an error about a module being blocked, or about a failed import, even though the files are all present and the paths look right. The cause is not your code. It is the way you opened the page. When you double-click a file, the browser opens it with a file:// address, meaning it is reading directly from your hard drive, not from a web server. For security reasons, browsers block module imports from file:// addresses, because a page read from disk should not be able to freely read other files from disk through imports.
This rule exists to protect users. If any local file could import any other local file without restriction, a malicious page saved to your computer could potentially read sensitive files by importing them as modules. Browsers therefore treat file:// pages with caution, and module loading is one of the things they restrict. Classic scripts do not face the same restriction in the same way, which is why a page with classic scripts might work from disk while the same page rewritten with modules fails. The difference is not a bug in modules; it is a security boundary doing its job, and the solution is to serve the page the way a real website is served, through a server, even a tiny local one.
A local server changes the address from file:// to a local web address, and the browser then treats the page as a normal web page, fetching its modules over the standard web protocol. The modules still come from your own computer, so nothing is published to the internet, but the browser's security model now applies the normal web rules instead of the stricter local-file rules. That is the whole trick: same files, same code, different address, and the security model that blocks the import no longer applies. This is also why professional developers always run a local server during development, even for simple pages. It makes the development environment match the production environment, where the site will always be served by a server.
If you remember only one practical rule from this section, make it this: never judge module code by opening the HTML file directly. Always open it through a local server, and if you see a module error, check the address bar first. If it starts with file://, that is almost certainly the problem, and starting a server will fix it. The next section explains what a local server is and how to think about it, without assuming any particular tool, so you can use whichever simple server fits your setup.
Check: Why does a module page often fail when opened straight from your files?
Section 22 — What a local development server does
A local development server is a small program that runs on your own computer and serves your project files over a local web address, just as a real server serves a website over the internet. When you start it and point it at your project folder, it listens for requests from your browser, finds the requested file in that folder, and sends it back. To the browser, the experience is identical to visiting a website, except that the website lives on your own machine and only you can reach it. Nothing is published, nothing leaves your computer unless you deliberately deploy it, and you can stop the server at any time.
The main benefit for module work is the one explained in the previous section: serving over a web address removes the local-file restrictions that block module imports. But a development server does more than that. It also lets you test the page as it will really behave, including relative paths, fetched data files, and any server-like behaviour your page relies on. A page that works through a server and fails from disk is not broken; it is simply a web page, and web pages are meant to be served. Testing through a server therefore gives you a more honest picture of whether the page is ready, and it catches path and loading problems early, while they are still cheap to fix.
Development servers often add conveniences that make daily work smoother. Many of them watch your files and refresh the browser automatically when you save, so you do not need to press refresh after every change. Some serve the site with helpful error pages when a file is missing, naming the file that could not be found. Others handle routing, so that a page address works even when the file structure is more complex. You do not need to understand all of these features to start. The core job is always the same: serve the folder, on a local address, so the browser can fetch files normally. Start with that, and explore the extras only when they solve a problem you actually have.
Choosing a server is less important than using one consistently. Your code editor may include a simple server extension, your programming language may include a built-in way to serve a folder, or a small standalone tool may do the job. Any of these is fine for learning, provided it serves your project folder and you open the page through the local address it provides, not by double-clicking the file. Make starting the server part of your routine, like opening the editor itself, and module development will feel straightforward rather than mysterious. The server is not an advanced extra; it is the normal way to develop for the web.
Check: What does a local development server do that opening a file directly does not?
Section 23 — Dynamic imports load code on demand
A normal import loads a module at the start, before the importing file runs. That is the right choice for code the page always needs, such as formatting helpers or form validation that runs on every visit. But some code is only needed sometimes. A chart library might only be needed when a user clicks a button to show a chart. A large editing tool might only be needed when a user enters edit mode. Loading all of that code on every visit, for every user, wastes time and bandwidth for people who never use the feature. Dynamic imports solve this by loading a module later, only when you ask for it, at the moment it is actually needed.
The syntax looks like a function call: import("./chart.js"). Despite looking like a function, it is a special language feature that tells the browser to fetch that module now, rather than at the start. You typically use it inside an event handler or inside a function that runs when a condition is met. For example, a button's click handler might dynamically import a chart module, wait for it to load, and then call its drawing function. Users who never click the button never download the chart code at all, and the initial page load stays light and fast. Users who do click wait a brief moment while the module loads, which is a fair trade for a feature they have explicitly requested.
This on-demand pattern changes how you think about page weight. Instead of asking "what does the page need?", you can ask "what does the page need right away, and what can wait until it is requested?" Code that is needed immediately stays as a normal import at the top of the file. Code that is needed rarely, or only after an interaction, becomes a dynamic import inside the relevant handler. That split is sometimes called lazy loading, and it is one of the simplest performance improvements you can make, because it directly reduces what the browser must fetch before the page becomes usable. It also keeps the initial import graph smaller and easier to understand.
There are limits to keep in mind. A dynamic import still needs a correct path, including the extension, and it still needs a server, just like a normal import. The module it loads must also be available at the path you give, which means the file must be part of what the server can serve. If the module fails to load, the import will fail, and your code should handle that possibility gracefully, rather than leaving the user staring at a broken button. With those cautions in mind, dynamic imports are a powerful and simple tool: load what you need, when you need it, and let the rest wait until it earns its download.
Check: When would you choose a dynamic import instead of a normal import?
Section 24 — Dynamic imports return a promise
Because loading a file takes time, a dynamic import cannot return the module immediately. The browser must fetch the file over the network, even from a fast local server, and fetching is never instant. JavaScript therefore represents a dynamic import as a promise: an object that stands for a value that will arrive in the future. The promise starts in a pending state, and when the module has loaded successfully, it resolves to the module's namespace object, which holds all of its exports. If the load fails, the promise rejects, and your code can catch that failure and respond to it.
The most readable way to work with this promise is with await, inside a function that is allowed to wait. A typical pattern looks like this:
async function showChart() {
const chartModule = await import("./chart.js");
chartModule.drawChart();
}
The await pauses the function until the module arrives, then continues with the module in hand. The rest of the page is not frozen while it waits; only this function pauses, which is the point of promises and waiting in JavaScript. If you cannot use await in a particular spot, you can use the promise's handler methods instead, running one piece of code when the module arrives and another if it fails. Both styles express the same idea: do this when the module is ready, not before.
Understanding the namespace object matters here. The value that await import(...) gives you is not a single function; it is an object containing all the module's exports. Named exports appear as properties with their export names, and a default export appears as a property called default. So if the chart module default-exports its drawing function, you would call chartModule.default(), while a named export would be called by its name, such as chartModule.drawChart(). Mixing these up is a common beginner error, and the fix is to check how the module exports its main function and to match your call to that style.
Error handling completes the pattern. A dynamic import can fail if the path is wrong, the file is missing, or the network fails, and a failed promise that nobody handles will surface as an unhandled error in the console. Wrap the import in a way that catches failures and shows the user a helpful message, or falls back to a simpler behaviour, rather than leaving a dead button. Dynamic imports are powerful precisely because they load on demand, but on-demand loading means on-demand failure is also possible, and good code plans for both outcomes. Practice by adding a fallback message for a failed dynamic import. A user who sees a clear message and a working alternative has a far better experience than a user facing a silent, dead button.
Check: Why does dynamic import need await or a promise handler?
Section 25 — Only import what you use
It is tempting, when a module offers many helpers, to import the whole thing just in case you need something from it later. In a plain browser setup, that temptation has a real cost. Importing a module loads the whole file, even if you only use one small function from it. If the file is large, the browser downloads and runs all of it, including code your page never calls, and that work slows the initial load. On a fast connection with a small file, the cost is invisible. On a slower connection, or with a large library, it becomes the difference between a page that feels instant and a page that feels sluggish.
The cost is not only about download size. Every import also adds a node to the import graph, and a large graph is harder to understand and harder to debug. When a file imports a whole toolbox, a reader cannot tell which tools the file actually uses without searching through its code. When a file imports only the two functions it needs, the import line itself answers that question. That clarity helps during reviews, during debugging, and during refactoring, when you need to know whether removing a function from a toolbox would break anything. A precise import line is a small piece of documentation that pays for itself repeatedly.
There is also a future benefit, even if you are not using build tools yet. Tools that optimise a project for production can often remove unused code from a bundle, but only if they can see clearly which exports are used and which are not. Precise imports make that analysis possible. Vague imports that bring in everything make it harder, because the tool cannot be sure whether a piece of code is truly unused or merely used indirectly. You do not need to understand those tools yet to benefit from the habit. Writing precise imports now means your code is already in good shape for optimisation later, without any extra work.
The habit itself is simple. Before importing, ask what this file actually needs from the other module, and import only those names. If you later need another helper, add it to the import line at that moment. That small discipline keeps files light, graphs honest and intentions clear. It also encourages better module design, because a module whose helpers are imported one by one, for specific jobs, is easier to keep focused than a module that is always swallowed whole. Import what you use, use what you import, and let the import lines tell the truth about each file's dependencies. Review your import lines at the end of a task, just as you review your code. Removing an import you no longer use keeps the file honest about what it really depends on.
Check: Why should you not import a whole toolbox file for one small function?
Section 26 — Bare imports need a tool
A bare import is an import that names a library without giving a path. It looks like import { render } from "my-library";, with just a name in quotes, no ./, no ../, and no file extension. This style is common in code written for build tools, because it reads cleanly and it does not tie the code to where a particular library happens to be stored on disk. The problem is that a plain browser, on its own, does not know what to do with a bare name. It knows how to fetch a path, but it has no built-in catalogue that maps library names to files, so it cannot turn "my-library" into a file to download.
Why does the browser not simply look in a standard folder? Because there is no standard folder that all projects agree on, and the web has no built-in package registry that browsers consult. A library might be stored in a folder of downloaded packages, in a shared folder, or served from a content network, and the browser cannot guess which. Guessing would also be a security risk, because a page should not be able to probe a user's file system for libraries by name. The browser therefore treats a bare import as an error, unless something else steps in to translate the name into a real path before the browser tries to fetch it.
That something else is usually a build tool or an import map, both covered in this chapter and the next. A build tool reads your code before the browser sees it, finds the bare import, locates the library in your project, and rewrites the import to point at a real file, or bundles the library's code directly into your output. An import map does a similar translation in the browser itself, using a mapping you write in the HTML. In both cases, the bare name is a convenience for the author, and a translation step makes it work in practice. Without that step, the convenience is only an illusion, and the page will fail.
For a beginner, the lesson is to recognise bare imports on sight and to know what they imply. If you copy example code that uses a bare import and paste it into a plain browser project, it will not work, and the failure is not a sign that you misunderstood imports in general. It is a sign that the example assumed a tool you are not using yet. Either add the tool, add an import map, or rewrite the import as a relative path to a real file you have downloaded. Knowing which of those three paths to take, and why, is the difference between being stuck on an error message and solving it in a minute.
Check: Why does a bare import fail in a plain browser with no tooling?
Section 27 — What an import map does
An import map is a small block of configuration in your HTML that tells the browser how to translate bare import names into real file paths. It is written as a script tag with a special type, containing a mapping from names to addresses. For example, it might map the name "my-library" to a path like "./libs/my-library.js". Once that map is in place, a module that writes import { render } from "my-library"; will work in the browser, because the browser consults the map, finds the real path, and fetches that file instead of trying to fetch a file literally called my-library.
Import maps solve a specific problem: they let you write clean, readable bare imports without running a build step. For small projects, or for learning, that can be a pleasant middle ground. You get the readability of library names, without the complexity of a build tool, and you keep the browser's native module loading, which is easy to inspect in the network tools. You can also map a name to a longer path, or to a file served from elsewhere, without changing every import in your code. Change the map in one place, and every import using that name follows the new address.
There are cautions to keep in mind. An import map must appear before any module that uses it, because the browser needs the map before it starts resolving imports. If the map loads too late, the imports will fail, even though the map itself is correct. The map must also be kept up to date. If a library file moves, or you upgrade to a version stored under a different file name, the map must be updated to match, or imports will point at a file that no longer exists. The map is another piece of configuration to maintain, and like all configuration, it can drift out of step with the files it describes if nobody owns it.
Import maps also do not solve every problem that a build tool solves. They do not bundle files, optimise code, or transform code written in a newer style into code older browsers can run. They only translate names to paths. That limitation is also a strength, because it keeps the mechanism simple and transparent. For a project that needs only name translation, an import map is enough. For a project that needs bundling, optimisation or transformation, a build tool is the right next step, and the next chapter explains those tools in conceptual terms. Choose the simpler tool that solves the problem you actually have. Keep the map near the top of the HTML, where a maintainer will find it, and add a comment naming each library it maps. A map without comments quickly becomes a mystery to the next reader.
Check: What problem does an import map solve?
Section 28 — Organising modules by job
When a project has only a few modules, organising them is easy. When it has dozens, organisation becomes a design decision that affects every developer who touches the project. One common approach is to organise by file type: all display code in one folder, all data code in another, all helpers in a third. That looks tidy at first, but it scatters a single feature across many folders. To understand a shopping basket feature, you must open the display folder, then the data folder, then the helpers folder, and hold all three in your head at once. The feature itself has no home; it exists only as a set of pieces spread across the project.
Organising by feature, or by job, takes the opposite approach. A feature folder holds everything that feature needs: its display code, its data code, its helpers and its tests, together in one place. A reader who wants to understand the basket opens the basket folder and finds the whole story there. A developer who needs to change the basket knows where to look and, just as importantly, knows where not to look. Changes stay local, reviews stay focused, and removing a feature becomes a matter of removing its folder, rather than hunting for its pieces across the project. The folder structure tells the truth about how the app is built, because it mirrors the user's experience of features, not the programmer's classification of file types.
This does not mean you should never have shared folders. Some helpers really are shared by many features: a date formatter, a currency formatter, or a small helper for fetching data. Those belong in a shared folder, clearly marked as shared, and imported by features that need them. The distinction is between code that belongs to one feature and code that genuinely serves many. If a helper is used by only one feature, it lives with that feature, even if it looks like a general helper in isolation. If it is used by five features, it earns a place in the shared folder. That rule keeps the shared folder small and honest, rather than letting it become a dumping ground for code nobody wanted to place properly.
As a project grows, feature folders can themselves contain subfolders, and shared folders can split by topic, but the principle stays the same. Group by what the code does for the user, not by what kind of file it is. A new developer should be able to guess where a feature lives from its name, open that folder, and understand the feature without a tour. When organisation follows the job, the project explains itself, and that self-explanation is one of the quietest and most valuable forms of documentation a front-end project can have.
Check: Why organise by feature rather than only by file type?
Section 29 — Keep modules small and single-purpose
A good module does one job well. A price module formats prices. A validation module checks input. A menu module opens and closes a menu. Each of those sentences is short, and that shortness is the test. If you cannot describe a module's job in one short sentence without using the word "and" to join unrelated jobs, the module is probably doing too much. It might be formatting prices and fetching data and updating the page, all in one file. Each of those jobs is reasonable on its own, but together they make a module that is hard to name, hard to test and hard to reuse, because using one job means dragging the other two along with it.
The warning signs of an overgrown module are easy to spot once you know them. The file keeps getting longer, and scrolling through it feels like travelling. Its name becomes vague, because no single word covers everything it does, so it ends up called something like helpers or main-logic. Its imports grow, because it needs many different things to do its many jobs, and its exports grow too, because other files need different pieces of it for different reasons. When you change one part, you worry about breaking another part that seems unrelated, because the parts share variables and state in ways that are not obvious. Those worries are data. They are telling you the module wants to be split.
Splitting is usually straightforward. Identify the distinct jobs, give each job its own file with a clear name, and let the original file become either one of those jobs or a thin entry point that imports the others. The price calculation moves to a price module, the data fetching moves to a data module, and the page updating moves to a display module. Each new module is shorter, its imports are fewer, and its purpose is obvious from its name. Testing also becomes easier, because you can test the price calculation without setting up the page, and test the display without needing real data. Small modules are not a style preference; they are a practical way to keep complexity manageable as a project grows.
There is a limit, of course. Splitting every function into its own file creates a different kind of mess, where understanding a feature means opening twenty tiny files. The goal is not the smallest possible file; it is the clearest possible job. A module should be large enough to contain a coherent idea, and small enough to hold in your head at once. When you reach that balance, the module feels obvious, and that obviousness is the reward for the discipline of keeping modules focused. A practical test is to explain a module to a teammate in one sentence. If you need several sentences and several asides, the module has probably outgrown its original job.
Check: What is the warning sign that a module has grown too big?
Section 30 — Side-effect imports
Most imports bring in a value you will use: a function you will call, a constant you will read, or a class you will create. A side-effect import is different. It imports a file only for what that file does when it loads, not for any value it exports. The syntax has no braces and no local name: import "./setup.js";. When the browser loads the importing file, it also loads and runs the setup file, and whatever top-level code that file contains takes effect. Perhaps it sets a global style, registers a handler, or configures a library. The importing file never refers to the setup file again, because it imported no value from it.
This style is sometimes necessary, and it is worth understanding why it exists. Some libraries or setup steps are designed to run once and affect the whole page, rather than to provide a value for one file to use. A file that applies a set of default settings, or that registers a custom element, fits that shape. Importing it for its side effect is a concise way to say "make sure this setup has run before the rest of this file runs". The import graph guarantees the order, so the setup runs first, and the rest of the code can rely on it having happened. Used deliberately, for genuine setup, side-effect imports are a legitimate tool.
The problem is readability. When you read an import line that names a value, you immediately know what the importing file depends on and how it will use it. When you read a side-effect import, you know only that a file will run, not what it will change. To find out, you must open the side-effect file and read its top-level code, and that code might change the page, set globals, or start timers in ways that are not obvious from the import line. A reader who misses the side effect will misunderstand the file, and a debugger who does not know about it will be confused about why the page behaves a certain way before any function is called.
For those reasons, use side-effect imports sparingly, and prefer an explicit alternative where you can. If a setup file exports a function called setup(), importing that function and calling it explicitly makes the dependency visible: the file says, in its own code, "I set this up, here, now". That explicit call is easier to search for, easier to test, and easier to remove later. Reserve bare side-effect imports for cases where a library or a convention genuinely requires them, and document them with a comment explaining what the imported file does. A reader should never have to guess what an import changes.
Check: Why are side-effect imports harder to understand than normal imports?
Section 31 — What a package manager is
A package manager is a tool that finds, downloads and keeps track of libraries your project uses, so you do not have to copy library files by hand. A library, in this context, is code written by someone else that solves a common problem: formatting dates, drawing charts, validating input, or handling a particular kind of interaction. Before package managers, using a library meant visiting a website, downloading a file, placing it in your project, and remembering to check for updates yourself. That manual process worked for one library, but it became error-prone as projects used more libraries, each with its own updates and its own dependencies on other libraries.
A package manager automates the boring and error-prone parts. You tell it which library you want, and it downloads the right files, places them in the managed folder for your project, and records the choice in your project's package file. If that library itself depends on other libraries, the package manager downloads those too, working out a set that fits together. When you want to update a library, you ask the package manager, and it replaces the files and updates the record. When another developer joins the project, they do not need your downloaded files at all; they run the package manager's install command, and it recreates the same set of libraries from the record. That reproducibility is the package manager's most valuable gift.
It is important to understand what a package manager does not do. It does not write your code, choose your libraries for you, or guarantee that a library is good. It will happily download a library that is poorly written, out of date, or unsuitable for your project, if you ask for it. Choosing libraries remains a human judgement, based on documentation, maintenance, size, and whether the library actually solves your problem without adding new ones. The package manager makes the mechanics of using a library easy, so that your judgement, not your file management, becomes the limiting factor.
For a beginner, the practical starting point is small. Learn how to add one library, how to see what is installed, and how to remove a library you no longer need. Learn where the record lives and what it contains, which is the subject of the next section. Do not feel pressure to understand every feature of a package manager at once. Most daily use is just those three actions: add, list and remove, plus installing a project's recorded libraries when you first clone it. Master those, and the package manager becomes a quiet helper that stays out of your way. Before adding a library, check its documentation and its maintenance history. A package manager will install whatever you ask for, so the decision to ask is where the real judgement lies.
Check: What job does a package manager do that copying files by hand does not?
Section 32 — What package.json is for
A file called package.json sits at the root of a project and describes the project in a structured way that both humans and tools can read. It is written in a plain data format, not in JavaScript code, and it contains fields with names and values, such as the project's name, a short description, and the libraries it depends on. When a tool needs to know something about your project, this is often the first file it reads. When a new developer joins, this is one of the first files they should open, because it summarises the project's shape in a single place.
Three kinds of information matter most for a beginner. First, the identity of the project: its name and, usually, a version label and a description that say what the project is. Second, the scripts section, which gives short names to commands you run often, such as starting a development server or running tests. Third, and most importantly for daily work, the dependencies: the lists of libraries the project uses, split into libraries needed to run the site and tools needed only while developing it. Those lists are what the package manager uses to recreate the project's library set on another computer. Without them, the downloaded library folder is just a pile of files with no record of why each one is there.
The package file is also a contract of sorts. If a library is listed, the project depends on it, and removing the library from the project should mean removing it from the list as well. If a library is used in code but not listed, the project will work on your computer, where the library happens to be downloaded, and fail on a teammate's computer, where it is not. That kind of failure is confusing, because the code looks identical in both places. Keeping the package file honest, by adding libraries through the package manager rather than by hand-copying files, prevents it. The package manager updates the record when it installs, so the record and the reality stay in step.
You should read a package file before you edit it, and edit it through the package manager's commands where possible, rather than typing directly into it by hand. Hand editing is possible, and sometimes necessary, but a typo in this file can break every tool that reads it, because the data format is strict about punctuation. Treat the package file as the project's manifest: a short, accurate, maintained description of what the project is, what it needs, and how to run it. A tidy package file is a sign of a tidy project. When you join a project, read this file second, after the main page. It will tell you which tools to expect, which libraries matter, and which commands the team uses every day.
Check: What three kinds of information does package.json record?
Section 33 — Dependencies versus development dependencies
Not every library a project uses plays the same role. Some libraries are needed by the finished site itself: code that runs in the browser, formats data, or draws part of the page. Those are dependencies, in the strict sense. Other libraries and tools are needed only while you are building the site: a formatter that tidies code, a linter that checks it, a testing tool that runs checks, or a development server that serves it locally. Those are development dependencies, because they support development, not the live site. Keeping the two groups separate is not bureaucratic tidiness; it tells a clear story about what the live site actually relies on.
The distinction matters in practice when a project is built for production. A build process can often leave development tools out of the final output, because the browser never needs a formatter or a test runner. If development tools were mixed in with real dependencies, the build would have to guess which pieces are needed at runtime and which are not, and guessing wrong either bloats the output or breaks it. By recording the split explicitly in the package file, you give the build a reliable answer. The live site gets what it needs, and nothing it does not, which keeps it smaller and faster.
The distinction also matters for humans. A new developer opening the package file can see at a glance which libraries shape the user experience and which shape the developer experience. If a formatting tool is listed as a development dependency, nobody will worry about whether removing it breaks the site for visitors, because it never ran in the browser in the first place. If a chart library is listed as a regular dependency, everyone knows the site's charts depend on it, and removing it is a user-facing decision, not a tidying exercise. That clarity prevents both kinds of mistake: removing something the site needs, and keeping something the site does not need out of fear.
When you add a library, ask a simple question: will this code run in the browser for visitors, or will it only run on my computer while I develop? If it runs for visitors, it is a dependency. If it only helps you develop, test, format or build, it is a development dependency. Most package managers let you choose which list to add to, and choosing correctly takes a second. That second, repeated across a project, builds a package file that tells the truth about the project, and a truthful package file is easier to maintain, easier to audit, and easier to hand to the next developer. If you are unsure which list a new library belongs in, ask whether removing it would break the site for visitors. If the answer is yes, it is a dependency; if no, it belongs with the development tools.
Check: Is a code formatter a dependency or a development dependency? Why?
Section 34 — The folder you do not edit by hand
When a package manager downloads libraries for your project, it places them in a folder that it manages, conventionally a folder for downloaded packages inside the project. This folder is not source code you own. It is a cache of other people's code, recreated from the package file whenever the project is installed. You should read files in there when you need to understand how a library works, because reading library code is a good way to learn, but you should not edit those files to fix a bug or to change a behaviour. Any change you make there is temporary and misleading.
The reason is reproducibility. The next time anyone installs the project, or updates the library, the package manager will overwrite that folder with a fresh copy of the library, and your hand edit will vanish without warning. Worse, your project will work on your computer, where the edit exists, and fail on every other computer, where it does not. A teammate debugging the failure will look at the library's published code, see no sign of your edit, and be thoroughly confused, because the behaviour they see on your machine does not match the code they can read. Hand edits to managed code create a project that only works in one place, which is the opposite of what a package manager is for.
If a library has a bug, or lacks a feature you need, the honest paths are to work around it in your own code, to report it to the library's maintainers, or to choose a different library. Working around it in your own code means writing a small wrapper in your own files that adjusts the library's behaviour, where the workaround is visible, version-controlled and shared with the whole team. That wrapper is your code, in your files, and it will survive reinstalls. It also documents the problem: a future reader can see that the library did something unexpected and that your code compensated for it, rather than finding a mysterious edit in a file that should not have been touched.
The same rule applies to generated files in general, a theme that returns in later chapters. If a file is produced by a tool from a source you control, edit the source, not the output. The managed library folder is the clearest example of this rule, because the tool that produces it is the package manager, and the source is the package file. Respect that boundary, and your project stays reproducible. Cross it, and you trade a quick fix for a hidden difference between machines that will cost far more time later. Reading library code is different from editing it. Read freely to learn how a library works, but make your changes in your own files, where they will be kept, shared and reviewed.
Check: Why should you never hand-edit a downloaded library file?
Section 35 — Scripts in package.json
The scripts section of the package file gives short, memorable names to commands you run often. Instead of remembering and typing a long command to start a development server, with all its options and flags, you give that command a name such as dev, and then you run the short script name through the package manager. The package manager looks up the name in the scripts section, finds the full command, and runs it for you. The effect is the same as typing the long command, but the experience is simpler, more reliable and easier to share.
This simplicity matters most in a team. A new developer joining a project does not need to ask how to start the site, how to run the tests, or how to build it for production. They open the package file, read the scripts section, and see a list of the project's everyday commands, each with a plain name that describes what it does. A script called dev starts development. A script called test runs the checks. A script called build prepares the production output. Those names are a kind of documentation, and they are documentation that cannot drift out of date as easily as a separate guide, because if a script name is wrong, running it fails immediately and someone fixes it.
Scripts also reduce mistakes. A long command with several flags is easy to mistype, and a mistyped flag can change what the command does in a subtle way. A script, once written and tested, is typed the same way every time, because the package manager expands it for you. That consistency is especially valuable for commands that are run rarely but matter greatly, such as a production build. Nobody wants to reconstruct a complex build command from memory under pressure. A script removes the memory burden and replaces it with a single, tested name.
When you create scripts, keep the names short and honest, and keep the commands themselves as simple as they can be. A script that chains together many steps can become a puzzle of its own, and at that point a dedicated build configuration, covered in the next chapter, may be the better home for the complexity. For everyday development commands, however, scripts are the right tool: short names, in the package file, for the commands a project uses every day. Learn to read them first, and to add one when you find yourself typing the same long command for the third time. Add a script the third time you type a long command, not the first. By then you know the command works, and the script will save real time rather than preserving an untested guess. That shared, tested shortcut is worth far more than the few seconds it takes to write it once.
Check: Why give a long command a short script name?
Section 36 — What a linter does
A linter is a tool that reads your code without running it and flags likely mistakes, risky habits and style problems. It works by analysing the text of your code against a set of rules, each rule describing a pattern to watch for. Some rules catch outright errors, such as using a variable that was never declared, or calling a function with the wrong kind of argument. Others catch habits that often lead to errors, such as leaving a variable unused, shadowing a name from an outer scope, or writing a condition that can never be true. Still others enforce consistency, such as requiring a particular style for quotes or semicolons, although many teams now leave pure formatting to a formatter and let the linter focus on correctness.
The value of a linter is that it catches slips early, before the code runs and before a human reviewer needs to spend time on them. A missing declaration, a typo in a variable name, or a comparison that uses assignment instead of equality are all easy to make and hard to spot by eye, especially in a long file. A linter spots them in seconds, points at the exact line, and often explains the problem in plain language. That early feedback shortens the loop between writing a mistake and fixing it, which is one of the most effective ways to learn. You do not need to run the page, click through to the broken feature, and then hunt for the cause. The linter tells you while the code is still fresh in your mind.
Linters are configurable, and that configurability is both a strength and a responsibility. A team chooses a set of rules that reflects its values: strict about undeclared variables, relaxed about a particular style choice, or firm about avoiding certain risky functions. A beginner does not need to configure a linter from scratch. Starting with a sensible, widely used set of rules is better than inventing your own, because the widely used set has been tested against many projects and its messages are well explained. As you gain experience, you will form opinions about which rules help and which get in the way, and you can adjust the configuration to match, with reasons you can explain to a teammate.
It is worth remembering what a linter cannot do. It cannot tell whether your code does the right thing for the user, whether a price calculation is correct, or whether a page is easy to use. It checks patterns, not purposes. A file can pass every lint rule and still be wrong in its logic, just as a file can fail a lint rule and still work. The linter is a first line of defence, not a guarantee. Use it to catch slips and to keep a consistent standard, and let testing and human review catch the deeper questions about whether the code is correct and useful.
Check: What kind of problems does a linter catch?
Section 37 — What a formatter does
A formatter is a tool that rewrites your code's layout, spacing, line breaks and indentation to one consistent style, without changing what the code does. It does not change variable names, logic or behaviour. It only changes presentation: where lines break, how much they are indented, where spaces appear, and how long lines are allowed to be. You run it on your code, and it returns the same code, tidier. The difference between a formatter and a linter is worth holding clearly. A linter flags problems for you to fix. A formatter fixes layout for you, automatically, so you do not need to argue about it or do it by hand.
The main benefit is that it ends arguments about style. In a team, or even in a solo project over time, people have different habits for indentation, line length and spacing. Without a formatter, those habits show up as inconsistent code, and code reviews waste time on layout comments that have nothing to do with whether the code works. With a formatter, everyone writes in whatever style is comfortable, runs the formatter, and the result is uniform. Reviews can then focus on logic, naming and design, which are the things that actually affect users. The formatter removes a whole category of discussion by making it automatic.
A second benefit is cleaner version history. When code is formatted consistently, a change to a file shows up in the version history as exactly the lines that changed in logic, not as a mixture of logic changes and layout changes. A reviewer reading a diff can see the real change immediately, without wading through reindented lines that have nothing to do with the fix. That clarity matters most when hunting a bug through history, because a clean diff points at the cause, while a noisy diff hides it among formatting churn. Running the formatter before committing is therefore not just tidiness; it is a habit that makes future debugging easier.
Formatters are also a kindness to future readers, including future you. Consistently formatted code is easier to scan, because the eye learns where to look for structure: indentation shows nesting, line breaks show separate ideas, and spacing shows the boundaries between parts of an expression. You do not need to configure a formatter elaborately to start. Pick a widely used one, accept its defaults, and let it do its work. You can always adjust a setting later, but the value comes from consistency, not from any particular choice of indentation width. Format automatically, and spend your attention on what the code does. Run the formatter as part of saving or committing, so formatting never becomes a separate chore. When formatting is automatic, it stops being a discussion and becomes part of the workflow.
Check: How does a formatter make code reviews easier?
Section 38 — Reading a module error
When a module fails, the browser console is your first and best source of information, but only if you read its message properly. Module errors usually name the file that failed and describe the kind of failure, and those two pieces of information are enough to narrow the search dramatically. The most common kinds are a missing file, a wrong path, and a name that was never exported. Each has a different fix, and guessing between them wastes time. Reading the message first, slowly, and taking it literally, is the habit that separates a quick fix from a long hunt.
Start with the path. Open the importing file, find the import line, and walk the path step by step from the importing file's folder, as described in the section on paths. Does each folder in the path exist? Does the final file exist, with exactly that name and extension, including capital letters? If any step fails, the path is wrong, and fixing the path fixes the error. This check alone solves a large share of module failures, because paths are easy to mistype and easy to break when files move. Do not assume the path is right because it looks right. Walk it, folder by folder, until you either arrive at the file or find the step where the walk fails.
Next, check the exported name. If the error says that a name was not exported, or that an import cannot be found in a module, open the exporting file and search for that exact name. Is it spelled the same way, with the same capitals? Is it actually marked with export, or did you define it without exporting it? Is it a named export that you tried to import as a default, or vice versa? Name errors are often one letter off, and the eye skips over them, so copy the name from the export and paste it into the import, rather than retyping it from memory. That small discipline eliminates a whole class of invisible typos.
Finally, check the loading setup. Is the script tag marked as a module? Is the page served through a local server, rather than opened from disk? Are there any earlier errors in the console that might have stopped the import graph from building? Module errors sometimes cascade, where one failed import causes others to fail as well, so fixing the first error in the console often clears several that follow it. Work through errors in order, fix one at a time, and refresh after each fix. That patient, ordered approach will solve almost every module problem you meet as a beginner. Keep a personal checklist of these three checks near your workspace while learning. After a few fixes, the checklist will become habit, and module errors will stop feeling intimidating.
Check: A module import fails. What three things do you check first?
Section 39 — Exporting values, not just functions
Exports are not limited to functions. A module can export constants, objects, arrays and classes, using the same export keyword, and importers bring them in with the same import syntax. This matters because many shared pieces in a front-end project are not actions but values: a tax rate, a maximum length for a field, a list of allowed options, or a set of labels for a form. Exporting those values from one module, rather than typing them in every file that needs them, keeps the project consistent and makes changes safe.
Consider a tax rate. If the rate is typed as a number in five different files, changing the rate means finding all five places and updating each one, and missing one creates a bug where different parts of the page use different rates. If instead one module exports the rate as a named constant, such as export const TAX_RATE = 0.2;, and every other file imports it, changing the rate means changing one line in one file. Every importer automatically sees the new value, because they all share the same export. The same logic applies to any value that appears in more than one place: a currency symbol, a page size for a list, a threshold for a discount, or a message shown to users. One source of truth beats many copies.
Exporting objects and arrays follows the same pattern, with a caution that returns in the next section. An exported object is shared, so every importer sees the same object. That is usually what you want for configuration, such as a settings object that holds the site's language and currency. It also means that if one importer changes the object, other importers see the change, which may or may not be intended. For simple constants, such as numbers and strings, this is not a concern, because those values cannot be changed in place. For objects, it is worth thinking about whether the object should be treated as read-only configuration or as shared state, and documenting that choice.
A good habit is to gather a project's shared constants into a small number of focused modules, rather than scattering them across feature files. A module for shop settings, a module for form limits, or a module for display labels, each exporting a handful of related constants, is easy to find and easy to update. When a value changes, you know where to go, and when a new developer joins, they can see the project's key numbers in one place. Functions do the work, but values shape the work, and exporting values well is part of designing a module's public face. When a constant changes, update it in its home module and check the pages that use it. One source of truth only helps if everyone actually imports from that source, rather than keeping private copies.
Check: Why export a shared constant instead of typing the number in every file?
Section 40 — Mutable exported values need care
A module's exports are shared. When several files import the same module, they all receive the same exported values, not separate copies. For numbers and strings, this sharing is harmless, because those values cannot be changed in place. For objects and arrays, sharing has a consequence that deserves care: if one importer changes the object, every other importer sees the changed object, because they all hold a reference to the same thing. The change is not local to the file that made it. It is visible everywhere the object is used, immediately and silently.
This can be useful, and it is the basis for shared state in small apps. A settings object exported from one module can be updated in one place, such as when a user changes a preference, and every part of the page that reads the settings will see the new preference without needing to be told. That is a legitimate pattern, and it works precisely because modules are shared. The danger arises when the sharing is accidental rather than deliberate. A file that sorts an exported array in place, for example, changes the order for every other file that uses that array, even though the sorting file only meant to display the items in a different order locally. A file that adds a property to a shared object for its own convenience pollutes the object for everyone else. Those bugs are hard to trace, because the file that caused the problem looks innocent on its own, and the file that shows the symptom did nothing wrong.
The safest habit is to treat exported objects as read-only unless you have deliberately designed them as shared state. If you need a changed version of an exported array, create a copy first and change the copy, leaving the original untouched. If you need a changed version of an exported object, create a new object based on it, rather than editing it in place. Functions that return fresh values, rather than editing shared ones, are easier to reason about, because their effect is contained in their return value, not spread across the app through a shared reference. When shared state is genuinely needed, give it a clear home, document how it should be changed, and provide functions for changing it, rather than letting any file edit it directly.
This section is not a warning against exporting objects. It is a warning against editing them casually. Shared values are powerful, and power needs a clear rule. The rule is simple: read shared exports freely, change them only through a deliberate, documented path, and prefer fresh copies for local changes. Follow that rule, and the sharing behaviour of modules becomes a reliable foundation. Ignore it, and the same behaviour becomes a source of bugs that appear far from their cause.
Check: Why can a shared, changeable export surprise you?
Section 41 — Testing a module in isolation
Testing a module in isolation means checking it on its own, apart from the rest of the app, to see whether it does what it promises. Because a module has clear inputs and clear exports, this kind of test is straightforward. You import the module into a small test page or a small test file, call its function with a known value, and compare the result with the value you expect. If the function formats a price, you give it a known number and check that the string it returns matches the format you designed. If the function validates input, you give it valid and invalid examples and check that it accepts the first and rejects the second, with the right messages.
The benefit of isolation is diagnosis. If a module passes its isolated test but fails in the app, the problem is likely in how the app uses the module, not in the module itself. Perhaps the app passes the wrong kind of value, calls the function at the wrong time, or misunderstands what the function returns. If the module fails its isolated test, the problem is inside the module, and you can fix it without worrying about the rest of the app. That narrowing of the search, from "something in the app is wrong" to "this module, with this input, gives this wrong output", saves a great deal of time, especially as apps grow larger and interactions become more complex.
Isolation also encourages better module design. A module that is hard to test in isolation is often a module that is doing too much, or that depends on too many outside things. If testing a price function requires setting up the whole page, the function is probably tangled with display code that should live elsewhere. If testing a validation function requires a network connection, the function is probably mixing validation with fetching, and splitting those jobs would make both easier to test. The test, in other words, is not just a check. It is feedback on the design. A module that is easy to test is usually a module that is well designed, and a module that resists testing is telling you something about its structure.
You do not need a testing framework to start. A small page that imports a module and logs a few results to the console is a valid test, and it teaches the habit of checking behaviour with known inputs. As you grow, you may adopt a framework that runs many such checks automatically and reports which passed and which failed. The tool changes, but the idea stays the same: a module promises something, and a test checks that promise with a concrete example. Build that habit for the small modules in this chapter, and it will scale naturally to the larger features you build later.
Check: Why are small modules easier to test than one huge script?
Section 42 — Naming modules clearly
A module's file name is the first thing a developer sees, and it should tell them what to expect before they open the file. A name like validate-form.js succeeds at this: it names the job, it names the thing the job acts on, and it leaves little room for doubt. A name like stuff.js or utils.js fails at it: it could contain anything, and a reader must open the file and read it to find out. That extra step, repeated across a project, adds up to real time and real confusion, especially for a new developer who does not yet know where anything lives.
Clear names also make searching possible. When you need to find the validation code, you can search for files with validate in the name, or search the project's imports for that name, and you will find it quickly. When the code lives in a file called utils.js, searching for validation finds nothing, because the file name does not mention validation at all. You must remember, or be told, that validation lives in the utilities file, which is knowledge that lives in people's heads rather than in the project itself. Knowledge that lives only in heads is fragile. It leaves when people leave, and it is invisible to new joiners. File names that describe jobs turn that hidden knowledge into visible structure.
Good module names share a few qualities. They are specific, naming the job and its subject, rather than the category of code. They use plain words, in the same style across the project, so that format-price.js, validate-form.js and fetch-products.js feel like a family, not a collection of accidents. They avoid abbreviations that only the original author understands, because a name that needs explaining has failed at its main job. They also avoid names that promise too much. A file called shop.js that only formats prices is misleading, just as a file called format-price.js that also fetches data is misleading. The name should match the job, and the job should match the name.
When you create a module, take a moment to name it well, and when you rename a module's job, rename the file to match. Renaming a file means updating its imports, which is a small cost, but it is a cost worth paying for a name that tells the truth. A project full of honestly named modules is a project that explains itself, and a project that explains itself is a project that is pleasant to work in, for you today and for anyone who joins tomorrow. If a file's name no longer matches its job after a refactor, rename it in the same change. A stale name misleads every future reader, and the cost of updating imports is small by comparison.
Check: Why is validate-form.js a better name than stuff.js?
Section 43 — One entry point for the app
Most module projects have one main file, often called main.js, that serves as the entry point for the app. This file is the one the HTML loads with its module script tag, and it is the root of the import graph. From this file, everything else the page needs is imported, directly or indirectly, through a chain of imports. The entry file's job is not to contain all the logic. Its job is to start the app: to import the pieces it needs, to select the elements on the page, to attach event listeners, and to call the functions that set the initial state. It is the conductor, not the whole orchestra.
Having a single entry point makes the app's startup easy to understand. A new developer can open main.js and see, in order, what happens when the page loads. First the imports, which list the app's direct dependencies. Then the setup, which shows how those dependencies are wired to the page. Then any initial calls, which show what the app does first. If startup were scattered across several script tags or several entry files, that story would be fragmented, and understanding the app would require piecing together fragments from different places. A single entry point tells the startup story in one place, in order, which is exactly how a reader wants to encounter it.
The entry point also gives you a single place to control what runs and what does not. If a feature is temporarily disabled, you can comment out its import and its setup in the entry file, and the feature's code will not be loaded at all, because nothing in the graph imports it anymore. If a feature is added, you add its import and setup to the entry file, and the graph grows to include it. That central control is valuable during development, when features are added, removed and rearranged frequently. It also makes the import graph itself easier to reason about, because every module is reachable from the entry point, or it is not part of the app at all.
Keep the entry file focused on startup. If it grows long, that is a sign that logic belonging in feature modules has crept into it. Move that logic out, into the module that owns the feature, and leave the entry file as a short, readable wiring diagram. A good entry file is one you can read in a minute and understand the whole app's startup. That readability is the reward for keeping the entry point single, short and focused on starting, not doing, the app's work. Resist the temptation to put feature logic in the entry file just because it is convenient. Convenience now creates a long, tangled entry file later, when several features have all taken the same shortcut.
Check: What job does a main entry file do?
Section 44 — Keeping secrets out of front-end modules
Anything shipped to the browser can be read by anyone who opens the page. This is a fundamental fact of front-end development, and it applies to modules just as it applies to any other front-end code. When a browser loads your site, it downloads your HTML, CSS and JavaScript files, including every module, and a curious visitor can open the browser's developer tools, read those files, and search them for interesting values. Obfuscation, which scrambles code to make it harder to read, does not change this fact. It makes reading slower, but it does not make it impossible, and it should never be treated as protection for a secret.
For that reason, passwords, private keys, secret tokens and other credentials must never appear in a front-end module, or in any front-end file at all. A token that allows access to a paid service, a key that identifies your account to a service, or a password for any system, if placed in browser code, is effectively published to the world. Anyone who finds it can use it as if they were you, and you may not notice until the damage is done. This is not a theoretical risk. It is one of the most common and most costly mistakes in front-end work, and it happens most often when a developer copies a working example that includes a key, or pastes a key into code to make something work quickly during development, intending to remove it later and then forgetting.
The correct home for a secret is on a server that you control, where it never leaves the server's memory or its protected configuration. The browser asks the server to do the sensitive job, the server uses the secret to do it, and the browser receives only the result, not the secret itself. For example, if a page needs data from a service that requires a key, the browser should ask your server, your server should call the service with the key, and your server should return the data to the browser. The browser never sees the key, and the key never appears in any file the browser downloads. Designing this server step takes more work than pasting a key into a module, but it is the only design that actually keeps the secret secret.
During development, the same rule applies. Use test keys, test accounts and local data where possible, and keep any real credential in server-side configuration that is never committed to version control and never bundled into front-end output. If you ever discover a secret in front-end code, treat it as exposed: revoke it, replace it, and then remove it from the code. Removing it from the code alone is not enough, because the old version may already have been downloaded, cached or copied. Secrets belong on servers. Browser modules belong to everyone who visits the page.
Check: Why must a secret token never appear in a front-end module?
Section 45 — Tooling is help, not magic
Tools exist to remove boring, error-prone work from development. A package manager removes the work of downloading and tracking libraries. A linter removes the work of spotting common slips. A formatter removes the work of tidying layout. A development server removes the work of serving files correctly. Each of these tools does a specific, understandable job, and each saves time that can be spent on the real work of designing and building features. Used well, tools make development faster, more consistent and less frustrating, and they let a small team achieve what would otherwise require constant manual care.
What tools cannot do is think for you. A package manager cannot choose the right library for your problem. A linter cannot tell whether your logic is correct for your users. A formatter cannot make a confusing design clear. A development server cannot fix a broken import path, although it can make the error easier to see. Tools amplify the judgement of the developer using them; they do not replace it. A developer who understands what a tool does can tell when it is helping and when it is hiding a problem, such as a tool that silently rewrites code in a way that masks a design flaw. A developer who treats a tool as magic, on the other hand, will be stuck when the tool fails, because they will not know what it was doing on their behalf.
This distinction matters most when a tool produces an error or a surprising result. If you understand the tool's job, you can reason about the error: which step failed, what input it received, and what output you expected. If you do not understand the job, the error is just a wall, and the only options are to search for someone else's answer or to abandon the tool entirely. Learning what each tool does, in plain terms, is therefore not extra work. It is the work that makes the tool useful in the long run. The chapters in this book introduce tools gradually, one job at a time, so that each tool's purpose is clear before the next one arrives.
A good approach to any new tool is to start with its simplest use, understand its output, and add complexity only when a real problem demands it. Use the package manager to install one library before you learn its advanced features. Use the linter with a standard set of rules before you customise it. Use the formatter with its defaults before you argue about its settings. That gradual approach keeps tools as helpers, not as mysteries, and it keeps your attention on the product you are building. Tools should serve the project. When a tool starts to feel like the project itself, step back and ask what problem it was meant to solve.
Check: What can tooling not do for you?
Section 46 — When you do not need a tool yet
It is easy to assume that a serious project needs a full set of tools from the start: a build tool, a package manager, a linter, a formatter, a test framework, and more, all configured before any real code is written. That assumption is understandable, because tutorials and example projects often show a complete setup, and it can feel unprofessional to start with less. In reality, tools should be added when they solve a problem you actually have, not when they solve a problem you might have one day. A small page with two or three modules, served by a local server, needs no build step at all, and adding one would only add things to learn, configure and fix, without making the page any better for its users.
The test for whether a tool is needed is simple: name the problem, in one sentence, that the tool solves for this project, right now. If you can name it, and the problem is real and recurring, add the tool. If you cannot name it, or the problem is hypothetical, wait. A build tool solves the problem of bare imports, many files, or code that needs transforming before browsers can run it. If your project has none of those problems, a build tool is not yet earning its place. A linter solves the problem of slips in a growing codebase. If your codebase is tiny and you review it carefully, a linter is a nice extra, not a necessity. The same logic applies to every tool. Need is the criterion, not fashion.
Waiting has a benefit beyond simplicity. When you eventually add a tool because a real problem demands it, you will understand why you are adding it, what you expect it to do, and how to tell whether it is working. That understanding makes configuration easier, because you are configuring for a known purpose, not copying a setup you do not understand. It also makes it easier to remove a tool later, if it stops helping, because you will remember what problem it solved and whether that problem still exists. Projects that accumulate tools without a clear need often become hard to maintain, not because any single tool is bad, but because the combination is nobody's deliberate design.
Start simple, stay simple while simple works, and add tools one at a time, each for a named reason. That discipline keeps a project approachable for new developers, keeps the build fast, and keeps your own attention on the features users care about. Complexity is a cost, and like any cost, it should be paid only when it buys something real. Revisit the decision as the project grows. A tool that was unnecessary last month may be necessary now, and the reason for adding it will be clear because the problem it solves has actually appeared.
Check: When is it fine to skip a build tool entirely?
Section 47 — Upgrading a library carefully
Libraries change over time. Their authors fix bugs, add features, improve performance, and occasionally change how a function works or what it returns. Upgrading a library therefore brings benefits, but it also brings risk, and upgrading carelessly is one of the quieter ways to break a working site. A careful upgrade is a small, deliberate process, not a single click followed by hope. It starts before the upgrade, with reading, and it ends after the upgrade, with testing, and both halves matter.
Before upgrading, read what changed. Library authors usually publish a list of changes for each release, describing new features, fixes, and, most importantly, changes that break existing code. Look for renamed functions, changed behaviour, removed features, and new requirements, such as a different way to initialise the library. If the list mentions a breaking change that affects a function you use, plan for it before you upgrade, rather than discovering it afterwards. Also check whether the upgrade is one you need. A release that fixes a bug you have hit, or adds a feature you need, is worth upgrading for. A release that only adds features you do not need can wait until a convenient moment, when you have time to test properly.
After upgrading, test the parts of your site that use the library, not just the site as a whole. Open the pages that use its features, try the interactions that depend on it, and check the cases that are most likely to break, such as empty input, large input, and unusual input. Run any automated checks you have, and read the browser console for new warnings or errors. If something breaks, the upgrade is not a failure; it is information. You now know exactly which change caused the problem, because the only thing that changed was the library, and you can decide whether to adjust your code to the new behaviour, to stay on the old version for now, or to report the problem to the library's authors.
Keep upgrades small and separate. Upgrade one library at a time, commit the change with a clear message, and test before upgrading the next one. That way, if a problem appears, you know which library caused it. Upgrading five libraries at once and then hunting for the cause among five changes is a much harder job. Careful upgrading is not slow for its own sake. It is slow in the places where slowness prevents a broken site, and fast everywhere else, because a tested upgrade does not need to be redone. Record the upgrade in your commit message, naming the library and the reason. That record helps a future debugger distinguish a library change from a change in your own code.
Check: What should you do before and after updating a library?
Section 48 — Documenting your modules
Documentation for a module does not need to be long, but it does need to answer the questions a new reader will ask. A short comment at the top of the file is usually enough, provided it covers the module's purpose, its exports, and what it expects. Purpose means the job in one or two sentences: what this module does, and why it exists. Exports means the public pieces: which functions, constants or classes the module shares, and what each one does in a line. Expectations means the inputs and assumptions: what kind of values a function expects, what it returns, and any condition it relies on, such as data being in a particular shape.
What documentation should not do is repeat every line of code in words. A comment that says "this line adds one to the count" above a line that adds one to the count helps nobody, because the code already says that, more precisely. Documentation should explain the contract: what goes in, what comes out, and what the caller must know to use the module correctly. For a formatting function, the contract might be that it expects a number and returns a string with two decimal places. For a validation function, the contract might be that it returns a list of messages, empty when the input is valid. Those contracts are the information a caller needs, and they are not obvious from the code alone, because the code shows how the contract is fulfilled, not what the contract is.
Good documentation also records decisions that are not obvious from the code. If a module uses a particular rule because of a business requirement, or if it handles an edge case in a surprising way for a reason, a short note explaining why prevents a future developer from "fixing" the surprising behaviour and breaking the requirement. The why is often more valuable than the what, because the what is visible in the code and the why is not. A sentence such as "prices are rounded down here because the shop's policy is never to overcharge" saves a future reader from a well-intentioned mistake.
Keep documentation close to the code it describes, and keep it up to date. A comment at the top of a module, updated when the module's exports change, is far more useful than a separate document that nobody maintains. When you change a module's behaviour, change its comment in the same commit, so the two never drift apart. Documentation that is wrong is worse than no documentation, because it misleads with confidence. Documentation that is short, accurate and focused on the contract is a gift to every future reader, including yourself in six months' time.
Check: What should a module's top comment explain?
Section 49 — Common module mistakes
Most beginner module failures come from a short list of mistakes, and learning that list in advance will save you hours of confusion. The first is forgetting type="module" on the script tag. Without it, the browser treats your file as a classic script, and the first import or export statement causes an error, because classic scripts do not understand module syntax. The fix is one attribute, but the error message does not always point at the script tag, so it is easy to spend time checking the JavaScript when the problem is in the HTML. Always check the script tag first when module syntax itself seems to be rejected.
The second common mistake is leaving off the file extension in an import path. Writing from "./format" instead of from "./format.js" fails in a plain browser, for the reasons explained earlier in this chapter. The browser asks for a file with no extension, does not find it, and reports a loading failure. The fix is to include the extension, every time, without exception. A related mistake is misspelling an exported name, or getting its capitalisation wrong, so that the import asks for a name the module never exported. Copy names from the export rather than retyping them, and check capitals carefully, because JavaScript treats formatPrice and formatprice as entirely different names.
The third common mistake is opening the page directly from your files, with a file:// address, instead of through a local server. As explained earlier, browsers block module imports from local files for security reasons, so a page that works perfectly through a server will fail from disk, with an error about loading modules. If you see a module error, check the address bar. If it starts with file://, start a local server and open the page through it, and the error will often disappear. A fourth mistake, worth adding to the list, is importing a file that is never reachable from the entry point, and wondering why its code does not run. Remember that the browser loads only what the import graph reaches, so a new file must be imported, directly or indirectly, from the entry module before it will execute.
When a module breaks, work through this list in order: script tag, extension, spelling, server, and reachability. Check each one deliberately, rather than changing several things at once and hoping one of them was the cause. That ordered checklist turns module debugging from a guessing game into a routine, and a routine is what you need when you are tired, under pressure, or working on a larger project where module errors are just one of many things competing for your attention. Print or save this checklist until it is habit. Module errors feel mysterious only until the checks become routine, after which most of them are fixed in a minute or two.
Check: Name three common beginner module mistakes.
Section 50 — Chapter 24 in one paragraph
Modules split JavaScript into small, private files that share only what they export, using named and default exports, clear paths and a local server for testing. Around them sit simple tools — a package manager, a package file, a linter and a formatter — that keep libraries, style and everyday commands tidy without taking over your thinking. That summary, short as it is, contains the whole chapter's argument, and it is worth unpacking once more before you move on, because the details matter as much as the headline.
The first half of the argument is about privacy and clarity. Modules give each file its own scope, so names do not clash by accident. Exports make sharing deliberate, and imports make dependencies visible. Paths, extensions and the module script tag are the mechanics that make that sharing work in a browser, and the local server is the environment that lets the browser apply its normal rules. Dynamic imports add a further refinement, letting code load on demand instead of all at once, and import maps and build tools, introduced here and explored in the next chapter, extend the system to libraries and larger projects. None of these pieces is complicated on its own. Together, they form a system for keeping code organised as it grows.
The second half of the argument is about tooling as a helper, not a master. A package manager tracks libraries so you do not have to copy files by hand. A package file records what a project needs, so another developer can recreate it. A linter catches slips early, and a formatter keeps layout consistent, so reviews can focus on logic. Scripts give everyday commands short names, so they are easy to remember and share. Each tool solves a named problem, and each should be added when that problem is real, not before. A project that uses modules well, and tools sparingly and deliberately, is a project that stays understandable as it grows, and understandability is the quality that makes all other qualities possible.
Carry two habits forward from this chapter. First, design files around what they export, and keep those exports small, clear and honest. Second, add tools for reasons you can name, and learn what each tool does before you rely on it. Those habits, practised on small projects, will scale to large ones without needing to be relearned. The next chapters build on them: version control will give your modules a history, and build tools will give them a production shape, but the module itself, a small file with a clear job and a clear public face, remains the unit everything else is built from. Return to this summary whenever a later chapter introduces a new tool. Ask how the tool changes the module system described here, and whether it makes modules clearer or merely adds another layer to understand.
Check: In your own words, summarise what modules and basic tooling give you.
Practical task
Build a small price calculator using modules, served from a local server. Create index.html with a module script, js/main.js as the entry point, js/format.js exporting a named price-formatting function, and js/calculate.js exporting a named total function. The page should have an input for a price, an input for a quantity, a button, and a result area that shows the formatted total when the button is clicked. Check in the browser console that there are no import errors, then add a second feature — a discount button that uses a dynamic import to load a discount module only when clicked.
Chapter questions
- Explain, in plain words, the difference between a named export and a default export, and show a short example of each.
- A module import fails in the browser. List, in order, what you would check and why.
- Why do module pages usually need a local server instead of being opened directly from your files?
- When would you use a dynamic import, and what does it return?
- A teammate suggests adding a build tool, a linter and a formatter to a two-page site at once. What would you advise, and why?