Web front-end
11 minute read

Maintain Control: A Guide to Webpack and React, Pt. 1

Michael is a senior full-stack developer specializing in front-end development with React and TypeScript.

When starting a new React project, you have many templates to choose from: Create React App, react-boilerplate, and React Starter Kit, to name a few.

These templates, adopted by thousands of developers, are able to support application development at a very large scale. But they leave the developer experience and bundle output saddled with various defaults, which may not be ideal.

If you want to maintain a greater degree of control over your build process, then you might choose to invest in a custom Webpack configuration. As you’ll learn from this Webpack tutorial, this task is not very complicated, and the knowledge might even be useful when troubleshooting other people’s configurations.

Webpack: Getting Started

The way we write JavaScript today is different from the code that the browser can execute. We frequently rely on other types of resources, transpiled languages, and experimental features which are yet to be supported in modern browsers. Webpack is a module bundler for JavaScript that can bridge this gap and produce cross browser–compatible code at no expense when it comes to developer experience.

Before we get started, you should keep in mind that all code presented in this Webpack tutorial is also available in the form of a complete Webpack/React example configuration file on GitHub. Please feel free to refer to it there and come back to this article if you have any questions.

Base Config

Since Legato (version 4), Webpack does not require any configuration to run. Choosing a build mode will apply a set of defaults more suitable to the target environment. In the spirit of this article, we are going to brush those defaults aside and implement a sensible configuration for each target environment ourselves.

First, we need to install webpack and webpack-cli:

npm install -D webpack webpack-cli

Then we need to populate webpack.config.js with a configuration featuring the following options:

  • devtool: Enables source-map generation in development mode.
  • entry: The main file of our React application.
  • output.path: The root directory to store output files in.
  • output.filename: The filename pattern to use for generated files.
  • output.publicPath: The path to the root directory where the files will be deployed on the web server.
const path = require("path");

module.exports = function(_env, argv) {
  const isProduction = argv.mode === "production";
  const isDevelopment = !isProduction;

  return {
    devtool: isDevelopment && "cheap-module-source-map",
    entry: "./src/index.js",
    output: {
      path: path.resolve(__dirname, "dist"),
      filename: "assets/js/[name].[contenthash:8].js",
      publicPath: "/"
    }
  };
};

The above configuration works fine for plain JavaScript files. But when using Webpack and React, we will need to perform additional transformations before shipping code to our users. In the next section, we will use Babel to change the way Webpack loads JavaScript files.

JS Loader

Babel is a JavaScript compiler with many plugins for code transformation. In this section, we will introduce it as a loader into our Webpack configuration and configure it for transforming modern JavaScript code into such that is understood by common browsers.

First, we will need to install babel-loader and @babel/core:

npm install -D @babel/core babel-loader

Then we’ll add a module section to our Webpack config, making babel-loader responsible for loading JavaScript files:

@@ -10,6 +10,22 @@ module.exports = function(_env, argv) {
       path: path.resolve(__dirname, "dist"),
       filename: "assets/js/[name].[contenthash:8].js",
       publicPath: "/"
+    },
+    module: {
+      rules: [
+        {
+          test: /\.jsx?$/,
+          exclude: /node_modules/,
+          use: {
+            loader: "babel-loader",
+            options: {
+              cacheDirectory: true,
+              cacheCompression: false,
+              envName: isProduction ? "production" : "development"
+            }
+          }
+        }
+      ]
     }
   };
 };

We are going to configure Babel using a separate configuration file, babel.config.js. It will use the following features:

  • @babel/preset-env: Transforms modern JavaScript features into backwards-compatible code.
  • @babel/preset-react: Transforms JSX syntax into plain-vanilla JavaScript function calls.
  • @babel/plugin-transform-runtime: Reduces code duplication by extracting Babel helpers into shared modules.
  • @babel/plugin-syntax-dynamic-import: Enables dynamic import() syntax in browsers lacking native Promise support.
  • @babel/plugin-proposal-class-properties: Enables support for the public instance field syntax proposal, for writing class-based React components.

We’ll also enable a few React-specific production optimizations:

The command below will install all the necessary dependencies:

npm install -D @babel/preset-env @babel/preset-react @babel/runtime @babel/plugin-transform-runtime @babel/plugin-syntax-dynamic-import @babel/plugin-proposal-class-properties babel-plugin-transform-react-remove-prop-types @babel/plugin-transform-react-inline-elements @babel/plugin-transform-react-constant-elements

Then we’ll populate babel.config.js with these settings:

module.exports = {
  presets: [
    [
      "@babel/preset-env",
      {
        modules: false
      }
    ],
    "@babel/preset-react"
  ],
  plugins: [
    "@babel/plugin-transform-runtime",
    "@babel/plugin-syntax-dynamic-import",
    "@babel/plugin-proposal-class-properties"
  ],
  env: {
    production: {
      only: ["src"],
      plugins: [
        [
          "transform-react-remove-prop-types",
          {
            removeImport: true
          }
        ],
        "@babel/plugin-transform-react-inline-elements",
        "@babel/plugin-transform-react-constant-elements"
      ]
    }
  }
};

This configuration allows us to write modern JavaScript in a way that is compatible with all relevant browsers. There are other types of resources that we might need in a React application, which we will cover in the following sections.

CSS Loader

When it comes to styling React applications, at the very minimum, we need to be able to include plain CSS files. We are going to do this in Webpack using the following loaders:

  • css-loader: Parses CSS files, resolving external resources, such as images, fonts, and additional style imports.
  • style-loader: During development, injects loaded styles into the document at runtime.
  • mini-css-extract-plugin: Extracts loaded styles into separate files for production use to take advantage of browser caching.

Let’s install the above CSS loaders:

npm install -D css-loader style-loader mini-css-extract-plugin

Then we’ll add a new rule to the module.rules section of our Webpack config:

@@ -1,4 +1,5 @@
 const path = require("path");
+const MiniCssExtractPlugin = require("mini-css-extract-plugin");

 module.exports = function(_env, argv) {
   const isProduction = argv.mode === "production";
@@ -26,6 +27,13 @@ module.exports = function(_env, argv) {
               envName: isProduction ? "production" : "development"
             }
           }
+        },
+        {
+          test: /\.css$/,
+          use: [
+            isProduction ? MiniCssExtractPlugin.loader : "style-loader",
+            "css-loader"
+          ]
         }
       ]
     }

We’ll also add MiniCssExtractPlugin to the plugins section, which we’ll only enable in production mode:

@@ -36,6 +36,13 @@ module.exports = function(_env, argv) {
           ]
         }
       ]
-    }
+    },
+    plugins: [
+      isProduction &&
+        new MiniCssExtractPlugin({
+          filename: "assets/css/[name].[contenthash:8].css",
+          chunkFilename: "assets/css/[name].[contenthash:8].chunk.css"
+        })
+    ].filter(Boolean)
   };
 };

This configuration works for plain CSS files and can be extended to work with various CSS processors, such as Sass and PostCSS, which we’ll discuss in the next article.

Image Loader

Webpack can also be used to load static resources such as images, videos, and other binary files. The most generic way of handling such types of files is by using file-loader or url-loader, which will provide a URL reference for the required resources to its consumers.

In this section, we will add url-loader to handle common image formats. What sets url-loader apart from file-loader is that if the size of the original file is smaller than a given threshold, it will embed the entire file in the URL as base64-encoded contents, thus removing the need for an additional request.

First we install url-loader:

npm install -D url-loader

Then we add a new rule to the module.rules section of our Webpack config:

@@ -34,6 +34,16 @@ module.exports = function(_env, argv) {
             isProduction ? MiniCssExtractPlugin.loader : "style-loader",
             "css-loader"
           ]
+        },
+        {
+          test: /\.(png|jpg|gif)$/i,
+          use: {
+            loader: "url-loader",
+            options: {
+              limit: 8192,
+              name: "static/media/[name].[hash:8].[ext]"
+            }
+          }
         }
       ]
     },

SVG

For SVG images, we are going to use the @svgr/webpack loader, which transforms imported files into React components.

We install @svgr/webpack:

npm install -D @svgr/webpack

Then we add a new rule to the module.rules section of our Webpack config:

@@ -44,6 +44,10 @@ module.exports = function(_env, argv) {
               name: "static/media/[name].[hash:8].[ext]"
             }
           }
+        },
+        {
+          test: /\.svg$/,
+          use: ["@svgr/webpack"]
         }
       ]
     },

SVG images as React components can be convenient, and @svgr/webpack performs optimization using SVGO.

Note: For certain animations or even mouseover effects, you may need to manipulate the SVG using JavaScript. Fortunately, @svgr/webpack embeds SVG contents into the JavaScript bundle by default, allowing you to bypass the security restrictions needed for this.

File-loader

When we need to reference any other kinds of files, the generic file-loader will do the job. It works similarly to url-loader, providing an asset URL to the code that requires it, but it makes no attempt to optimize it.

As always, first we install the Node.js module. In this case, file-loader:

npm install -D file-loader

Then we add a new rule to the module.rules section of our Webpack config. For example:

@@ -48,6 +48,13 @@ module.exports = function(_env, argv) {
         {
           test: /\.svg$/,
           use: ["@svgr/webpack"]
+        },
+        {
+          test: /\.(eot|otf|ttf|woff|woff2)$/,
+          loader: require.resolve("file-loader"),
+          options: {
+            name: "static/media/[name].[hash:8].[ext]"
+          }
         }
       ]
     },

Here we added file-loader for loading fonts, which you can reference from your CSS files. You can extend this example to load any other kinds of files you need.

Environment Plugin

We can use Webpack’s DefinePlugin() to expose environment variables from the build environment to our application code. For example:

@@ -1,6 +1,7 @@
 const path = require("path");
 const MiniCssExtractPlugin = require("mini-css-extract-plugin");
 const HtmlWebpackPlugin = require("html-webpack-plugin");
+const webpack = require("webpack");

 module.exports = function(_env, argv) {
   const isProduction = argv.mode === "production";
@@ -67,6 +68,11 @@ module.exports = function(_env, argv) {
   	new HtmlWebpackPlugin({
     	template: path.resolve(__dirname, "public/index.html"),
     	inject: true
+  	}),
+  	new webpack.DefinePlugin({
+    	"process.env.NODE_ENV": JSON.stringify(
+      	isProduction ? "production" : "development"
+    	)
   	})
 	].filter(Boolean)
   };

Here we substituted process.env.NODE_ENV with a string representing the build mode: "development" or "production".

HTML Plugin

In the absence of an index.html file, our JavaScript bundle is useless, just sitting there with no one able to find it. In this section, we will introduce html-webpack-plugin to generate an HTML file for us.

We install html-webpack-plugin:

npm install -D html-webpack-plugin

Then we add html-webpack-plugin to the plugins section of our Webpack config:

@@ -1,5 +1,6 @@
 const path = require("path");
 const MiniCssExtractPlugin = require("mini-css-extract-plugin");
+const HtmlWebpackPlugin = require("html-webpack-plugin");

 module.exports = function(_env, argv) {
   const isProduction = argv.mode === "production";
@@ -63,7 +64,11 @@ module.exports = function(_env, argv) {
         new MiniCssExtractPlugin({
           filename: "assets/css/[name].[contenthash:8].css",
           chunkFilename: "assets/css/[name].[contenthash:8].chunk.css"
-        })
+        }),
+      new HtmlWebpackPlugin({
+        template: path.resolve(__dirname, "public/index.html"),
+        inject: true
+      })
     ].filter(Boolean)
   };
 };

The generated public/index.html file will load our bundle and bootstrap our application.

Optimization

There are several optimization techniques that we can use in our build process. We will begin with code minification, a process by which we can reduce the size of our bundle at no expense in terms of functionality. We’ll use two plugins for minimizing our code: terser-webpack-plugin for JavaScript code, and optimize-css-assets-webpack-plugin for CSS.

Let’s install them:

npm install -D terser-webpack-plugin optimize-css-assets-webpack-plugin

Then we’ll add an optimization section to our config:

@@ -2,6 +2,8 @@ const path = require("path");
 const MiniCssExtractPlugin = require("mini-css-extract-plugin");
 const HtmlWebpackPlugin = require("html-webpack-plugin");
 const webpack = require("webpack");
+const TerserWebpackPlugin = require("terser-webpack-plugin");
+const OptimizeCssAssetsPlugin = require("optimize-css-assets-webpack-plugin");

 module.exports = function(_env, argv) {
   const isProduction = argv.mode === "production";
@@ -75,6 +77,27 @@ module.exports = function(_env, argv) {
           isProduction ? "production" : "development"
         )
       })
-    ].filter(Boolean)
+    ].filter(Boolean),
+    optimization: {
+      minimize: isProduction,
+      minimizer: [
+        new TerserWebpackPlugin({
+          terserOptions: {
+            compress: {
+              comparisons: false
+            },
+            mangle: {
+              safari10: true
+            },
+            output: {
+              comments: false,
+              ascii_only: true
+            },
+            warnings: false
+          }
+        }),
+        new OptimizeCssAssetsPlugin()
+      ]
+    }
   };
 };

The settings above will ensure code compatibility with all modern browsers.

Code Splitting

Code splitting is another technique that we can use to improve the performance of our application. Code splitting can refer to two different approaches:

  1. Using a dynamic import() statement, we can extract parts of the application that make up a significant portion of our bundle size, and load them on demand.
  2. We can extract code which changes less frequently, in order to take advantage of browser caching and improve performance for repeat-visitors.

We’ll populate the optimization.splitChunks section of our Webpack configuration with settings for extracting third-party dependencies and common chunks into separate files:

@@ -99,7 +99,29 @@ module.exports = function(_env, argv) {
           sourceMap: true
         }),
         new OptimizeCssAssetsPlugin()
-      ]
+      ],
+      splitChunks: {
+        chunks: "all",
+        minSize: 0,
+        maxInitialRequests: 20,
+        maxAsyncRequests: 20,
+        cacheGroups: {
+          vendors: {
+            test: /[\\/]node_modules[\\/]/,
+            name(module, chunks, cacheGroupKey) {
+              const packageName = module.context.match(
+                /[\\/]node_modules[\\/](.*?)([\\/]|$)/
+              )[1];
+              return `${cacheGroupKey}.${packageName.replace("@", "")}`;
+            }
+          },
+          common: {
+            minChunks: 2,
+            priority: -10
+          }
+        }
+      },
+      runtimeChunk: "single"
     }
   };
 };

Let’s take a deeper look at the options we’ve used here:

  • chunks: "all": By default, common chunk extraction only affects modules loaded with a dynamic import(). This setting enables optimization for entry-point loading as well.
  • minSize: 0: By default, only chunks above a certain size threshold become eligible for extraction. This setting enables optimization for all common code regardless of its size.
  • maxInitialRequests: 20 and maxAsyncChunks: 20: These settings increase the maximum number of source files that can be loaded in parallel for entry-point imports and split-point imports, respectively.

Additionally, we specify the following cacheGroups configuration:

  • vendors: Configures extraction for third-party modules.
    • test: /[\\/]node_modules[\\/]/: Filename pattern for matching third-party dependencies.
    • name(module, chunks, cacheGroupKey): Groups separate chunks from the same module together by giving them a common name.
  • common: Configures common chunks extraction from application code.
    • minChunks: 2: A chunk will be considered common if referenced from at least two modules.
    • priority: -10: Assigns a negative priority to the common cache group so that chunks for the vendors cache group would be considered first.

We also extract Webpack runtime code in a single chunk that can be shared between multiple entry points, by specifying runtimeChunk: "single".

Dev Server

So far, we have focused on creating and optimizing the production build of our application, but Webpack also has its own web server with live reloading and error reporting, which will help us in the development process. It’s called webpack-dev-server, and we need to install it separately:

npm install -D webpack-dev-server

In this snippet we introduce a devServer section into our Webpack config:

@@ -120,6 +120,12 @@ module.exports = function(_env, argv) {
         }
       },
       runtimeChunk: "single"
+    },
+    devServer: {
+      compress: true,
+      historyApiFallback: true,
+      open: true,
+      overlay: true
     }
   };
 };

Here we’ve used the following options:

  • compress: true: Enables asset compression for faster reloads.
  • historyApiFallback: true: Enables a fallback to index.html for history-based routing.
  • open: true: Opens the browser after launching the dev server.
  • overlay: true: Displays Webpack errors in the browser window.

You might also need to configure proxy settings to forward API requests to the backend server.

Webpack and React: Performance-optimized and Ready!

We just learned how to load various resource types with Webpack, how to use Webpack in a development environment, and several techniques for optimizing a production build. If you need to, you can always review the complete configuration file for inspiration for your own React/Webpack setup. Sharpening such skills is standard fare for anyone offering React development services.

In the next part of this series, we’ll expand on this configuration with instructions for more specific use cases, including TypeScript usage, CSS preprocessors, and advanced optimization techniques involving server-side rendering and ServiceWorkers. Stay tuned to learn everything you’ll need to know about Webpack to take your React application to production.

Understanding the basics

Does React use Webpack?

No, but they can be used together. Webpack and React accomplish different tasks in the development process. React can be used without Webpack.

Can you use React without Webpack?

Yes, you can use a different module bundler for writing React applications, or skip the bundling step altogether.

What is the purpose of Webpack?

Webpack ties source files together in a way that can be understood by the browser.

What is Webpack and how does it work?

Webpack is a module bundler for JavaScript. Webpack resolves external module dependencies and publishes them in a way that can be understood by the browser.

What is the Webpack bundler?

Webpack is an npm module used for bundling JavaScript. It is responsible for collecting application dependencies and merging them for consumption by web browsers.

Is Webpack a framework?

No, it is not. A framework is something that lets you write your code in a different, more convenient way, or introduces new functionality. Webpack does not do any of that and instead comes after the fact to optimize and adapt your code to different environments.