Fabien Huet

Fabien
Huet

Web ninja //
CTO on demand

Home Github About me

šŸŽ“ Webpack, loaders, Babel, sourcemaps, React, Hot Module Reload, TypeScript, modules, code splitting and lazy loading full tutorial to transpile and bundle your code

in React, JavaScript, Webpack

So many keywords in this titleā€¦ The ecosystem of web development today appears complicated, and you may feel overwhelmed. So let me take you in a step-by-step tutorial to learn about each of those words and the related technologies. This article is mainly about how to use webpack to bundle your code into static assets.

TL;DR: here is the Github repo with the result. You can clone it and use it as a starter kit for single page applications with Webpack, Babel, React, TypeScript, HMR and code-splitting.

Make sure you have node and npm installed. Check with node -v and npm --version. You should have at least version 9 for the first and 6 for the second.

Get started with Webpack

Use default values

Create a folder, run npm init inside to initialize a package.json file. Then install webpack and webpack command line interface in this folder and add it to your dev dependencies with npm install -D webpack webpack-cli. You should now have this lines in your package.json file:

"devDependencies": {
    "webpack": "^4.8.3",
    "webpack-cli": "^2.1.4"
}

And many folders in your node_modules folder.

To use a globally installed npm package xxx (installed with npm install -g xxx), run npm xxx. To use a locally installed npm package, run npx xxx.

Create an index.js file in /src containing console.log('Hello world!');. Then run your first webpack command npx webpack src/index.js --output dist/index.js. Congratulations, you have just used webpack! The terminal should display something like that:

Hash: bc5be164b8f35dda104c
Version: webpack 4.8.3
Time: 14ms
Built at: 2018-05-25 14:27:14
    Asset       Size  Chunks             Chunk Names
index.js  564 bytes       0  [emitted]  main
Entrypoint main = index.js
[0] ./src/index.js 21 bytes {0} [built]

You can see the files that you have bundled and the output.

The bundle time and other data may be useful for debugging. You can ask for more informations, like the dependencies list by passing the --verbose flag.

Because you didnā€™t provide any configuration, it uses production defaults values. The JavaScript result in dist/index.js may seem hard to read. Pass the mode flag with a none value to have an output easier to read: npx webpack src/index.js --output dist/index.js --mode none.

Import other files with harmony modules imports

In /src, create a myModule.js file with console.log('My module'); and import it in index.js with import './myModule';.

Run the bundle command and you will see both files in the list.

index.js  3.04 KiB       0  [emitted]  main
Entrypoint main = index.js
[0] ./src/index.js 51 bytes {0} [built]
[1] ./src/myModule.js 26 bytes {0} [built]

You can also open /dist/index.js to check that both console.log are in the bundle.

Load the code into an HTML file

Your code is not super useful without an html shell. In /src, create an index.html file with:

<html>
<head></head>
<body>
    <script src="../dist/index.js"></script>
</body>
</html>

If you open the file in your browser and check the console, you can read the following messages.

index.js:89 My module
index.js:82 Hello world!

Configure webpack

The more complicated things your build, the more flags you need. At some point, this becomes unmanageable. You need a write a file to configurate the call. To configure webpack, you add a webpack.config.js file at the root of your folder. This file is a node.js file so you can do anything you would be able to do in any node file. You can also write it as a json file ; but the node file it more powerful.

Remake your base configuration

To replicate this command npx webpack src/index.js --output dist/index.js --mode none in a config file, you need to write this code:

const path = require('path');

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist/'),
        filename: 'index.js'
    },
    mode: 'none'
};

Then run npx webpack and webpack uses your config file with the same result as precedently.

Wrap it with an npm script

This is unrelated to webpack, but npm offers a neat way to launch command. You can add commands in the "scripts" object of your npm package:

"scripts": {
    "build": "npx webpack"
},

You can now run npm run build to launch your build command. You can also compose the scripts for a more advanced use.

Use webpack to enable interactive coding

Watch mode

When you code, you donā€™t want to reload your page each time you made a change to your files. Run your webpack command with the --watch flag: npx webpack --watch src/index.js --output dist/index.js --mode none. The output looks like that:

Webpack is watching the filesā€¦

Hash: fac002cbaae2a99f656c
Version: webpack 4.8.3
Time: 17ms
Built at: 2018-05-25 15:32:18
   Asset      Size  Chunks             Chunk Names
index.js  3.04 KiB       0  [emitted]  main
Entrypoint main = index.js
[0] ./src/index.js 51 bytes {0} [built]
[1] ./src/myModule.js 26 bytes {0} [built]

The process stays active. If you go to you index.js file and change Hello world! to Hello Earth!, this is added to your prompt:

Hash: 1b86e8df6f9e24f4ab30
Version: webpack 4.8.3
Time: 1ms
Built at: 2018-05-25 15:33:51
   Asset      Size  Chunks             Chunk Names
index.js  3.04 KiB       0  [emitted]  main
Entrypoint main = index.js
[0] ./src/index.js 47 bytes {0} [built]
[1] ./src/myModule.js 26 bytes {0} [built]

You bundle automatically rebuilt.

Notice that the build time went down from 17ms to 1ms. 17ms seems short, but with thousands of files, it can go up to a few seconds. Webpack has an in-memory caching mechanism, so when you change just one file, it rebuilds only that file.

Watch is a good tool, but you will user a more powerful one: webpack dev server.

Getting started with webpack dev server and auto-reload

Now that your code bundles automatically, you want that new bundle version to be live pushed in the browser. The first thing you need to do is adding webpack dev server to your project with npm install -D webpack-dev-server. Now start your server with npx webpack-dev-server. Your prompt starts with:

ā„¹ ļ½¢wdsļ½£: Project is running at http://localhost:8080/
ā„¹ ļ½¢wdsļ½£: webpack output is served from /

So head to http://localhost:8080/ to see the listing of your code. You can click on src to display your index.html file. However, you want it to display the content of /src by default. So go to the config file and in the exports, add:

devServer: {
    contentBase: path.resolve(__dirname, 'src')
}

Now if you kill and restart the server, you have this additional line ā„¹ ļ½¢wdsļ½£: Content not from webpack is served from /Volumes/Projects/webpack-tutorial-example/src in the prompt. So when you go to http://localhost:8080/, you will see your index.html page.

If you delete the content of dist, you see that there is nothing in the console anymore. Letā€™s fix that.

Webpack dev server serves the content from an in-memory version of the generated bundle and exposes it here http://localhost:8080/webpack-dev-server. So, you need to make it serve in /dist. Update your config file with a public path:

devServer: {
    contentBase: path.resolve(__dirname, 'src'),
    publicPath: '/dist/'
}

Change your HTML to call the right script <script src="dist/index.js"></script>. Now kill the server, restart with npx webpack-dev-server, go to http://localhost:8080/ and check the console. Both messages should be displayed. Now if you change index to log console.log('Hello me!');, you can see that your entire page reloads automatically.

Restart the dev server on configurations changes

Nodemon is a commonly used watcher for node. You can use it to restart your server when the config file changes. It can be nice when you are playing with the configuration. Start with npm install -D nodemon and edit your package.json file.

"scripts": {
    "build": "npx webpack",
    "server": "webpack-dev-server",
    "server-autoreload":
    "nodemon --watch webpack.config.js -x webpack-dev-server"
},

When you type npm run server-autoreload, your dev server restarts each time you update your config file. There is a small overhead, so when you finished your configuration, you may use directly npm run server.

Watch static assets

If you modify the index.html file, nothing happens automatically. You have to reload the page manually because this file is currently a static asset and not part of a bundle.

To enable watching for static assets, add it to your configuration file:

devServer: {
    contentBase: path.resolve(__dirname, 'src'),
    publicPath: '/dist/',
    watchContentbase: true
}

Kill and restart and you can now live update the HTML too!

All the configuration possibilities are documented on the webpack site

Introducing the HRM, hot module reload

Hot reload is awesome for development. It is faster than reloading your page, however the key point is not the speed but the persistent state. Your application keeps its state when you update the code!

In your config file, add the webpack import const webpack = require('webpack'); and then update your devServer configuration.

module.exports = {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist/'),
        filename: 'index.js',
        publicPath: '/dist/'
    },
    mode: 'development',
    devServer: {
        contentBase: path.resolve(__dirname, 'src'),
        publicPath: '/dist/',
        watchContentBase: false,
        hotOnly: true
    },
    plugins: [
        new webpack.NamedModulesPlugin(),
        new webpack.HotModuleReplacementPlugin()
    ]
}

output.publicPath need to be set to /dist/ so the HRM can find the files.Disable the watchContentBase so an update to the files in src wonā€™t trigger a reload, then set the triggers to hotOnly and add a couple of plugins. webpack.HotModuleReplacementPlugin() will perform the actual hot reload and webpack.NamedModulesPlugin() is there, so your plugins have a name and not only an id. The naming is useful for debugging.

You can now try to make changes in your index.js file. There is no full-page reload anymore. However, you have this warning:

Ignored an update to unaccepted module ./src/index.js -> 0
[HMR] The following modules couldn't be hot updated: (They would need a full reload!)
[HMR]  - ./src/index.js

This warning appears because you have not setup any behavior when the module reloads.

Add a behavior in the js file on hot reload

In your js file, simply add:

if (module.hot) {
    module.hot.accept();
}

Accept the reload when it comes. Now, if you change the message to console.log('Hello you!'); you can see in your console that the logged message changed without a global page reload.

In production, HMR does not do anything. So any code inside you if block runs only in dev mode. You can use that block to reinitialize some state or to inject any code you need on refresh.

Configure environment

Development code and Production code wonā€™t look the same. Production code need to be minified, uglified and striped from development tools like the HMR. Right now, when you run npm run build (which calls npx webpack) your bundle is 28.2 Ko. This bundle is too heavy for a couple of console.log.

index.js  28.2 KiB    main  [emitted]  main

The easiest way is to use an environment variable. You can pass environment variables to webpack by adding an env flag; under the hood, webpack uses yargs to pass the arguments. If you pass the flag webpack --env.foo=bar --env.baz=qux you are able to get the values in module.exports.

module.exports = env => {
    console.log(env.foo); // 'bar'
    console.log(env.baz); // 'qux'
}

Wrap your config file according to this pattern and pass the env.NODE_ENV flag. You can use any key, but most people use NODE_ENV. The npm scripts looks like that:

"scripts": {
    "build": "webpack --env.NODE_ENV=production",
    "server": "webpack-dev-server --env.NODE_ENV=development",
    "server-autoreload": "nodemon --watch webpack.config.js -x webpack-dev-server --  --env.NODE_ENV=development"
},

In your configuration file, you add the isDevelopment boolean. Then set the mode conditionally mode: isDevelopment ? 'development' : 'production',. Finally, initialize the plugin before the default configuration. The new build from npm run build is 0.65 Ko. Better than the 28.2Ko you had before! index.js 652 bytes 0 [emitted] main

const path = require('path');
const webpack = require('webpack');

module.exports = env => {
console.log('NODE_ENV:', env.NODE_ENV);
    const isDevelopment = env.NODE_ENV === 'development';

    const plugins = [];
    if (isDevelopment) {
        plugins.push(new webpack.NamedModulesPlugin());
        plugins.push(new webpack.HotModuleReplacementPlugin());
    }

    return {
        entry: './src/index.js',
        output: {
            path: path.resolve(__dirname, 'dist/'),
            filename: 'index.js',
            publicPath: '/dist/'
        },
        mode: isDevelopment ? 'development' : 'production',
        devServer: {
            contentBase: path.resolve(__dirname, 'src'),
            publicPath: '/dist/',
            watchContentBase: false,
            hotOnly: true
        },
        plugins
    };
};

The if (module.hot)

In your index.js file, you have that code :

if (module.hot) {
    module.hot.accept();
}

This code is development only, and you donā€™t want to see it in production. Donā€™t worry; it wonā€™t be there. Because if(module.hot) is transpiled to if(false) in your production bundle and you minifier treats it as dead code and eliminates it.

Cool feature : overlay debug

Add overlay: true, to your devServer configuration.

devServer: {
    overlay: true,
    ...
}

Then add a typo in your code like removing the last single quote in the console log, save and look what happened. Your browser should display this warning in an overlay:

Failed to compile.

./src/index.js
Module parse failed: Unterminated string constant (3:12)
You may need an appropriate loader to handle this file type.
| import './myModule';
|
| console.log('Hello world!);-

Cool! When you made an error that breaks your code, webpack tells you live what error you made and where. This is super nice for debugging when you are coding an UI.

Expose your local dev server to the world

Right now, your server is only available on your local machine. You may want to expose the server to other machines on your local network. Like co-workers machines, mobile devices or computer running another OS to test your application. Simply add host: '0.0.0.0', to your devServer configuration

devServer: {
    host: '0.0.0.0',
    ...
},

Then find your local IPV4 Adress. It should look like that: 192.168.1.32; and you can visit http://192.168.1.32:8080/ from any device connected to the same network. Usually a smart phone in wifi.

Clean dist before you build or develop

Install the clean webpack plugin with npm i -D clean-webpack-plugin and add it to your configuration file

...
const CleanWebpackPlugin = require('clean-webpack-plugin');

module.exports = env => {
    ...
    const plugins = [new CleanWebpackPlugin(['dist'])];
    ...

Pass an array of folders to remove before any build or server initialization.

This helps you to clean tokenized builds.

Get rid of the extensions for the imports

Instead of import App from './App.js';, you want to write import App from './App';. To do that, simply add the extensions in the resolver. In webpack.config.js:

...
mode: isDevelopment ? 'development' : 'production',
resolve: {
    extensions: ['.js', '.json', '.ts', '.jsx', '.tsx']
},
devServer: {
...

Using sourcemaps

The code generated for development or production can become barely debuggable. When you have much code bundled, it can be problematic to find the bugs. So, add the sourcemap in your webpack.config.js:

...
{
entry: './src/index.js',
devtool: 'cheap-module-eval-source-map',
output: {
...

Generate a runtime error in index.js by adding console.log(hello);. The console displays a reference error. When you click on the index.js link, you see the actual source code you wrote and not the transpiled code.

You can use eval-source-map to get a longer build with more information; or a hidden-source-map for production to avoid sending it to clients. The webpack documentation gives more details.

Use babel loader to transpile form ESNext to es5

Install babel

Modern JavaScript is awesome. The language has evolved for the better in the last few years, but many users still use old browsers that donā€™t support those more modern features. Use Babel to transpile the modern JavaScript you write into the old JavaScript that is the most widely supported. You can check their Github repo. At first, install babel with:

npm i -D babel-loader @babel/core @babel/preset-env

About preset env and compatibility

Babel is a set of plugins to transpile code. @babel/preset-env is a powerful preset that allows you to choose precisely the compilation target for your production code. It uses the syntax of browserslist.

If you want to support ie9+, firefox 10+, chrome 5+ and iOS7 for example, here is what you will have to write :

ie >= 9, Firefox >= 10, Chrome >= 5, iOS >= 7

You can also have a simple cover 99% and babel adjusts its settings to make sure your transpiled code works for 99% of the users in the world.

There is no good general rule. If you work for a public service, target ie >= 6. If your work for startups, target last 2 versions (for each browser). The more browsers you support, the bigger your bundle is. However, you or your client may have specific requirements. Think about mobile users.

You can check on the browserl.ist site your configuration. The basic is default. Try > 0.1% for example, it shows you the list of everything you support with this setting.

Add some ES6

Nothing fancy, but letā€™s add some es6 to your index.js file:

const myName = 'Fabien';
const greetings = `Hello, Iā€™m ${myName}`;
console.log(greetings);

const and template litterals cannot be interpreted by older browser. So you need to transpile it.

Add the module and the preset

In your webpack.config.js file, add the following module:

module.exports = env => {
    ...
    return {
        ...
        module: {
            rules: [
                {
                    test: /\.js$/,
                    exclude: /(node_modules)/,
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                [
                                    '@babel/preset-env',
                                    {
                                        modules: false,
                                        debug: true,
                                        target: {
                                            browsers: ['cover 99%']
                                        }
                                    }
                                ]
                            ]
                        }
                    }
                }
            ]
        },
        ...
    };
};

Now, when you npm run build, the following code is generated in dist/index.js:

var o="Hello, Iā€™m ".concat("Fabien");console.log(o)

const became var and the template literal now uses concat. ie9 can now read the modern code you wrote!

The options object can be configurated in a .babelrc.js file.

Go for a whitelist instead of a blacklist for the transpiled files

exclude: /(node_modules)/ will exclude the node modules. However, you may want to be more restrictive and go for a whitelist. Better use include: path.resolve(__dirname, 'src'),.

This is to avoid processing files that may exist in other folders like /public, /assets, testsā€¦ or any folder you may add later.

Use a .babelrc file

To simplify your webpack.config.js file, remove rules.use.options and put it in the .babelrc.js file:

module.exports = {
    presets: [
    [
        '@babel/preset-env',
        {
            modules: false,
            debug: true,
                target: {
                    browsers: ['cover 99%']
                }
            }
        ]
    ]
};

Disable the transform in development

You probably use a modern browser to develop. So you donā€™t need to transpile to much of your code. Letā€™s split the modules for dev and production.

const path = require('path');
const webpack = require('webpack');

module.exports = env => {
    const isDevelopment = env.NODE_ENV === 'development';

    const plugins = [];
    if (isDevelopment) {
    plugins.push(new webpack.NamedModulesPlugin());
    plugins.push(new webpack.HotModuleReplacementPlugin());
    }

    let module = {};
    if (!isDevelopment)
    module = {
        rules: [
        {
            test: /\.js$/,
            exclude: /(node_modules)/,
            use: {
            loader: 'babel-loader'
            }
        }
        ]
    };

    return {
    entry: './src/index.js',
    output: {
        path: path.resolve(__dirname, 'dist/'),
        filename: 'index.js',
        publicPath: '/dist/'
    },
    mode: isDevelopment ? 'development' : 'production',
    devServer: {
        contentBase: path.resolve(__dirname, 'src'),
        hotOnly: true,
        overlay: true,
        publicPath: '/dist/',
        watchContentBase: false
    },
    module,
    plugins
    };
};

The development code is not transpiled anymore and is processed way faster.

Use aliases for absolute imports

When you import a module in your code, you can use relatives path imports like ../../Components/MyComponent or absolute imports like /src/Components/MyComponent. Both relative and absolute import used that way can cause trouble when refactoring. However, you can use aliases to help you:

...
resolve: {
    alias: {
        Components: path.resolve(__dirname, 'src/Components/'),
    },
...

It may save yourself many troubles.

Add React to your app

Configure the loader

You write React application with JSX. JSX is not valid JavaScript. It has to be transpiled. So letā€™s add the loader for react with npm i -D @babel/preset-react. and add it in your .babelrs.js file:

module.exports = {
    presets: [
        [
            '@babel/preset-env',
            ...
        ],
        '@babel/preset-react'
    ]
};

Our js file is now processed first by the react loader and then by the babel loader.

Webpack uses loader in a reverse order.

Initialize React

Add React to your packages with npm i -S react react-dom, and initialize a simple React application in index.js:

import React from 'react';
import ReactDom from 'react-dom';

class App extends React.Component {
    render() {
        return <div>Hello world!!</div>;
    }
}

let root = document.getElementById('root');
if (!root) {
    root = document.createElement('div');
    root.id = 'root';
    document.body.appendChild(root);
}

ReactDom.render(<App />, root);

if (module.hot) {
    module.hot.accept();
}

The react application works. You can now start coding. Notice that when you update your code, the HRM live update the content.

React HRM

Live reload is not good enough. Letā€™s add a simple state and a counter to the application:

render() {
    return (
    <div>
        Counter : {(this.state || {}).counter || 0}
        <br />
        <button
            onClick={() =>
                this.setState({ counter: ((this.state || {}).counter || 0) + 1 })
            }
        >
            add
        </button>
    </div>
    );
}

When you update your code, the live reload erases the state. You want to use React HMR to avoid that. npm i -D react-hot-loader.

In the .babelrc.js, add

    ...
    ],
    plugins: ['react-hot-loader/babel']
};

Put the application code in an App.js file:

import React from 'react';
import { hot } from 'react-hot-loader';

class App extends React.Component {
    render() {
        return (
            <div>
                Counter: {(this.state || {}).counter || 0}
                <br />
                <button
                    onClick={() =>
                        this.setState({ counter: ((this.state || {}).counter || 0) + 1 })
                    }
                >
                    add
                </button>
            </div>
        );
    }
}
export default hot(module)(App);

The index.js file renders the App.

import React from 'react';
import ReactDom from 'react-dom';
import { hot } from 'react-hot-loader';
import App from './App.js';

let root = document.getElementById('root');
if (!root) {
      root = document.createElement('div');
      root.id = 'root';
      document.body.appendChild(root);
}

ReactDom.render(<App />, root);

Now you have a hot module reload; a faster live reload that keeps the state.

Use TypeScript

TypeScript is fantastic, and you should use it. Wel, you donā€™t have to, but if you want, letā€™s see how you can do this.

Use TypeScript in webpack.config.js

Start by installing some packages with npm i -D typescript ts-node @types/node @types/webpack.

The @types packages gives you autocompletion for node and webpack.

Then change the extension of your config file to webpack.config.ts. Thatā€™s it; you can use TypeScript.

Donā€™t forget to update to .ts in your server-autoreload script in package.json.

Use TypeScript in your React files

Start by installing the loader with npm i -D @babel/preset-typescript and to add it in the presets:

presets: [
    [
        '@babel/preset-env',
        {
            modules: false,
            //debug: true,
            target: {
                browsers: ['cover 99%']
            }
        }
    ],
    '@babel/preset-react',
    '@babel/typescript'
],

Let me introduce two extensions. .tsx for all files that contains JSX and .ts for all the other JavaScript files.

Right now, both your index.js and App.js file contain JSX, so they both need the .tsx extension.

You need to change the test in your module rules. test: /\.js$/ must become test: /\.(tsx?)$/i that will return all the ts and tsx file. And to change the entry to entry: './src/index.tsx'. You can now use TypeScript in your React files.

You should install the types for React and react-dom with npm i -D @types/react @types/react-dom. I love the autocomplete.

Install another plugin commonly used in react npm i -D @babel/plugin-proposal-class-properties and add it in the plugins list plugins: ['@babel/plugin-proposal-class-properties', 'react-hot-loader/babel']. It allows you to use things like state = {...}; in the react classes.

TypeScript and React

Learning TypeScript is out of the scope of this tutorial, but here is what the App.tsx file should look like when typed.

import React from 'react';
import { hot } from 'react-hot-loader';

export interface AppPropsInterface {}
export interface AppStateInterface {
    counter: number;
}

class App extends React.Component<AppPropsInterface, AppStateInterface> {
    state = {
        counter: 0
    };

    private incrementCounter = (
        event: React.MouseEvent<HTMLButtonElement>
    ): void => {
        this.setState({ counter: this.state.counter + 1 });
    };

    public render(): JSX.Element {
        return (
            <div>
                Counter: {this.state.counter}
                <br />
                <button onClick={this.incrementCounter}>add</button>
            </div>
        );
    }
}

export default hot(module)(App);

A bit more about typescript configuration

You can create a tsconfig.json at the root of your project to specify the behavior of the TypeScript compiler. Since babel 7 supports TypeScript, the idea is to use TypeScript only for the tooling. Here are my options:

{
    "compilerOptions": {
        "noEmit": true /* Most important thing: will not compile anything */,
        "jsx": "preserve" /* Do not compile your JSX */,
        "allowSyntheticDefaultImports": true /* To import without "* as" */,
        "alwaysStrict": true,
        "target": "ESNEXT",
        "strictNullChecks": true /* You have to add "| null" in your type when needed */,
        "strictFunctionTypes": true /* Enable strict checking of function types. */,
        "noImplicitThis": true /* Raise error on 'this' expressions with an implied 'any' type. */,
        "noUnusedLocals": true /* Report errors on unused locals. */,
        "noUnusedParameters": true /* Report errors on unused parameters. */,
        "noImplicitReturns": true /* Report error when not all code paths in function return a value. */,
        "noFallthroughCasesInSwitch": true /* Report errors for fallthrough cases in switch statement. */,
        "moduleResolution":
          "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */,
        "baseUrl":
          "./src" /* Base directory to resolve non-absolute module names. */
    }
}

Dynamic import

A compelling feature of webpack is code splitting. You can setup dynamic import for components and they are not included in the base bundle but loaded only when required. At first, install the plugin with npm i -D @babel/plugin-syntax-dynamic-import and add it to the plugin list:

plugins: [
    '@babel/plugin-syntax-dynamic-import',
    '@babel/plugin-proposal-class-properties',
    'react-hot-loader/babel'
]

Create a /src/SubView.tsx file with a functional component:

import React from 'react';
export interface PropsInterface {
    counter: number;
}
const SubView: React.SFC<PropsInterface> = ({ counter }) => (
    <div>This is a subview created when the counter was {counter}</div>
);
export default SubView;

And then, update App.tsx as follow:

export interface AppStateInterface {
    counter: number;
    subView: JSX.Element | null;
}

class App extends React.Component<AppPropsInterface, AppStateInterface> {
    state = {
        counter: 0,
        subView: null
    };

    private incrementCounter = (): void => {
        this.setState({ counter: this.state.counter + 1 });
    };

    private addSubView = (): void => {
        import('./SubView')
            .then(module => {
                const Component = module.default as React.SFC;
                this.setState({
                    subView: <Component counter={this.state.counter} />
                });
            })
        .catch(error => console.error(error));
    };

    public render(): JSX.Element {
        return (
        <div>
            Counter: {this.state.counter}
            <br />
            <button onClick={this.incrementCounter}>add</button>
            <br />
            <button onClick={this.addSubView}>add sub view</button>
            {this.state.subView}
        </div>
        );
    }
}

Full reload and check your network panel. When you click the ā€œadd subviewā€ button, there is a 0.index.js XHR call. The code is your little module. It was not included in the main bundle and need to be called.

Splitting allows you to reduce the final bundle size significantly.

Your linter: TSLint

Because of TypeScript, you want to use TSLint instead of ESLint. I highly encourage the AirBnB rules. You may not agree with everything, but they are so commonly used that nobody feels lost when using them. Install with npm i -D tslint.

Make sure to have the right extensions installed for your EDI.

Include the HTML file in your build

You need to copy the file from /src and paste it to /dist. This is done using the Copy Webpack Plugin. Install with npm i -D copy-webpack-plugin. This is used only in production, so update the webpack.config.ts file as follow:

...
if (isDevelopment) {
    plugins.push(new webpack.NamedModulesPlugin());
    plugins.push(new webpack.HotModuleReplacementPlugin());
} else {
    plugins.push(
    new CopyWebpackPlugin(
        [
            {
                from: 'src/index.html',
                to: path.resolve(__dirname, 'dist')
            }
        ],
            {}
        )
    );
}
...

Now when you npm run build, the TML is copied in the /dist folder.

If you have various assets (picture, pdfā€¦), this is how you include them in your build.

Milestone reached!

You can deploy the content of your dist folder in any FTP, you have a functioning page!

Optimize the production build

Minify the HTML

Minifiy the HTML is a super small win, but 1 bit is 1 bit! First, install the minifier with npm i -D html-minifier. Then add the transform in your plugin:

...
new CopyWebpackPlugin(
    [
        {
            from: 'src/index.html',
            to: path.resolve(__dirname, 'dist'),
            transform(htmlAsBuffer) {
                return Buffer.from(
                    HTMLMinifier.minify(htmlAsBuffer.toString('utf8'), {
                        collapseWhitespace: true,
                        collapseBooleanAttributes: true,
                        collapseInlineTagWhitespace: true
                    })
                );
            }
        }
    ],
    {}
)
...

You can find all the minification options in the github repo of html-minifier.

When you npm run build, your HTML is now minified.

Uglify the JavaScript

You can try babel minify, but I use Uglify for now. Install the plugin with npm i -D uglifyjs-webpack-plugin. Then add it to your plugins list for the dev build:

...
if (isDevelopment) {
    plugins.push(new webpack.NamedModulesPlugin());
    plugins.push(new webpack.HotModuleReplacementPlugin());
} else {
    plugins.push(
        new UglifyJsPlugin({
            parallel: true,
            sourceMap: true,
            cache: true
        })
    );
    plugins.push(
    ...

When you npm run build, your JS is now uglified.

Donā€™t forget to add sourceMap: true to keep generating the sourcemaps files. parallel and cache speeds up the build.

Make sourcemaps external

Webpack inlines your sourcemaps in their parent files. Which is practical for your development environment. However, it adds much weight to the bundles. You declared devtool: 'cheap-module-eval-source-map', to ask for that inlining. Letā€™s use the source map dev tool plugin to gain a more fine-grained control of the source maps generation.

plugins.push(
    new webpack.SourceMapDevToolPlugin({
        filename: 'sourcemaps/[name].js.map',
        lineToLine: true
    })
);

You also need to remove the 'react-hot-loader/babel' from your babel settings and the devtools: 'cheap-module-eval-source-map' from the exported plugin to allow sourcemaps generation.

Split the configurations

Our production and development settings are now different enough to justify a split. Make two files webpackConfig/development.js and webpackConfig/production.js.

Our main webpack.config.ts will just import and export the production and development settings:

const path = require('path');
const productionConfig = require('./webpackConfig/production');
const developmentConfig = require('./webpackConfig/development');

module.exports = env => {
    if (env.NODE_ENV === 'production')
        return productionConfig(env, path.resolve(__dirname));

    if (env.NODE_ENV === 'development')
        return developmentConfig(env, path.resolve(__dirname));
};

Note that you pass the path from the main file to avoid having to look up for it in the config files.

The development config will look like that:

const webpack = require('webpack');

function buildDevelopementConfig(env, dirname) {
    console.log('Start build for NODE_ENV: ', env.NODE_ENV);

    return {
        entry: dirname + '/src/index.tsx',
        devtool: 'cheap-module-eval-source-map',
        output: {
            path: dirname + '/dist',
            filename: 'index.js',
            publicPath: '/',
            sourceMapFilename: 'bundle.map'
        },
        mode: 'development',
        resolve: {
            extensions: ['.js', '.json', '.ts', '.jsx', '.tsx']
        },
        devServer: {
            host: '0.0.0.0',
            contentBase: dirname + '/src',
            hotOnly: true,
            overlay: true,
            publicPath: '/',
            watchContentBase: false
        },
        module: {
            rules: [
                {
                    test: /\.(tsx?)$/i,
                    include: dirname + '/src',
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                [
                                    '@babel/preset-env',
                                    {
                                        modules: false,
                                        debug: true,
                                        target: {
                                            browsers: ['cover 99%']
                                        }
                                    }
                                ],
                                '@babel/preset-react',
                                '@babel/typescript'
                            ],
                            plugins: [
                                '@babel/plugin-syntax-dynamic-import',
                                '@babel/plugin-proposal-class-properties',
                                'react-hot-loader/babel'
                            ]
                        }
                    }
                }
            ]
        },
        plugins: [
            new webpack.NamedModulesPlugin(),
            new webpack.HotModuleReplacementPlugin()
        ]
    };
}

module.exports = buildDevelopementConfig;

And the production configuration will look like this:

const webpack = require('webpack');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const HTMLMinifier = require('html-minifier');
const UglifyJsPlugin = require('uglifyjs-webpack-plugin');

function buildProductionConfig(env, dirname) {
    console.log('Start build for NODE_ENV: ', env.NODE_ENV);

    return {
        entry: dirname + '/src/index.tsx',
        output: {
            path: dirname + '/dist',
            filename: 'index.js',
            publicPath: '/',
            sourceMapFilename: 'bundle.map'
        },
        mode: 'production',
        resolve: {
            extensions: ['.js', '.json', '.ts', '.jsx', '.tsx']
        },
        module: {
            rules: [
                {
                    test: /\.(tsx?)$/i,
                    include: dirname + '/src',
                    use: {
                        loader: 'babel-loader',
                        options: {
                            presets: [
                                [
                                    '@babel/preset-env',
                                    {
                                        modules: false,
                                        debug: true,
                                        target: {
                                            browsers: ['cover 99%']
                                        }
                                    }
                                ],
                                '@babel/preset-react',
                                '@babel/typescript'
                            ],
                            plugins: [
                                '@babel/plugin-syntax-dynamic-import',
                                '@babel/plugin-proposal-class-properties'
                            ]
                        }
                    }
                }
            ]
        },
        plugins: [
            new CleanWebpackPlugin(['dist']),
            new UglifyJsPlugin({
                parallel: true,
                sourceMap: true,
                cache: true
            }),
            new webpack.SourceMapDevToolPlugin({
                filename: 'sourcemaps/[name].js.map',
                lineToLine: true
            }),
            new CopyWebpackPlugin(
                [
                    {
                        from: dirname + '/src/index.html',
                        to: dirname + '/dist',
                        transform(htmlAsBuffer) {
                            return Buffer.from(
                                HTMLMinifier.minify(htmlAsBuffer.toString('utf8'), {
                                    collapseWhitespace: true,
                                    collapseBooleanAttributes: true,
                                    collapseInlineTagWhitespace: true
                                })
                            );
                        }
                    }
                ],
                {}
            )
        ],
        performance: {
            hints: 'warning'
        }
    };
}

module.exports = buildProductionConfig;

Analyze my bundles

It is always interesting to visualize the impact of your imports on your production bundles. Letā€˜s install Webpack Bundle Analyzer with npm i -D webpack-bundle-analyzer. Then add it to your production configuration with:

...
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');

...
plugins: [
    new BundleAnalyzerPlugin({ analyzerMode: 'static' }),
...

Now when you npm run build a page opens and display the detailed weight of your bundles and the related imports.

Mission complete!

You have now a complete development environment to start building a single page web application with React and TypeScript. You can also deploy your build directly on an FTP server to expose it to the world!