home

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:

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:

Opening an HTML file with Live Server in VS Code

The file should open in your browser:

You can inspect the React code in the browser’s Debugger
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.

import() is not supported for URLs with the file:/// protocol

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.

Inspecting the original JSX code in the browser Debugger

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.

Without the .map files, the browser Debugger is harder to use

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.

Comparing the compiled file with the raw JSX

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:

  1. Productivity now

    You’ll complete your projects faster with the tools that React provides.

  2. Real-world context

    The production-ready projects that you will be working on are far more complex than what is covered by this tutorial

  3. 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:

  1. The entry-point bundle for App.jsx, with all the code for running the pure JavaScript version of App.jsx
  2. Code shared between the compiled versions of App.jsx and LazyComponent.jsx
  3. 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:

Before the Import Placeholder Module button is pressed

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.

After the Placeholder Module button is loaded

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:

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

  2. 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 property default, whose value is the anonymous function from placeholder.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.

  1. I’ve explicitly used a Promise here, rather than the sweeter async/await syntax that you might already know. This is simply to underline the fact that the output of import() is a Promise which must be resolved before the imported module can be used.

  2. Line 14 then receives this default function and renames it to renderGame and then executes it. Which is how the textContent of the #game-space div 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.

“Placeholder module evaluated” is logged to the console only once

Once the browser has downloaded the file and evaluated it, it will not ask for it again, even if you disable the browser cache.

The Network tab only shows one download, even when caching is disabled
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 default property 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:

Importing an interactive mini-game
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.

The Three Games web app, with none of the games loaded

You can click on each button in turn, to see the game that the app imports and displays:

Which word is the odd one out?
Which picture matches the word?
Which word matches the picture?

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:

  1. There are three buttons which are given the same event listener
  2. The url of the module that is loaded is obtained using the id of 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:

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:

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:

  1. Everything is included in one file.

  2. The data used by the game is defined inside the file itself:

    const words = [
      "ball",
      "bear",
      "bell",
      "boar",
      "fair",
      "fall",
      "fell",
      "four"
    ]
  3. There are helper functions to control the logic of the game, such as:

    const checkClick = ( target, word, button, div ) => {
    // 9 lines skipped
    }
  4. The UI is generated using the function that is exported as default:

    export default function renderGame(root) {
    // 40 lines skipped
    }
  5. 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() from word-game.js into helpers.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 to helpers.js
  • 1 point for making it possible to customize the name of this button
  • 3 points for noticing that the differences-game.js module can use the same clear(), checkClick() and newButton() functions as the other two games, so you can import these from helpers.js
  • 4 points for expanding styles.css so 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.js and word-game.js to 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: