React with Webpack From Scratch

React with Webpack From Scratch

A beginner's deep dive into setting up a webpack build to run React with webpack-dev-server and hot-loading. (React 18 and Webpack 5)

·

15 min read

Featured on Hashnode

Maybe you are just curious, maybe you want more control, or maybe create-react-app just feels dirty. Whatever the reason, let's build a simple React App from scratch using React ^18 and Webpack ^5!

First some disclaimers:

First. Because this is Javascript there are like a billion ways to do the same thing. What follows is a process I've adopted through preference, tinkering, and whatever got returned on a web search. My hope is this tutorial will give you enough insight to explore your preferences.

Second. I'm only going to explain how to set this up for a development environment. If your project is production bound, that's a separate step I will not go into.

Third. This is long. I go into detail and that takes space and time. This is not a quick and easy copy and past post. If you want to get something out of this, follow along and do each step.

Ok. Let's start.

1. Setting up the Project

System prerequisites:

  • node -v = ^18.6
  • npm -v = ^8.13

I use VS Code (1.69.2 as of this writing)

Setting up the project:

You need a root directory. Obviously. I'm going to call this one ReactFromScratch. Inside that directory initialize all the typical things:

git init npm init

For the sake of this article, we don't care what these commands create. All we need is the package.json file for dependencies.

Next, you'll want a project directory. This is typically called src or app but you can call it anything you want. I'm going to stick with src. This is where all of the project files go. Inside that directory make 2 files:

  1. index.html
  2. index.js

You can name these files anything you want to. What's listed are conventional names and it might be handy to keep it that way until you understand their role. We'll populate them with stuff later.

To recap: This is what our project file looks like so far.

.
├── src
│   ├── index.html
│   └── index.js
├── package.json

1. The Build Directive

A Little Background

Before I go into the details of setting up a Webpack build, it's worth understanding how a webpage, specifically a React app, ends up in your browser.

The simplest webpage you can create is just an HTML file. You can load that directly into the browser. A React App is no different. That index.html file we created before becomes the portal for the app. I emphasize becomes because simply trying to load that specific file will not go well. You need to build first.

Behind the scenes a React app generates a lot of HTML, but that all happens in JavaScript. Hence the index.js file. Again, you can create a simple HTML file, load some Javascript into it and have it running fine without any fancy build tools. In the old days, this is exactly how web pages with Javascript were created. But those Javascript files quickly became enormous monolithic tombs, prone to bugs and nearly impossible to maintain. I recommend reading Why Webpack for a crash course on how builders like Webpack came to be.

Anyway, a React app is big and complex and not even native JavaScript. You simply cannot load this directly into the browser. It won't know what to do. So we use tools like Webpack to bridge our development of an app to something a browser can understand and load. It also allows us to do cool things like Lazy Loading, Optimizations, and Transpiling. I'll cover some of this, as well as using Hot Loading, only possible when using a build tool like Webpack!

Anyway, it's important to understand that the end goal of our build directive is to create an HTML file that can load javascript. The two files, index.html and index.js, we created above will be used by Webpack as templates to build the actual files we can run. They are not functional in any other way.

Entry and Output

Back in the root directory next to the package.json file, create a new file and call it webpack.config.js. You could name this anything you want, but I don't recommend it, and I'm not going to cover how to make that work. This is the file name Webpack will look for when you run Webpack commands.

Open that file and put the following into it.

const path = require('path')

module.exports = () => {
  return {
    mode: 'development'
  }
}

Webpack expects a static JSON object as its configuration. You could simply export a static object but Webpack also supports a function call as an intermediary to return a static JSON object. This becomes useful later on.

We'll build the config one step at a time and I'll explain what each attribute does and why we need it.

You'll notice I'm requiring 'path' right away. This becomes handy in a Webpack config to resolve absolute paths. It can be tricky to know what paths should be relative vs absolute. I'll make note of that when we get to it.

I've also set the mode to development. The mode affects optimizations and handles some other things related to running a dev environment. Dive deeper webpack mode.

Entry

entry: './src/index.js'

This is where Webpack will go to start compiling all the project files. It's the root node of a large dependency tree. I recommend trying out dependency-cruiser if you're interested in getting a feel for what this looks like. Our entry is going to be that index.js file we created earlier. It doesn't matter what that file is named or where it is located, so long as Webpack knows how to get to it.

The entry config is a relative path. We're using it in its simplest form. See entry docs for details on its many nuances and variations.

Output

output: {
      filename: 'main.js',
      path: path.resolve(__dirname, 'dist'),
}

This is the reverse of entry. This is where Webpack puts all the compiled code. As I mentioned earlier, this is going to be that giant .js file that can be loaded by the browser. There are two parts to this config, and they do what the names imply.

  • filename This will be the name of the file.
  • path This will be where the file is put.

The path config is an absolute path, and we need to resolve it with __dirname. We're more or less doing the minimum amount of work to get webpack working. Output is a very large config option and has many many options. See output docs.

A Side Trip to "Hello World"

Let's take a small side trip and run our first build.

First, open the index.js file and add this:

const node = document.createElement("div");
const textnode = document.createTextNode("Hello World");
node.appendChild(textnode);

document.getElementById("root").appendChild(node);

All we're doing here printing "Hello World" using vanilla javascript. No fancy compiling is needed. Later we'll put our call to React in this file to build with webpack we need to install 2 dependencies: npm install --save-dev webpack@^5 webpack-cli@^4

webpack is the main deal that does all the build stuff. webpack-cli is how we tell webpack to do stuff via the command line.

In the package.json file add this line in the scripts object:

"scripts": {
      "build": "webpack"
}

You have 3 options for running webpack. Add it as a script like the above (recommended) and run it with 'npm run build', run it directly using npx webpack, or install webpack globally (not really recommended) and call it via webpack.

Now from the console run npm run build. You'll notice a new folder was created in your root called dist. Inside of that folder is your compiled javascript file called 'main.js'. Open that file and you'll notice it looks quite a bit different from our original code.

When using development mode webpack defaults your devtool option to eval. You see this in action when you look at the compiled code which is now wrapped in an eval() call. More on this later.

So we built the javascript file. Cool. How about that HTML file?

We could simply create an HTML file in the dist folder and have it load the javascript, but that's not the webpack way. Instead, we generate an HTML file during the build process. We use the HtmlWebpackPlugin to do this task for us.

First, install it.

npm install --save-dev html-webpack-plugin@^5

Then add it to our `webpack.config.js' file.

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = () => {
    return {
        mode: "development",
        entry: './src/index.js',
        output: {
            filename: 'main.js',
            path: path.resolve(__dirname, 'dist'),
       },
       plugins: [
            new HtmlWebpackPlugin()
       ]
    }
}

Now build again (npm run build) and open the generated HTML file in the dist folder. You'll notice it knows about our main.js file and loads it. Webpack is so smart!

But this isn't enough. If you loaded this HTML (try it if you like), nothing will happen because we never established a "root" node for our Hello World code to attach to. To do this we need to create a template HTML that includes that node and tell HtmlWebpackPlugin where our template is.

Copy that generated HTML into the 'index.html' file and add in the tags:

<body>
  <div id="root"></div>
</body>

You might as well change the <title> while you're at it. Now back in the webpack.config.js file make our HtmlWebpackPlugin look like this so it's aware of our template:

new HtmlWebpackPlugin({
      template: path.resolve(__dirname, 'src', 'index.html'),
})

Here's what our project structure looks like with the dist directory now.

.
├── dist
│   ├── index.html
│   └── main.js
├── src
│   ├── index.html
│   └── index.js
├── package.json
└── webpack.config.js

Now build again (npm run build) and open the index.html file in the dist folder in your browser. Hopefully, you see a little message there :)

It might feel a little overkill to go into such detail on this part of the build process, but it's also important, and often the source of confusion. Nothing magical is happening here. Now we have a simple but working build process. All we do from here is add functionality to the build to do bigger better and more productive things.

2. Adding React

First step is to install React and React DOM.

npm install react@^18 react-dom@^18

Next is to create the app. I'm going to keep this really simple. Create a new directory in src called app and create a new file called App.jsx in there. This will be the main entry for the React app. Inside that file add something simple, like:

import React from "react"

export default function App() {
    return <div>Hello World!</div>
}

Now back in the index.js file in our src directory replace the existing code with this boiler plate React render code:

import React from 'react'
import { createRoot } from 'react-dom/client'
import App from './app/App.jsx'

const container = document.getElementById('root')
const root = createRoot(container)
root.render(<App />)

Let's see what happens when we build now. npm run build

I get this:

Module parse failed: Unexpected token (6:12)
You may need an appropriate loader to handle this file type, currently no loaders are configured to process this file. See https://webpack.js.org/concepts#loaders
| const container = document.getElementById('root')
| const root = createRoot(container)
> root.render(<App />)

Remember when I said React isn't native javascript? I wasn't kidding. So yes, we need an appropriate loader. Something can read React and turn it into native javascript. Thankfully we have one and webpack gives us the ability to add this to the build pipeline. There are few steps here so let's go through each one.

First we need something that can transpile those .jsx files. There are a few options, but babel is really all you need. Babel has a transpiler specifically for react. Install it:

npm install --save-dev @babel/preset-react@^7

Babels @presets are actually a bundle of plugins. If you want to see what's included in preset-react check out @babel/preset-react. Also check out babel's git repo. Community development at its best.

The way bable works is by looking in a file called .babelrc in the root directory of the project to see how it should handle your raw javascript, including .jsx files. This is where we'll add @preset-react, which is the transpiler for React (jsx) files.

Create a file in the root called .babelrc and add the following in it:

{
  "presets": ["@babel/preset-react"]
}

And here's what our final directory structure looks like for this tutorial.

.
├── dist
│   ├── index.html
│   └── main.js
├── src
│   ├── app
│   │   └── App.jsx
│   ├── index.html
│   └── index.js
├── .babelrc
├── package.json
└── webpack.config.js

If you built the project again you'd still get the same error from before. Now we need to tell webpack that we're using bable to handle our jsx files. Navigate to the webpack.config.js file and modify it to look like this:

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = () => {
    return {
        mode: "development",
        entry: './src/index.js',
        output: {
            filename: 'main.js',
            path: path.resolve(__dirname, 'dist'),
        },
        module: {
            rules: [
                {
                    test: /\.(js|jsx)$/,
                    exclude: /node_modules/,
                    use: {
                        loader: 'babel-loader',
                    },
                }
            ]
        },
        plugins: [
            new HtmlWebpackPlugin({
                template: path.resolve(__dirname, 'src', 'index.html'),
            })
       ]
    }
}

What we added was the module key with some rules. These rules tell webpack what to do when it encounters a certain file type. We tell it what filetype by giving it a test, in this case we're testing for js and jsx files. We're also telling it to exclude /node_modules/ because we don't want to transpile our dependencies. They should already be transpiled.

Finally we tell it what to use to load these file types, and in this case we're using babel-loader. Babel loader can be thought of a bridge between webpack and that .babelrc file. Webpack does the work of gathering up the files and passing it off to the loader that does the work of transpiling it.

I should mention that the need for a .bablerc file is not required. You can add babel configurations directly in the webpack.config file. I'm not covering that here. See babel-loader for examples.

We still need to install babel-loader though.

npm install --save-dev babel-loader@^8

With that done we should now be able to build. npm run build. With any luck it builds correctly and if you open the index.html file in the dist directory you should see "Hello World!"

Congrats! You turned React into vanilla javascript!

3. Server Setup and Hot Loading

This section used to be a lot longer, but webpack now has a super easy built in HMR (hot module replacement) functionality, and react-refresh has replaced the somewhat cumbersome React Hot Loader. It's now very easy to get this working.

What is hot module replacement? This gives a developer the ability to see realtime changes to the code in the browser without the need to reload the website. Right now every time you make a change we'll need to rebuild the code and refresh the webpage.

Webpack ^5 gives us the ability to integrate HMR with just a few lines added to the webpack.config.js file. Make that file look like this:

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = () => {
    return {
        mode: "development",
        entry: './src/index.js',
        output: {
            filename: 'main.js',
            path: path.resolve(__dirname, 'dist'),
        },
        devServer: {
            port: 3000,
            hot: true,
            open: true,
        },
        module: {
            rules: [
                {
                    test: /\.(js|jsx)$/,
                    exclude: /node_modules/,
                    use: {
                        loader: 'babel-loader',
                    },
                }
            ]
        },
        plugins: [
            new HtmlWebpackPlugin({
                template: path.resolve(__dirname, 'src', 'index.html'),
            })
       ]
    }
}

What's been added is the devServer key. In its configuration we've added a few parameters. Port: which what port to run the server on. Hot: enable HMR. Open: open the webpage in your default browser on server start. That's it. We also need to install webpack-dev-server. Do that now.

npm install --save-dev webpack-dev-server@^4

Webpack-dev-server makes your webpage dynamic during development. It listens for changes to your code, rebuilds the code (with HMR), and serves the new code to the browser. This automates the process of building your project and manually opening it.

With webpack-dev-server installed we can add a new script to the package.json file. Under the build script we've been using add this entry: "start": "webpack serve",. This tells webpack to build our project then start a server to handle it. Try it now!

npm run start

Your project should have automatically been opened in the browser. You'll also notice the output in the terminal is new. A bunch of [webpack-dev-server] stuff as well as the fact that the process is still running. You can kill the server with control c.

Now make a change to the App.jsx file, like "Hello Universe!" or something. When you hit save you'll see a bunch of action in the terminal. This is the server handling your change. Navigate back to your open webpage and notice the change is already there. Your productivity just increased by a lot!

This gets us pretty far, but when developing in React we have one extra step. The problem comes when you start adding state to your application. Webpack's HMR will reset state which get really annoying when the application grows. To handle this we'll add react-refresh-webpack-plugin. Let's just install that now:

npm install --save-dev @pmmmwh/react-refresh-webpack-plugin@^0 react-refresh@^0

You'll notice that we also installed react-refresh. The React team has created this api which allows the wiring up of Fast Refresh, a built in feature in React 18, for bundler plugins. I found this article helpful in understanding this feature. React-refresh-webpack-plugin uses this api to do it's hot loading magic.

Now that we have react-refresh-webpack-plugin installed we enable it by adding a babel plugin and a webpack plugin. First the babel plugin.

Make the .babelrc look like this:

{
    "presets": ["@babel/preset-react"],
    "plugins": ["react-refresh/babel"]
}

Now the webpack plugin. Make the webpack.config.js file look like this:

const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin')

module.exports = () => {
    return {
        mode: "development",
        entry: './src/index.js',
        output: {
            filename: 'main.js',
            path: path.resolve(__dirname, 'dist'),
        },
        devServer: {
            port: 3000,
            hot: true,
            open: true,
        },
        module: {
            rules: [
                {
                    test: /\.(js|jsx)$/,
                    exclude: /node_modules/,
                    use: {
                        loader: 'babel-loader',
                    },
                }
            ]
        },
        plugins: [
            new HtmlWebpackPlugin({
                template: path.resolve(__dirname, 'src', 'index.html'),
            }),
            new ReactRefreshWebpackPlugin()

       ]
    }
}

We require ReactRefreshWebpackPlugin then add a new instance of it to the plugins key. That's it! Start your server again.

npm run start

It may seem like nothing has changed, but like before modifications to your app propagate automatically to the browser. The difference is state. Let's test this. Add some simple state to the App.jsx file. I did this:

import React from "react"

export default function App() {
    const[state, setState] = React.useState("World")

    function hello() {
        if (state == "World") {
            setState("Universe")
        } else {
            setState("World")
        }
    }

    return <div onClick={hello}>Hello {state}</div>
}

When we click on "Hello World" state will change to "Universe" and visa-versa. Now restart the server. npm run start. Change the state so it says "Universe" then modify App.jsx to say "Goodbye". You'll notice that our state has not changed! It still says "Goodbye Universe".

Now stop the server and comment out the plugins in both the .bablerc file and the webpack.config.js file. Restart the server and do the same thing above. Everytime you modify your App.jsx' file the state reverts back to "World". So now you understand whyReact Refresh` is so cool!

4. Recap

This tutorial got us as far as setting up React with Webpack without using Create React App. Hopefully you learned not just how to do this, but how each related part of Webpack works to get us there.

If you want a reference here's the working git repo for this tutorial: React From Scratch