Create a React application from scratch (Part 6): Using a Module Bundler

Mostafa Fouad
18 min readJan 16, 2018

--

This post is part of a beginner-level series of posts meant for people who use
ready-made tools, templates or boilerplates for React but wish to learn and understand how to build a React application from the very beginning.

All posts in this series:
Part 1: Introduction
Part 2: Initialization and the First File
Part 3: Using ES2015 Syntax
Part 4: Enforcing a Style Guide
Part 5: Setting up an Express Server
Part 6: Using a Module Bundler
Part 7: Setting up React and Best Practices
Part 8: Setting up Redux
Part 9: Setting up React Router
Part 10: TDD and Setting up Jest

Quick history lesson

So what is a module bundler anyway? I can tell you that it is a piece of software that lets you break your code into many smaller files (modules) and then stitch them together (and their dependencies) into a single file (bundle) in the correct order.

To understand why you need one, let us go back in time a couple of years.

The past

In the old days, we used to manage scripts, stylesheets and other assets by including them in the HTML document in a specific order.

<script src="jquery.min.js"></script>  
<script src="jquery.plugin.min.js"></script>
<script src="main.min.js"></script>

The files had to be in a specific order to guarantee dependencies were loaded first and to avoid errors and conflicts. Some scripts relied on other scripts and stylesheets so you had to load these as well.

This solution worked, but it was far less than ideal. Many files were being loaded on the page, which means there were many requests being made to the server to fetch each file. You had to manage the order of dependencies manually to make sure a certain file must be loaded before the other, and some of these files created global variables for itself (jQuery, jQuery plugin, Another window.plugin … etc).

To make things a little better, you had to use tools like Gulp or Grunt to process your files, minify and concatenate them. You had to write ‘tasks’ to define a build process and you had to write ‘scripts’ to execute these tasks. This was a tiresome and time consuming solution.

The Present

But then along came a knight in shining armour called Webpack, a module bundler that would not only promise to handle dependencies properly, bundle all your files into a single one, allow you to split your code, but it would also let you import stylesheets, fonts, images, JSON and other static non-JavaScript files into your code!

Webpack allows you to ‘pack’ all your code and its dependencies (including non-JavaScript files) into a single file. This means less data to be downloaded, less requests to be made and a faster processing time.

Well, unfortunately life is not perfect — and so is Webpack. If you have read the documentation, you would find that it is awful and the configuration syntax is just confusing and intimidating to beginners.

Webpack configuration object

The Future? Meet Parcel

Another module bundler is taking the stage with promises to do the same things at a better performance and with zero-configuration and it is called Parcel.

All it needs is a <script> element in your document as an entry point and it will do the rest of the work for you. It provides out-of-the-box support for static asset files (no plugins) and it also provides a fast build time, according to the benchmarks on their GitHub page.

We will go through the setup and configuration of Webpack and then we will also setup Parcel and you can compare both and pick your favourite.

Setting up Webpack

First, install the package as a dependency:

$ npm install --save webpack

Creating a Bundle

Then create a directory with the name src, which will contain all front-end code for the application. Inside this directory, create a file index.js which would serve as the entry point for Webpack to create our bundle and another file module.js that represents a simple module to be imported by index.js:

$ mkdir src
$ cd ./src
$ touch index.js module.js

Then inside module.js, type some code and some exports:

/**
* module.js
*/
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;

And use them inside index.js:

/**
* index.js
*/
import { add, subtract } from './module';const resultA = add(2, 3);
const resultB = subtract(5, 1);
console.log(resultA, resultB);

Now that we have some code written, let us create a bundle file using the following command:

$ npx webpack --entry ./src/index.js --output-filename ./public/js/bundle.js

Even without any extra configuration, this command is already long, tedious to write and hard to read. We will use a different way to configure Webpack later.

This command simply instructs Webpack to use src/index.js as an entry point and generate the bundle.js file at public/js directory. Once you have generated the bundle, go ahead and include it in the index.pug template file:

extends layoutblock content
main#app
script(src="/js/bundle.js")

Now if you run npm start and navigate to http://localhost:3000/ then open up the Dev Tools, you will see 5 4 logged to the console. This means that our code and all dependencies were successfully bundled into one file that works perfectly in the browser.

Webpack Configuration File

Besides the CLI, Webpack can be configured using a JavaScript file. Create a directory and name it webpack then create an empty file config.js inside this directory:

$ mkdir webpack
$ cd webpack
$ touch config.js

This file will export our configuration as a normal JavaScript object:

/**
* Webpack configuration for development
*/
import path from 'path';export default {
entry: path.join(process.cwd(), 'src/index'),
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
},
};

This is the same configuration that we used in the CLI. Usage of a configuration file might require more characters to be written but will provide more flexibility and will allow adding more settings easily.

The entry key represents the entry file for the bundle and output represents where the bundle will be saved and which filename to use for it.

Now with our code and configuration file in place, try to rebuild the bundle:

$ npx webpack --config ./webpack/config.js

You would probably get a syntax error: Unexpected token import and this is because we are using ES2015 syntax inside the configuration file and we do not let Babel process the file first.

We can fix this problem by installing babel-register in the project as a dependency:

$ npm install --save babel-register

Then add .babel as a suffix to the filename. The configuration filename should be config.babel.js after the change. If you try to rebuild now, you should not get any errors:

$ npx webpack --config webpack/config.babel.js

Devtool

The devtool option allows controlling how Webpack generates source maps. You can refer to this table for different values. We will use ‘eval-source-map’ for fast rebuilds and the best quality source-maps for development. On production, we will use a different value ‘source-map’ or ‘hidden-source-map’.

/**
* Webpack configuration for development
*/
import path from 'path';export default {
devtool: 'eval-source-map',
entry: path.join(process.cwd(), 'src/index'),
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
},
};

Target

Because JavaScript can be written for both server and browser, Webpack needs to know for which environment your code is going to be compiled. Sure enough, we will set this option to ‘web’.

/**
* Webpack configuration for development
*/
import path from 'path';export default {
devtool: 'eval-source-map',
entry: path.join(process.cwd(), 'src/index'),
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
},
target: 'web',
};

Loaders (Babel loader)

A Loader is a transformation that is applied on the source code of a module. It allows you to pre-process files as you load them. Rebuilding the bundle with the simple code that we have right now will work fine but if we use a bit of ES2015 inside module.js:

/**
* module.js
*/
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
export const createObject = o => ({ ...o, anotherOption: 'Hi!' });

We will get an Unexpected token error again. We need to add a loader that can understand what this syntax is about and transform it to a syntax that the browser understands. Babel loader will do just that so let us install it as a dependency:

$ npm install --save babel-loader

Then add it in the Webpack configuration file:

/**
* Webpack configuration for development
*/
import path from 'path';export default {
devtool: 'eval-source-map',
entry: path.join(process.cwd(), 'src/index'),
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
},
module: {
rules: [
{
test: /\.js?$/,
use: 'babel-loader',
exclude: /node_modules/,

},
],
},

target: 'web',
};

Do not get confused — Babel core enables ES2015 on the server-side and Babel loader for Webpack enables ES2015 on the client-side.

Loaders (ESLint loader)

Another important loader is ESLint loader. It lints your code before building the bundle to make sure bad code will not go through. Install the loader as a development dependency:

$ npm install --save-dev eslint-loader

Then modify the configuration file as follows:

/**
* Webpack configuration for development
*/
import path from 'path';
import webpack from 'webpack';
export default {
devtool: 'eval-source-map',
entry: path.join(process.cwd(), 'src/index'),
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
],

module: {
rules: [
{
enforce: 'pre',
test: /\.js$/,
use: 'eslint-loader',
},

{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/,
},
],
},
target: 'web',
};

We imported webpack in order to have access toNoEmitOnErrorsPlugin. This plugin ensures no assets that contain errors are emitted into the bundle. These errors include linting errors, so we are going to include eslint-loader in the loaders array and set enforce to pre to ensure the loader checks the code before the bundle is built.

Loaders (Style loader)

If you have not heard of CSS Modules, you should. Webpack allows you to load stylesheets right into your JavaScript modules using a Style loader. It is recommended that you use the Style loader in combination with CSS loader, so add both of them as dependencies:

$ npm install --save style-loader css-loader

Then list them in the configuration file:

/**
* Webpack configuration for development
*/
import path from 'path';
import webpack from 'webpack';
export default {
devtool: 'eval-source-map',
entry: path.join(process.cwd(), 'src/index'),
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
},
plugins: [
new webpack.NoEmitOnErrorsPlugin(),
],
module: {
rules: [
{
enforce: 'pre',
test: /\.js$/,
use: 'eslint-loader',
},
{
test: /\.js$/,
use: 'babel-loader',
exclude: /node_modules/,
},
{
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},

],
},
target: 'web',
};

Note that they have to be in that order, style-loader must be listed before css-loader. You are now able to import stylesheets into your code, try this:

/**
* styles.css
*/
body {
background: green;
}
.globalClass {
color: blue;
}
:local(.localClass) {
color: red;
}

And inside index.js:

/**
* index.js
*/
import styles from './styles.css';
import { add, subtract } from './module';
const resultA = add(2, 3);
const resultB = subtract(5, 1);
console.log(resultA, resultB);
console.log(styles.localClass); // _19OBmKu4X8SmIISJiYXz8U
console.log(styles.globalClass);
// undefined

Works perfectly! But seriously, who still writes plain CSS nowadays? Let us add a SASS loader.

Install the package as a dependency, along with node-sass:

$ npm install --save sass-loader node-sass

Then add it to the loaders list — correct, you guessed it, after the CSS loader:

/**
* Webpack configuration for development
*/
import path from 'path';
import webpack from 'webpack';
...
{
test: /\.(s)?css$/, // <-- note this (s)?
use: ['style-loader', 'css-loader', 'sass-loader'],
},
],
},
...
};

Note: We are now testing for .css and .scss files as well.

The order, again, is important. The SASS loader would first transform SASS to normal CSS, the CSS loader would translate the styles into CommonJS and then the Style loader would inject the styles into the page by creating style nodes from JavaScript strings.

Loaders (Image loader)

We would probably need to use images in our stylesheets, wouldn’t we? Let us add a couple of loaders that would help with this.

The SVG inline loader will allow including SVG files and will also help remove unneeded crusts generated by Sketch and Adobe Illustrator. For other image file types such as JPEG and PNG, we are going to use the File loader.

Install both as dependencies:

$ npm install --save svg-inline-loader file-loader

And update the loaders list:

/**
* Webpack configuration for development
*/
import path from 'path';
import webpack from 'webpack';
export default {
...
module: {
rules: [
...
{
test: /\.svg$/,
use: 'svg-inline-loader',
},
{
test: /\.(jpe?g|png|gif|ico)$/i,
use: 'file-loader',
},

],
},
...
};

Refer to this page to learn more about Webpack loaders.

Reading environment variables

In order to load the environment variables into the front-end as well, we are going to use a Babel plugin called Transform Inline Environment Variables. This plugin reads those variables and exposes them to our client-side code.

Install the package as a dependency:

$ npm install --save babel-plugin-transform-inline-environment-variables

Then add the plugin to the list of plugins in Webpack configuration file config.babel.js:

...
{
test: /\.js?$/,
use: {
loader: 'babel-loader',
options: {
plugins: ['transform-inline-environment-variables'],
},
},

exclude: /node_modules/,
},
...

That is all there is to it. Add a variable inside the .env file then log it inside your code to make sure everything is working properly:

APP_NAME=dolphin

Then inside index.js:

console.log(process.env.APP_NAME); // 'dolphin'

If you try this now, the logged message should be ‘undefined’ not ‘dolphin’. This is simply because the environment variables are not loaded when you run npx webpack to build the bundle. You can load the environment variables using the env cmd package:

$ npm install --save env-cmd

Then you can load the variables before building the bundle:

$ npx env-cmd .env webpack --config ./webpack/config.babel.js

Webpack Dev Server

We keep rebuilding the bundle manually each time we change the code, but we should not have to.

Webpack provides a Development Server that uses Express to serve the bundle with hot module replacement features that inject updated modules into the active runtime so you do not have to refresh the page each time you make a change to your code.

We are going to use the development server as a middleware with our current Express setup, so install the package as a development dependency:

$ npm install --save webpack-dev-middleware

Then create a dev-server.js file inside webpack directory:

/**
* Webpack dev server
*/
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackConfig from './config.babel';
export default (app) => {
const webpackCompiler = webpack(webpackConfig);
// use dev middleware
app.use(webpackDevMiddleware(webpackCompiler, {
// defines the level of messages to log
logLevel: 'warn',
}));
};

This file exports one thing, a function that accepts a reference to an Express application. This function simply uses the configuration file to create a Webpack compiler which is then passed to the Dev Middleware. The Dev Middleware is then mounted on the Express app and that is it.

We have the function that mounts the Dev Middleware but we have not used it yet, so let us go back to app.js and use it there:

/**
* app.js
*/
import dotenv from 'dotenv';
import path from 'path';
import express from 'express';
import logger from 'morgan';
import bodyParser from 'body-parser';
import cookieParser from 'cookie-parser';
import webpackDevServer from './webpack/dev-server';
import routes from './routes/index';
// use dotenv
dotenv.config({
silent: true,
});
// Express app setup
const app = express();
// views engine
app.set('views', path.join(__dirname, './views'));
app.set('view engine', 'pug');
// include webpack-dev-server for development only
if (process.env.NODE_ENV !== 'production') {
webpackDevServer(app);
}
...

This is a development server middleware, so make sure it is not used on production. If you check the terminal window, you will see that the bundle is updated each time you make a change to your code and save. However, the bundle file that is being updated is not the same one that we are using.

The bundle that we are including in our document is located at /js/bundle.js while the bundle that is being served by the middleware is located at /bundle.js directly. This is the default path for the bundle. Open up http://localhost:3000/bundle.js and see.

Let us tell Webpack to serve the file from /js instead of the root path. Inside the configuration file, add publicPath to the output object:

/**
* Webpack configuration for development
*/
import path from 'path';
import webpack from 'webpack';
export default {
devtool: 'eval-source-map',
entry: path.join(process.cwd(), 'src/index'),
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
publicPath: '/js',
},
...

Then use the same option in the Dev Server file:

/**
* Webpack dev server
*/
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackConfig from './config.babel';
export default (app) => {
const webpackCompiler = webpack(webpackConfig);
// use dev middleware
app.use(webpackDevMiddleware(webpackCompiler, {
// defines the level of messages to log
logLevel: 'warn',
// public path to bind the middleware to
publicPath: webpackConfig.output.publicPath,
}));
};

Perfect! The bundle is now updated properly, however we still need to refresh the page manually each time we make a change, so let us do something about that.

Hot Module Replacement (HMR)

HMR is simply a way of updating modules in a running application by adding/removing modules without causing a full page reload. We are going to enable HMR using a middleware called webpack-hot-middleware.

Install the middleware as a development dependency:

$ npm install --save-dev webpack-hot-middleware

Then edit the configuration file and add the plugin:

...
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
],
...

Remember that we set the entry option as a string? We are going to convert it into an array because we will need to add the hot middleware as the first entry point. This connects to the server to receive notifications when the bundle rebuilds and then updates the client bundle accordingly.

...
entry: [
'webpack-hot-middleware/client?reload=true',
path.join(process.cwd(), 'src/index'),
],
...

The ?reload=true part instructs the middleware to auto-reload the page when Webpack gets stuck.

Now add the middleware into the server:

/**
* Webpack dev server
*/
import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackHotMiddleware from 'webpack-hot-middleware';
import webpackConfig from './config.babel';
export default (app) => {
...
// allow using Webpack hot reloading
app.use(webpackHotMiddleware(webpackCompiler));
};

Run npm start and navigate to the app on your browser. You will see a message logged to the console:

HMR Enabled on the client

If you try to make some changes to the code, the page will be automatically reloaded. This is not ‘hot’ enough but it is a good progress, at least you do not have to reload the page manually anymore.

Accepting change

HMR is ‘opt-in’, so you need to put some code at chosen points of your application to accept the updates. The dependencies are handled by the module system.

For example, you place your hot replacement code in module A. Module A requires module B and B requires C. If module C is updated, and module B cannot handle the update, modules B and C become outdated. Module A can handle the update and new modules B and C are injected.

Add the following code inside index.js:

import styles from './index.scss';
import { add, subtract } from './module';
if (module.hot) {
module.hot.accept();
}
const resultA = add(2, 3);
...

Note that module object is not the ./module file.

Finally…

Here is how the configuration file should look like:

/**
* Webpack configuration for development
*/
import path from 'path';
import webpack from 'webpack';
export default {
devtool: 'eval-source-map',
entry: [
'webpack-hot-middleware/client?reload=true',
path.join(process.cwd(), 'src/index'),
],
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
publicPath: '/js',
},
plugins: [
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin(),
],
module: {
rules: [
{
enforce: 'pre',
test: /\.js(x)?$/,
use: 'eslint-loader',
},
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
plugins: ['transform-inline-environment-variables'],
},
},
exclude: /node_modules/,
},
{
test: /\.json$/,
use: 'json-loader',
},
{
test: /\.(s)?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.svg$/,
use: 'svg-inline-loader',
},
{
test: /\.(jpe?g|png|gif|ico)$/i,
use: 'file-loader',
},
],
},
target: 'web',
};

Configuration for Production
One last thing we need to do is to create another configuration file for production, but the good news is that both files will be similar:

/**
* Webpack configuration for production
*/
import path from 'path';
import webpack from 'webpack';
export default {
devtool: 'source-map',
entry: path.join(process.cwd(), 'src/index'),
output: {
filename: 'bundle.js',
path: path.join(process.cwd(), 'public', 'js'),
publicPath: '/js',
},
plugins: [
new webpack.optimize.OccurrenceOrderPlugin(true),
new webpack.optimize.UglifyJsPlugin(),

],
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
plugins: ['transform-inline-environment-variables'],
},
},
exclude: /node_modules/,
},
{
test: /\.json$/,
use: 'json-loader',
},
{
test: /\.(s)?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
},
{
test: /\.svg$/,
use: 'svg-inline-loader',
},
{
test: /\.(jpe?g|png|gif|ico)$/i,
use: 'file-loader',
},
],
},
target: 'web',
};

What we did differently:

  • Used source-map instead of eval-source-map to emit full source map as a separate file.
  • Removed the hot middleware entry point.
  • Removed the HotModuleReplacementPlugin and NoEmitOnErrorPlugin.
  • Added OccurrenceOrderPlugin and UglifyJsPlugin for optimisation.
  • Removed ESLint loader from the loaders list.

Then add a ‘build’ script to package.json:

...
"scripts": {
"start": "babel-node bin/www",
"build": "NODE_ENV=production npx env-cmd .env webpack --config ./webpack/config-prod.babel.js",

"postinstall": "npm run build",
"lint": "eslint . --ext .js,.jsx",
"test": "echo \"Error: no test specified\" && exit 1"
},
...

The ‘postinstall’ script will guarantee that the bundle is rebuilt when npm install is executed. For more information, here is a useful list of npm scripts.

Congratulations! We have finally configured Webpack! Now let us move on to Parcel.

Setting up Parcel

Parcel, as mentioned before, is a blazing fast module bundler that requires zero configuration and provides support for stylesheets, html and static asset files out-of-the-box. It automatically uses Babel, postCSS and postHTML to apply code transformation. It also provides HMR with no configuration at all.

Note: Make sure you remove any Webpack related code and configuration before setting up Parcel.

Install the package as a dependency:

$ npm install --save parcel-bundler

Now run the following command to create the bundled code:

$ npx parcel src/index.js -d public/js/

This will create a new index.js bundle file in public/js and will start a development server at http://localhost:1234/ by default.

The option -d is an alias for --out-dir which is the output directory.

Watch Mode

Parcel starts the development server automatically and this is very useful in case your application does not already have a server. In our case, we have an Express server setup, so it would be more suitable to run Parcel in watch mode:

$ npx parcel watch src/index.js -d public/js/

This will not start a development server but Parcel will be watching for file changes and will rebuild the bundle when the changes happen.

ES2015 Syntax

Since we already have .babelrc file in our project, Parcel will use it to compile our ES2015 syntax to normal ES5 syntax. No extra configuration, loaders or plugins needed, just the .babelrc file.

Stylesheets

Parcel also supports importing SASS files, all it needs is for you to have node-sass installed. Again, no extra configuration, loaders or plugins needed.

Reading environment variables

To support reading environment variables, all we need to do is install the same Babel plugin that we used earlier with Webpack. Install the package as a dependency:

$ npm install --save babel-plugin-transform-inline-environment-variables

Then add the plugin to the list of plugins in .babelrc:

{
"presets": [
"es2015",
"stage-0"
],
"plugins": ["transform-inline-environment-variables"]
}

That is it! Now we can read environment variables in our client-side code.

Middleware

Instead of using Parcel through the CLI, we can use the middleware provided by Parcel with Express. Modify app.js file and create a bundler:

...
import Bundler from 'parcel-bundler';
...// use parcel bundler
if (process.env.NODE_ENV !== 'production') {
const bundler = new Bundler('./src/index.js', {
outDir: 'public/js',
watch: true,
});
bundler.bundle(); app.use(bundler.middleware());
}
// logger
app.use(logger('combined'));
...

What we do here is that we import the Bundler class from the package then we create a new bundler instance, then we call bundler.bundle() to set the main asset type internally and finally we add the middleware to our Express app.

If you run npm start now, the bundler will run in watch mode and you will get ES2015, SASS support and HMR with almost no effort at all!

Production Mode

Again, there is no need for any configuration at all. Parcel will detect NODE_ENV and if it is ‘production’, Parcel will disable HMR and the Dev Server automatically and will also minify your scripts and stylesheets for a better performance.

Simply, update the ‘build’ script in package.json file:

...
"scripts": {
"start": "babel-node bin/www",
"build": "NODE_ENV=production npx parcel build src/index.js -d public/js/ --no-cache",
"postinstall": "npm run build",
"lint": "eslint . --ext .js,.jsx",
"test": "echo \"Error: no test specified\" && exit 1"
},
...

Webpack or Parcel?

At the end of the day, it simply boils down to what you really need and how large your project is. The simplicity that Parcel provides makes it a perfect candidate for small or medium sized projects. Webpack is more flexible and customizable which makes it more suitable for a large scale application.

At the time of writing, Parcel is still at its early days so things may change in the future but for now and for this particular tutorial, we will be using Webpack.

Directory structure should look like this by now

Conclusion

A module bundler is essential for creating a modular and scalable front-end application. Parcel is perfect for small or medium sized applications but Webpack is more flexible and customizable which makes it a better candidate for large scale applications. Choose the one that better suits your application, do not over-complicate or over-simplify.

Was this article useful? Please click the Clap button below, or follow me for more.

Thanks for reading! If you have any feedback, leave a comment below.

Go to Part 7: Setting up React and Best Practices

--

--