Code Splitting with React and Vite
using import(), React.lazy() and React.Suspense
February 2026
Prerequisites
This tutorial assumes that you are already comfortable with the basics of React, and that you have already created a number of interactive apps with a single focus. In particular, I expect you to know about:
If any of these concepts are unfamiliar to you, please follow the links provided before you continue.
Development environment and local server
I also assume that you are working with an IDE (Integrated Development Environment) like VS Code, which allows you to open HTML files using an extension like Live Server. This means that I can separate the code you’ll be writing into different files, each with a single purpose.
You can download these files from here.
What is Code Splitting?
In a simple React project, the final build process
creates three files:
- a minified JavaScript file
- a compact CSS file
- a skeleton
index.htmlfile which loads the JavaScript and CSS files
When you open the production version of your app, the
index.html file is downloaded first, and the
<script> and <link> tags it
contains in its <head> trigger the download of the
JavaScript and CSS files, and the JavaScript file in turn may trigger
the download of various assets.
In other words, there is a one-time flood of data from the server, and then everything the app needs is available locally. But this flood can take a significant amount of time.
With code splitting, your build tool creates separate JavaScript and CSS files for each feature, and only loads those file when they are explicitly requested. This means that the initial download of data is minimized, but there may be a delay before a newly-requested feature is operational, while its files download.
How does your build tool know where to split your code?
The answer is: you tell your build tool where to split your code by
the way you write it. You indicate where the code should be split with
requests for import().
Since early 2019, all major browsers
support the dynamic
import() feature. Dynamic import allows you to load a
module asynchronously at run-time.
During the build process, your build tool will notice every time you
use the import() syntax, and will bundle each block of
imported code into its own JavaScript and CSS files. The main JS file
that is requested by the index.html file will contain the
relative URLs of each of these feature bundles, so it can
import() them whenever they are needed.
The challenges
Imagine this scenario: you click on a link to an activity which downloads a feature bundle. Let’s call this Activity A. You start working with Activity A, and then you switch to a different activity. When you come back to Activity A later, you find that all the changes you have made have been forgotten, and you are back to where the activity started.
When you navigate away from an activity, your browser dismounts the components associated with it, and all the state associated with those components is lost. One solution is to keep the state for the activity in a Context which does not get dismounted. If you do that, you can restore the component state from the Context when the activity is remounted later.
If the Context only contains state for Activity A, then it would make sense to import it at the same time as the components for Activity A, but then you have to insert the Context’s Provider into your app’s component hierarchy, as a parent or ancestor of the activity components.
If you later add a new activity (Activity B) that needs to store some of the same state as Activity A, you might need to place the shared Provider higher up the component hierarchy, so that it can make the Context available to both activities.
Building a code-splitting app, step by step
I plan to show, step-by-step, how code splitting works in a React frontend. I’ll show you how to build an app where you can simply create a new directory in your development environment that contains all the code and assets that you need for a new activity, so that the new version of the deployed app will be able to load the activity on the fly. I’ll show you how to ensure that each activity maintains its state if you navigate to a different activity and back.
Installing the Work Files
Eventually, I’ll show you how to build a demo app using npm and Vite to provide a sturdy development framework. But for now, you can use something more lightweight.
Browsers cannot understand the JSX syntax that React uses, so your React code has to be compiled to plain JavaScript before it is deployed to a server. Vite provides all the tools you need to compile your code and create a production-ready app. For this tutorial, though, you can use esbuild to take care of compilation. Vite is built on top of esbuild, so the JavaScript output will be the same. You’ll just have to provide your own local server to host the files you create.
Preparing a workspace
Clone or download the files in this repository: Lazy-Loading Sandbox.
Open your IDE and a Terminal window inside the Sandbox directory. You should see a folder hierarchy like this:
.
├── 01
│ ├── App.jsx
│ ├── index.html
│ └── LazyComponent.jsx
├── 02
│ ├── App.jsx
│ ├── index.html
│ └── LazyComponent.jsx
├── .../
├── build.mjs
├── buildAll.mjs
├── cleanUp.js
├── package-lock.json
├── package.json
└── README.md
Each numbered folder contains a mini-app for you to practise with, but these have to be compiled to JavaScript before you can open them in your browser.
Installing node modules
In the Terminal run this command:
npm i esbuild react react-dom
You should see a new folder appear in your Sandbox directory:
┆
├── node_modules
│ ├── @esbuild
│ ├── esbuild
│ ├── react
│ ├── react-dom
│ └── scheduler
┆
I assume that you are used to seeing react,
react-dom and scheduler in your dependencies.
The esbuild module is the one which will compile your React
code to JavaScript and perform code splitting for you.
Building all the mini-apps at once
Each mini-app is contained in a numbered folder. To minimize the download size, none of the mini-apps have been built for deployment. To do this, run the following command in your Terminal:
node buildAll.mjs
This will tell the buildAll.mjs script to create an
assets/ subfolder inside each of the numbered folders. This
will look something like this, but the exact names of the files may be
different:
.
├── 01
│ ├── App.jsx
│ ├── assets
│ │ ├── App.js
│ │ ├── App.js.map
│ │ ├── chunk-UASCOLGB.js
│ │ ├── chunk-UASCOLGB.js.map
│ │ ├── LazyComponent-C4ZJBLUW.js
│ │ └── LazyComponent-C4ZJBLUW.js.map
│ ├── index.html
│ └── LazyComponent.jsx
├── .../
├── launch.mjs
├── buildAll.mjs
├── cleanUp.js
├── node_modules/
├── package-lock.json
├── package.json
└── README.md
Opening a mini-app in your browser
Use your local server to open the index.html file in any
of the numbered folders. If you are working with VS Code, this is how
you do this:
The file should open in your browser:
Double-click doesn’t work
You must use a local server to open the
index.html files.
If you simply double-click on a mini-app index.html file
on your desktop, it will open in your browser, but, for your
safety, the browser will refuse to load any files via the
import() command, so none of the code splitting features
will work.
The Simplest Split
In the Sandbox folder that you created in the
last step, take a look at the
files at the root of the 01/ folder:
index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Code Splitting 01</title>
<!-- The build step guarantees this is a valid link -->
<script defer type="module" src="./assets/App.js"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
This is a generic HTML file, which is identical in every numbered folder, except that the title changes to match the folder name:
<title>Code Splitting 01</title>
It simply defines…
<div id="root"></div>
… which React will use as the container for all the DOM elements that
it generates, and it loads the App.js script from the
assets/ folder, which was created by the
esbuild when you ran node buildAll.mjs.
<script defer type="module" src="./assets/App.js"></script>
App.jsx
import { Suspense, lazy } from 'react';
import { createRoot } from 'react-dom/client';
const LazyComponent = lazy(() => import('./LazyComponent.jsx'))
function App() {
return (
<Suspense fallback={<div>Loading...</div>}>
<LazyComponent />
</Suspense>
);
}
createRoot(document.getElementById('root')).render(<App />);
The App.jsx file implements many powerful concepts that
I’ll explain in the following sections. In short, it uses…
import('./LazyComponent.jsx')
… to load the LazyComponent.jsx file asynchronously at
run time, and it uses lazy() and
<Suspense> to handle this import elegantly for React.
More about this later.
LazyComponent.jsx
export default function LazyComponent() {
return <h1>I am lazy.</h1>;
}
LazyComponent.jsx is an ultra-simple component function,
which shows the text "I am lazy." as a header.
Note that the LazyComponent() function is
explicitly exported as the default. This makes it easier
for the React.lazy, which expects the dynamically imported
module to provide a default export. You’ll see how
to deal with named exports later.
assets/
The assets/ folder, which was created by the build
process, contains 6 files.
The random-looking last eight letters in a file’s name are a hash of its contents. The name will stay the same, so long as the contents do not change. This means that the browser can safely cache the file, knowing that when a file with an identical name is requested, its contents will be identical. This means it it is safe to use its cached copy instead of triggering a new download.
However, if the contents of any file have changed since I wrote this tutorial, then the hashes that you see will be different.
01
├── App.jsx
├── assets
│ ├── App.js
│ ├── App.js.map
│ ├── chunk-U3XB4E5Q.js
│ ├── chunk-U3XB4E5Q.js.map
│ ├── LazyComponent-27PLPFAM.js
│ └── LazyComponent-27PLPFAM.js.map
├── index.html
└── LazyComponent.jsx
The .map files
The files with the extension .map are not required for
your code to run. They are used by the browser to make it easier for you
to use the Debugger. Thanks to the .map files, you can step
through your original JSX code, and the browser will follow your steps
through the compiled JavaScript code behind the scenes for you.
Without the .map files, the JSX code will not be
available, and all the browser can show you is the compiled JavaScript
code, which you didn’t write and which is much more complex to
follow.
App.js
App.js is a pure JavaScript file, and it is huge. It
contains all the code that React wants to use at runtime, and which
esbuild chose not to place in the
chunk-XXXXXXXX.js, in addition to the compiled version of
the original App.jsx file.
import {
__commonJS,
__toESM,
require_jsx_runtime,
require_react
} from "./chunk-U3XB4E5Q.js";
// Over 20000 lines skipped, then the code compiled from App.jsx... //
// 01/App.jsx
var require_App = __commonJS({
"01/App.jsx"() {
var import_react = __toESM(require_react());
var import_client = __toESM(require_client());
var import_jsx_runtime = __toESM(require_jsx_runtime());
var LazyComponent = (0, import_react.lazy)(() => import("./LazyComponent-27PLPFAM.js"));
function App() {
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react.Suspense, { fallback: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { children: "Loading..." }), children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(LazyComponent, {}) });
}
(0, import_client.createRoot)(document.getElementById("root")).render(/* @__PURE__ */ (0, import_jsx_runtime.jsx)(App, {}));
}
});
export default require_App();
chunk-XXXXXXXX.js
The file whose name begins chunk- and which has the
extension .js contains code that esbuild chose
share between multiple modules.
It only exists because the App.jsx code contains
import(), and because the App.jsx file and the
LazyComponent.jsx file both need access to the same React
functions.
It, too, is a fairly big file, and it would be even bigger if your split modules had more code in common.
It will be downloaded when the browser is executing
App.js and encounters a static import from
chunk-XXXXXXXX.js, as a dependency.
LazyComponent-XXXXXXXX.js
LazyComponent has been given its own file, because it is loaded by
the import() command. It is a fairly small file, because
all the React features it requires have already been included in
App.js or chunk-XXXXXXXX.js. React runtime
helpers have been placed in the shared chunk, while App.js contains
application glue code.
Its contents are a pure JavaScript version of
LazyComponent.jsx.
import {
__esm,
__toESM,
require_jsx_runtime
} from "./chunk-U3XB4E5Q.js";
// 01/LazyComponent.jsx
function LazyComponent() {
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h1", { children: "I am lazy." });
}
var import_jsx_runtime;
var init_LazyComponent = __esm({
"01/LazyComponent.jsx"() {
import_jsx_runtime = __toESM(require_jsx_runtime());
}
});
init_LazyComponent();
export {
LazyComponent as default
};
//# sourceMappingURL=LazyComponent-27PLPFAM.js.map
The weight of React
Take a moment to compare the size of the raw App.jsx file and the
size of the JavaScript bundle that now includes your code and its
dependencies. App.js is around 3000 times larger than
App.jsx.
But I said that the whole purpose of this tutorial was to show you how to reduce the size of the initial download, didn’t I?
No worries. Most of this size increase does not come from your code, but from bundling React and ReactDOM into the same file.
To be fair, at this point, esbuild is transpiling the
JSX for development purposes, so it is generating unminified JavaScript
and helpers that are useful during dev-time debugging.
There are ways to reduce the amount of production code that your server will deploy for the Internet, using minification and tree-shaking, but this tutorial is not the place to discuss that.
The arguments for using React are:
Productivity now
You’ll complete your projects faster with the tools that React provides.
Real-world context
The production-ready projects that you will be working on are far more complex than what is covered by this tutorial
Future-proofing
Unused power today is likely to be used later. The actual project that I am creating will eventually contain activities that have not even been thought of yet. React gives me room to expand.
However, if you have just a small project that you know is never going to grow big, it might be worthwhile writing it in plain JavaScript.
Summary
In summary, esbuild has split the code of this mini-app
into three files:
- The entry-point bundle for App.jsx, with all the code for running the pure JavaScript version of App.jsx
- Code shared between the compiled versions of App.jsx and LazyComponent.jsx
- The code specific to each module that is loaded with the
import()command. (In this case, there is only one such module).
For each of these three files, esbuild has also created
a file with a .map extension, to help you when you are
stepping through or debugging your JSX code.
import(), lazy() and Suspense
In the next sections, you can explore how import(),
lazy() and Suspense work, separately and
together.
The import() command is native JavaScript.
Suspense and lazy() are React-specific
features, designed to handle the asynchronous nature of
import() cleanly in a React environment.
Dynamic import()
The import()
command is native JavaScript. You can use it completely
independently of React. Its purpose is to request a JavaScript module by
URL, downloading it if it has not already been loaded, and to return a
Promise.
This Promise should resolve to the usable contents of the file if the
download is successful.
Proof of concept
Here’s a very simple demo of dynamic import() at work.
You can create your own scripts similar to the ones listed below, or you
can open the 03_import_demo subfolder in your
Sandbox/ folder.
In either case, you’ll need to open the index.html file
with your local server, for the reasons explained
.
index.html
A simple HTML file with a button and a <div> with
the id “game-space”, which is where an imported module will
appear after the button is clicked.
<!DOCTYPE html>
<html>
<head>
<title>Import() Demo</title>
<script defer src="script.js"></script>
</head>
<body>
<button id="button">
Import Placeholder Module
</button>
<div id="game-space"></div>
</body>
</html>
script.js
The JavaScript file that is loaded directly by
index.html. I’ll describe how it works shortly.
const button = document.getElementById("button")
const gameSpace = document.getElementById("game-space")
button.addEventListener("click", importGame)
function importGame() {
const url = `./placeholder.js`
const promise = import(url)
promise
.then(result => {
console.log("result:", result)
return result.default
})
.then(renderGame => renderGame(gameSpace))
.catch(error => console.error(error))
}
placeholder.js
The external JavaScript file which will be imported by a click on the
button. It does nothing special. It simply indicates its presence by
adding some text to the #game-space div, a pointer to which
is passed to it by the root argument.
export default function (root) {
root.textContent = "🎉 Placeholder module loaded!";
}
console.log("Placeholder module evaluated")
Testing this proof of concept
Launch index.html with your local server. You should see
a button:
Click on the button. You should see that the function from
placeholder.js has been called and the textContent of the
#game-space div has been set.
Modules and JSON imports
You can use the import keyword to load either functional
code from a .js file or data from a .json
file. I’ll explain how to import JSON data later.
A JavaScript file that is loaded using import is called a
module. A module exports one or more objects. These objects
may be functions, plain old JavaScript objects (POJOs) created with
{} curly brackets, or arrays. A module can export one
object as default. Any other object it exports must have a
name. The example you have just tested uses an anonymous function
exported as default. You’ll see other possibilities later
in the tutorial.
import vs require
You may have used the require keyword in Node projects.
This has a similar, but not identical, function, and browsers do not
support require. In particular, require() is
synchronous. Node.js stops executing the current script while it loads
the required file and executes it. This is fine when the
required file is stored locally on the server and can be loaded
fast.
In a browser, it can take some time before a file is downloaded, so
import works asynchronously, and returns a Promise.
This Promise needs to be resolved before it can be
used.
Understanding function importGame()
This is the code which is executed when you click on the Import Placeholder Module button:
const url = `./placeholder.js`
const promise = import(url)
promise
.then(result => {
console.log("result:", result)
return result.default
})
.then(renderGame => renderGame(gameSpace))
.catch(error => console.error(error))
Things to notice:
The path to the file to import is given by a URL. This can be relative to the location of the page that calls it (as in the case above), or it can be an absolute URL to a file on some other server.
The
import()call returns a promise. When this resolves, the.then()function logs it to the console, so that you can see that the resolved value is an object with a propertydefault, whose value is the anonymous function fromplaceholder.js, which has now been given a name:default.Object { default: function default(root) Symbol(Symbol.toStringTag): "Module" }
In addition there is a Symbol with the value “Module”. This symbol indicates that the object represents an ES module. All ES modules are automatically executed in “strict mode”. This means that you will see more meaningful errors, and the browser can perform certain optimizations which are not available in JavaScript’s default “sloppy” mode.
I’ve explicitly used a Promise here, rather than the sweeter
async/awaitsyntax that you might already know. This is simply to underline the fact that the output ofimport()is a Promise which must be resolved before the imported module can be used.Line 14 then receives this
defaultfunction and renames it torenderGameand then executes it. Which is how thetextContentof the#game-spacediv got set.
Multiple import() calls, only one download
You may already have noticed evidence that the
placeholder.js file is only downloaded once. If you open
the Console tab of your Developer Tools, you should see that the
line…
console.log("Placeholder module evaluated")
… logs only one message in the Console, even if you click on the Import Placeholder Module multiple times.
Once the browser has downloaded the file and evaluated it, it will not ask for it again, even if you disable the browser cache.
Summary
The points to note here are:
import()returns a Promise which must be resolved before the imported module can be used- The imported module is evaluated once and for all
- The imported data is an object with a
defaultproperty that allows you to access the module.
Now that you understand how import() can load a module
dynamically, let’s see how to make the imported module interactive. In
the next section, you’ll work with a tiny game that you can actually
play.
A Simplistic Game
The does not create anything interactive.
You can change the content of placeholder.js to what is
shown below, or open the 04_import_number_game/ subfolder
in your Sandbox/ folder, where you will find the same
code.
Edited placeholder.js
export default function renderGame(root) {
root.innerHTML = ""
const target = Math.ceil(Math.random() * 5)
const checkNumber = (event) => {
const button = event.target
if (button.textContent == target) {
button.style.background = "green"
button.style.color = "white"
} else {
button.disabled = true
}
}
const title = document.createElement("h2")
title.textContent = `Click number ${target}`
root.append(title)
for (let i = 1; i <= 5; i++) {
const button = document.createElement("button")
button.textContent = i
button.style.margin = "0.5em"
button.onclick = checkNumber
root.append(button)
}
}
This creates a simple, yet functional game that demonstrates the basics of user interaction:
A React version of the same game
Compare the plain JavaScript code above to how it would look when written in JSX for React:
Placeholder.jsx
const RenderGame = () => {
const target = Math.ceil(Math.random() * 5)
const checkNumber = (event) => {
const button = event.target
if (button.textContent == target) {
button.style.background = "green"
button.style.color = "white"
} else {
button.disabled = true
}
}
const title = <h2>Click number {target}</h2>
const buttons = [1,2,3,4,5].map( number => (
<button
key={number}
onClick={checkNumber}
style={{
margin: "0.5em"
}}
>
{number}
</button>
))
return (
<>
{ title }
{ buttons }
</>
)
}
ReactDOM
.createRoot(document.getElementById("root"))
.render(<RenderGame />)
You can test this JSX version by launching the
JSindeX.html file that you’ll find in the same
Sandbox/04-import-number-game/ folder.
Do you see how the plain vanilla JavaScript performs an identical function to the JSX code?
Only the line…
root.innerHTML = ""
… has no equivalent in the JSX code, because the JSX code is not imported.
A peek behind the curtain
The JSindeX.html file uses <script>
tags in the <head> to download the code for React and
ReactDOM from a CDN, and also downloads a Babel standalone that
transpiles the JSX code in Placeholder.jsx to plain
JavaScript directly in the browser.
<!DOCTYPE html>
<html>
<head>
<title>JSX Number Game</title>
<!-- Load React and ReactDOM from CDN -->
<script src="https://unpkg.com/react@18.2.0/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<!-- Load Babel standalone to convert JSX to JS -->
<script src="https://unpkg.com/@babel/standalone@7.21.4/babel.min.js"></script>
<!-- Load the custom script that creates the React components -->
<script defer type="text/babel" src="./Placeholder.jsx"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
NOTE: Using the Babel standalone is fine for a quick and
dirty demo, like this one, but it won’t allow you to use
import in any shape or form. This is why you need to use
esbuild to precompile the JSX code elsewhere in this
tutorial.
Summary
This section has shown you that plain JavaScript can do everything that React’s JSX code can do. Indeed, this should go without saying, because JSX transpiles down to JavaScript for production.
The way you write code in modular components in React is made
possible by the native JavaScript keyword, import.
In the next section, you’ll see JavaScript versions of the three mini-language game modules that make up the final React project, which you already saw as a preview at the beginning.
Three Language Games
In the folder Sandbox/05_three_games/ you can find the
index.html file and open it with your local server. You
should see a page with three buttons, with the names of the games, and a
grey array, where the games will appear.
You can click on each button in turn, to see the game that the app imports and displays:
The container app
index.html
The index.html file is deliberately boring: three
buttons, one mount point, no framework.
If it helps, you can think of this as the non-React equivalent of a root component with three event triggers and one render target.
So bore me with it
The index.html file loads a basic stylesheet (which I
won’t treat here) and a plain JavaScript file (which I describe
below).
It creates three buttons and a <div> with the
id game-space (the grey area), and wraps these
in a <main> tag. Nothing complex.
<!DOCTYPE html>
<html>
<head>
<title>Three Games</title>
<link rel="stylesheet" href="styles.css">
<script defer src="script.js"></script>
</head>
<body>
<main>
<div id="buttons">
<button type="button" id="difference-game">
Odd One Out
</button>
<button type="button" id="picture-game">
Picture Game
</button>
<button type="button" id="word-game">
Word Game
</button>
</div>
<div id="game-space"></div>
</main>
</body>
</html>
script.js
The script.js file is almost identical to the
. The only major differences are that this time:
- There are three buttons which are given the same event listener
- The
urlof the module that is loaded is obtained using theidof the button that was clicked.
const buttons = document.getElementsByTagName("button")
const gameSpace = document.getElementById("game-space")
Array.from(buttons).forEach( button => {
button.addEventListener("click", importGame)
})
function importGame(event) {
const gameName = event.target.id
const url = `./games/${gameName}.js`
const promise = import(url)
promise
.then(result => result.default)
.then(renderGame => renderGame(gameSpace))
.catch(error => console.error(error))
}
As you will see later, this is the same pattern that
React.lazy() uses internally: a user action triggers a
dynamic import, which resolves to a render function.
The three games
The first game (Odd One Out) is clearly different from the other two. It has no images and it uses a different set of words.
The other two games (Picture Game and Word Game) have a lot in common. Although each has its own specific game logic, they share:
- the same word list
- the same images
- the same basic styling and layout
- the method for checking if the player got the right answer.
It would make sense for them to import these shared features from the same modules. These modules would be loaded and evaluated once, when the first of the two games is imported, and then be already available when the second game is opened.
Making work for you
But this is a tutorial, so I have deliberately coded them differently, to give you some work to do.
You can imagine that each of these files was written by a different member of your Dev Team, after an initial discussion about the use of modules. Each member of the team has understood the concept slightly differently.
If you can apply the same elegant modular to each of these mini-games, then you will have shown how much you have understood so far.
But first you need to know what you have to work on.
picture-game.js: a standalone approach
I won’t show all the code in
Sandbox/05-three-games/games/picture-game.js here, because
you can have the file itself that you can look at. You don’t need to
understand the whole file. Just notice how many different
responsibilities it has.
Here’s what to look out for:
- Everything lives in one file
- Styles, data, logic are bundled
- The module exports a single render function
A closer look
You’ll find that the file at
Sandbox/05-three-games/games/picture-game.js is organized
in a similar way to the from the last section:
Everything is included in one file.
The data used by the game is defined inside the file itself:
const words = [ "ball", "bear", "bell", "boar", "fair", "fall", "fell", "four" ]There are helper functions to control the logic of the game, such as:
const checkClick = ( target, word, button, div ) => {// 9 lines skipped}The UI is generated using the function that is exported as
default:export default function renderGame(root) {// 40 lines skipped}Styling is treated inline (using a somewhat more sophisticated method than in the ):
const h2Styles = { margin: 0, "text-align": "center", }const applyStyles = (element, styles) => { Object.assign(element.style, styles) }const h2 = document.createElement("h2") h2.textContent = word applyStyles(h2, h2Styles)
Cleaning up before moving in
If you look at both difference.js and
picture-game.js, you will see that, despite their
differences, they both clear out any DOM elements before they start
adding their own. Each of them contains its own clear()
function, which it calls before it does anything else:
const clear = element => {
while (element.firstChild) {
element.firstChild.remove()
}
}
const newGame = () => {
// Empty the parent element
clear(root)
// core of newGame() function skipped
}
This is not best practice. Best practice is DRY: Don’t Repeat
Yourself. In short, picture-game.js, like
difference.js, are independent and self-contained. But they
don’t need to be. There’s a better way.
Summary
In this section, you’ve been able to play with three simple games
which can be loaded on demand, using import(). In the
current set-up, the games are mutually exclusive; you can only play one
of them at a time. Each one clears out any DOM components that another
has created before installing its own.
The most troubling is that each is independent of the others: they share nothing between them.
When working in React, you have been used to creating components that are stored in a single script but which can be used in multiple places. As you will see in the next section, this is also possible in plain JavaScript. In fact, plain JavaScript could do it first.
In the next section, you’ll see how the elements that can be shared between the games can be broken out into separate modules that all can use.
Using submodules
It’s time to look at the word-game.js script, and its
modular approach.
The word-game.js script is much more compact than the
other two. Much of the code it needs has been externalized in other
modules. The word-game.js module uses static
import ... from ... statements to load the submodules it
needs synchronously as part of module evaluation, before continuing
executing its own code.
import wordGame from './submodules/word.js'
import words from '../../words.json' with { type: 'json' }
import {
clear,
getChoices,
checkClick
} from './submodules/helpers.js'
import … with { type: “json” }
Notice how the JSON file is imported:
import words from '../../words.json' with { type: 'json' }
The import
attribute { type: "json" } that is applied using the
keyword with ensures that the browser parses the incoming
file correctly.
The Internet is often an unsafe place for packets of data, and a file
with the extension .json might actually contain malicious
JavaScript. The browser does not simply parse it as an object:
it parses the incoming data specifically as a JSON object, and
throws an error if this is not the case. Potentially malicious code is
neutralized.
See the MDN article on the subject for more details.
The word-game.js file also contains a function to append
a stylesheet to the HTML created by the index.html file,
rather than using inline styles:
const applyStyles = () => {
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = './games/submodules/styles.css'
document.head.appendChild(link)
}
Finally, it exports a function which itself activates a module which it imported at the beginning:
// This function is the module's public API
export default function renderGame(root) {
applyStyles()
return wordGame({ root, words, clear, getChoices, checkClick })
}
If you are looking for buzzwords to describe this kind of design pattern, you might like to read more about:
Can you see how, from a React perspective, this parallels the way hooks are passed into components?
The submodules/ folder
The real work of word-game.js has been delegated to the
imported files. The words.json file is stored at the root
of the Sandbox/ folder, because you will be using it later.
The other imported files are stored in submodules/
folder.
submodules/helpers.js
If you compare helper.js with
picture-game.js, you will see that they both include a
number of identical functions.
You don’t need to understand every line here — just notice that none of these functions know which game they’re being used in.
export const clear = element => {
while (element.firstChild) {
element.firstChild.remove()
}
}
export const getChoices = (words) => {
// Choose a random word that did not appear in the last 3 times
let random = Math.floor(Math.random() * (words.length - 3))
const word = words.splice(random, 1)[0]
words.push(word) // move to end of words
const decoys = [...words]
// Remove the last word, which cannot be a decoy
decoys.pop()
// Choose 3 decoys at random
const choices = Array.from({ length: 3 })
choices.forEach(( _, index, array ) => {
const random = Math.floor(Math.random() * decoys.length)
const decoy = decoys.splice(random, 1)[0]
array[index] = decoy
})
// Place the correct word in a random position
random = Math.floor(Math.random() * 4)
choices.splice(random, 0, word)
return { word, choices }
}
export const checkClick = (target, correct, button) => {
if (correct) {
target.classList.add("right")
button.disabled = false
} else {
target.classList.add("wrong")
}
}
The Word Game needs these functions just as much as the Picture Game does, so it makes sense to create a separate module that both games can share. One download: multiple uses.
And as a bonus: you can test these functions in isolation, to make sure that they do exactly what you expect, before making other modules depend on them.
Named exports, but no default export
Notice that the functions in helpers.js are all declared
in a similar way, with an obligatory name:
export const functionNameRequired = (parameters) => { ... }
None of the functions are exported as default, like you
have seen before:
export default function optionalFunctioName(parameters) { ... }
Question: Without a default export, what will the imported
object look like?
Answer
As you might expect: the imported module is an object with no
default key:
{
checkClick: checkClick(target, correct, button),
clear: clear(element),
getChoices: getChoices(words),
Symbol(Symbol.toStringTag): "Module"
}
submodules/word.js
The submodules/word.js script receives the
clear(), getChoices() and
checkClick() functions in the call it receives from
word-games.js, along with the words array, and
the root DOM element that has been forwarded from the
script.js module.
All the rest of the code in the submodules/word.js
script is specific to this one game. The newGame() function
simply creates the DOM elements needed for one iteration of this
particular game.
Note that there are no inline styles. Styling is taken care of by the
stylesheet that was appended to the HTML <head>.
export default function gameCore({
root,
words,
clear,
getChoices,
checkClick
}) {
const newGame = () => {
// Empty the parent element
clear(root)
// Choose a target word and three decoys
const { word, choices } = getChoices(words)
// Recreate the game UI with a custom div inside root
const space = document.createElement("div")
space.className = "word-game"
root.append(space)
const title = document.createElement("h2")
title.textContent = "Choose the Word"
space.append(title)
const image = document.createElement("img")
image.src = `../images/${word}.webp`
image.alt = word
space.append(image)
const p = document.createElement("p")
choices.forEach( text => {
const span = document.createElement("span")
span.textContent = text
span.addEventListener(
"click",
({target}) => checkClick( target, text === word, button ))
p.append(span)
})
space.append(p)
const button = document.createElement("button")
button.type = "button"
button.textContent = "Next Image"
button.addEventListener("click", newGame)
button.disabled = true
space.append(button)
}
// Force game() to run as soon as gameCore() is called
newGame()
}
Really?
I just wrote: “All the rest of the code in the
submodules/word.js script is specific to this one game”.
It’s time for you to prove me wrong, in the following challenge.
Challenge: Refactoring the Games
How much of the code in word-game.js and its submodules
can be shared with the Picture Game?
Your challenge (if you accept it) is to refactor the files in the ’05-three-games/` folder, so that the least amount of bandwidth is used when a visitor plays all three games.
Consider that you are “coding by differences”. Any time you find something that is duplicated in two or more scripts, move that thing out into a module, and import it into the scripts that need to use it.
Do your best to come up with a neat, elegant solution, before you look at my scoring system and my suggested answer.
Scoring
You can earn a total of 30 points.
- 1 point for moving
applyStyles()fromword-game.jsintohelpers.js - 1 point for making it possible to customize the url for the
stylesheet in the
applyStyles()call - 2 point for noticing that the creation of the button that triggers
newGame()is common to all three games, and can be moved tohelpers.js - 1 point for making it possible to customize the name of this button
- 3 points for noticing that the
differences-game.jsmodule can use the sameclear(),checkClick()andnewButton()functions as the other two games, so you can import these fromhelpers.js - 4 points for expanding
styles.cssso that it can be used for all three games - 4 points for noticing that now the only difference between any of
the games is the module that generates the UI for the game, and that
this means you can reduce the files
differences-game.js,picture-game.jsandword-game.jsto thin entry points - 10 points for creating a generic game module for your thin entry points to use. You can only get this points if your generic module gathers up the data and functions required by all the games, and calls the appropriate game UI function with all the necessary information
- 4 points for arranging all the files in a folder hierarchy, and for giving all your files and folders meaningful names.
You’ll find my solution (for which I award myself 30 points) in
06-three-modularized-games/. And you can award yourself a
bonus of 20 points if your own solution is better than mine.
Summary
In this section, you’ve seen a different way to structure a game
module: instead of doing everything in one file,
word-game.js acts as a thin entry point that pulls together
data, shared helpers, styles, and a game-specific UI module.
Static imports determine what gets bundled together. Anything that
word-game.js imports synchronously becomes part of the same
download, and anything that lives outside that boundary can be shared
with other games at no extra cost.
By pushing common logic into submodules, you reduce duplication, make the code easier to test, and ensure that shared functionality is loaded once and reused. The result is a design that scales: adding new games means writing only what’s different, while everything else stays the same.
You’ve also had the chance to practice refactoring code you didn’t write, with the explicit goal of optimizing it for code splitting.
In the next section, you’ll see the same ideas expressed in JSX, and
explore why React needs to provide lazy() and
Suspense when components are loaded dynamically with
import().
A Use Case for Code Splitting
Alice teaches French as a foreign language. She has a smartboard in her classroom, and there are enough tablets for all her students. She wants to create a number of fun activities, to encourage her students to practice their new language, and she has a vivid imagination. Ideally, she wants web app with a single simple URL, to which she (or rather we) can add new activities from time to time, as her plans crystallize.
To begin with, the app will have a single activity, and it won’t take long to load. But over time, the amount of code and assets required for all the activities that Alice plans will become too much to load all at once. Why make the end-user wait while the code and assets load for activities that are not yet needed? Why not load files just in time?
What we need to create is basicly a generic engine whose possibilities can evolve organically, without us having to rewrite the core code each time a new activity is added.
Code splitting seems to be the solution. So that’s what this tutorial is about.
Sneak preview
In this tutorial, you won’t be building the powerhouse app that Alice wants. Your own project has very different requirements.
Instead, you’ll be building an app with three ultra-simple language games that are loaded on demand. This will be enough for you to master the necessary techniques. It will be up to you to replace these simple games with features that you actually need in your own project.
The app that you will create here will look something like this: