Vanilla React 18 + TypeScript + Webpack

Tools like create-react-app are great, but abstract away lots of details. Here's how you can set up a "vanilla" React + TypeScript app using Webpack.

Written by Zach

Last Updated: Mar 29, 2024

Recently, I was working on a beginner-focused video about building a basic React app, and it occurred to me that there aren't many resources online that explain how to create a vanilla (loose term here) TypeScript + React18 application without all the boilerplate that CRA (create-react-app) adds to the mix.

So here's a brief guide explaining my solution for React 18.x and TypeScript 4.x+.

Source Code

You can find a working example of the code explained below in my repo here.

Required packages

For this setup, you'll need all of the following packages:

# React
yarn add react react-dom

# Types
yarn add -D @types/node @types/react @types/react-dom @types/copy-webpack-plugin

# Build tools
yarn add -D typescript ts-loader webpack webpack-cli copy-webpack-plugin style-loader css-loader ts-node webpack-dev-server

As a brief overview, here's what each dependency is primarily for:

  • react, react-dom - our React app!
  • @types/* and typescript - TypeScript type definitions and compiler
  • webpack, webpack-cli, webpack-dev-server - for running webpack builds via command line (see scripts in package.json) and serving in local dev
  • ts-loader, style-loader, css-loader - the loaders that Webpack will use to properly bundle our TypeScript and CSS files.
  • copy-webpack-plugin - this copies the /public directory to /dist so that /dist has everything needed to run an app!
  • ts-node - mainly used to actually run Webpack (since we defined the Webpack config as a .ts file)

Directory Structure

This is obviously a matter of preference, but here's the directory structure we'll be using:

dist/  # Build outputs (not in source control)
public/
  index.html
  globals.css
src/
  components/
    Footer.tsx
    Footer.css
  entrypoint.tsx
  App.tsx

Public Assets

First up, let's look at public/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <link rel="stylesheet" href="globals.css" />
    <title>Document</title>
  </head>
  <body>
    <div id="root"></div>
    <script src="bundle.js"></script>
  </body>
  <html></html>
</html>

As simple as it gets! Our bundle.js will be the bundled React app, which will render inside the id=&quot;root&quot; element, and inherit all the styles from globals.css (along with per-component styles, which will override globals).

Next, our public/globals.css file, which just has two styles:

p {
  color: green;
}

a {
  color: blue;
}

React App

Our app is simple. It has some text and a footer component.

Here is src/App.tsx:

import Footer from "./components/Footer";

export default function App() {
  return (
    <main>
      <p>App</p>
      <Footer />
    </main>
  );
}

src/components/Footer.tsx looks like this:

import "./Footer.css";

export default function Footer() {
  return (
    <footer>
      <a href="#">A Footer Link</a>
    </footer>
  );
}

And finally, here's the footer CSS. I have intentionally added a conflicting style to demonstrate CSS specificity between CSS modules and globals.css. Links in the footer will be red instead of blue (defined in globals.css).

/* Footer.css */

a {
  color: red;
}

Rendering the app root

By default, these components won't show up in the DOM. We need to initialize React by creating a "root" and rendering our app into it. To do this, I have created a src/entrypoint.tsx with the following contents. Please note, this is different syntax than you're probably used to seeing as React v18.x introduced the createRoot method vs. the old ReactDOM.render method.

import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";

const rootElement = document.getElementById("root");

// New as of React v18.x
const root = createRoot(rootElement!);

root.render(
  <StrictMode>
    <App />
  </StrictMode>
);

And that's it! That's the entirety of our application, and is intentionally as simple and useless as possible to better demonstrate the real purpose of this tutorial, which is setting up a minimal group of build tools required to get this thing working!

Setting up TypeScript and Webpack

By default, if we open up index.html in the browser, it's not going to find the bundle.js we added because that doesn't exist yet. This bundle.js will be created as an output of the TypeScript + Webpack build script (yarn build).

Of course, you could install the react-scripts package (used by CRA), or even simpler, just use create-react-app, but that's not our goal here. Our goal is to define the simplest setup possible, and to do that, we'll be configuring everything from scratch. We could of course use something else besides Webpack to do this, but Webpack is what I'm most familiar with and I would assume most devs are too.

Configuring TypeScript with React 18

For starters, we need a tsconfig.json. What does this do for us?

  1. Allows our editor (I'm using VSCode) to use intellisense for type-checking
  2. Allows the TypeScript compiler (tsc) to know how to compile typescript files (and JSX-flavored files).
  3. Used by Webpack to perform step #2

This will vary depending on your needs, but here's a relatively standard starter:

{
  "compilerOptions": {
    "jsx": "react-jsx" /* IMPORTANT! - this allows our JSX to be transpiled correctly */,
    "target": "es6" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */,
    "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */,
    "allowSyntheticDefaultImports": true /* Need for Webpack config file */,
    "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */,
    "strict": true /* Enable all strict type-checking options. */,
    "skipLibCheck": true /* Skip type checking all .d.ts files. */,
    "outDir": "dist",
    "moduleResolution": "node"
  },
  "include": ["**/*.ts", "**/*.tsx"]
}

Believe it or not, this is all we need to start compiling our files with TypeScript. If you've followed the steps above, you should be able to run:

yarn tsc

This will run the TypeScript compiler against all files we specified in the include array of our config and write them as ES6 (see target and module options) to the dist/ directory (see outDir option).

But... This isn't so helpful. We still don't have a bundle.js file that we can include in index.html, and index.html doesn't get copied to dist/ yet. It's time to bring in Webpack!

Configuring Webpack with React18 + TypeScript

We now need a file called webpack.config.ts in the root of our project.

import path from "path";
import { Configuration } from "webpack";
import CopyWebpackPlugin from "copy-webpack-plugin";

const config: Configuration = {
  mode:
    (process.env.NODE_ENV as "production" | "development" | undefined) ??
    "development",
  entry: "./src/entrypoint.tsx",
  module: {
    rules: [
      {
        test: /.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
  resolve: {
    extensions: [".tsx", ".ts", ".js"],
  },
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist"),
  },
  plugins: [
    new CopyWebpackPlugin({
      patterns: [{ from: "public" }],
    }),
  ],
};

export default config;

This has quite a bit going on in it, so let's take it a section at a time.

First, let's look at the input and output config:

{
  entry: './src/entrypoint.tsx',
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "dist")
  },
  resolve: {
    extensions: ['.tsx', '.ts', '.js'],
  },
}

The entry determines what file Webpack will build bundle.js from. The output then tells webpack to combine everything and save it to dist/bundle.js. Finally, resolve tells webpack how to resolve modules.

Next up, we have the loaders:

{
  module: {
    rules: [
      {
        test: /.tsx?$/,
        use: "ts-loader",
        exclude: /node_modules/,
      },
      {
        test: /\.css$/,
        use: ["style-loader", "css-loader"],
      },
    ],
  },
}

This specifies which loaders to use for files matched to the test property regex. It is important to understand that ts-loader will read our tsconfig.json file to know how to type-check our TS files and transpile our JSX to JavaScript that the browser can understand.

And finally, we have the CopyWebpackPlugin:

{
  plugins: [
    new CopyWebpackPlugin({
      patterns: [{ from: "public" }],
    }),
  ],
}

For larger projects, you generally won't be using this plugin. Instead, you might use the HTMLWebpackPlugin.

Since this project is so small, I'm using this to copy the entire public/ directory (which has index.html and globals.css) to the dist/ directory, which is where Webpack is going to save the bundle.js. In index.html, you'll see this line:

<script src="bundle.js"></script>

Based on this, bundle.js needs to be in the same directory as index.html. Furthermore, it is a good practice to keep all build outputs in one spot. In effect, after the build, our dist/ directory will look like this:

dist/
  index.html
  globals.css
  bundle.js

Building and serving the React app

At this point, everything is configured. In our webpack.config.ts, notice that we are using NODE_ENV to specify the output environment, so let's create a production build like so:

NODE_ENV=production yarn build

This will create the dist/ directory with everything needed to serve the app.

Open dist/index.html in your browser and you should see the app running!

Live Reloads with Webpack Dev Server

While our app is working, we definitely want the ability to make changes to the source without having to run yarn build every time. To do this, we can utilize Webpack dev server, which is now incredibly easy to set up.

All we need to do is run our script yarn dev, which will then run:

webpack serve

There is one very important detail to consider here. Webpack dev server rebuilds and stores the build output in memory, so you will not see the dist/ directory changing each time you make a change to your application. Additionally, do you recall this line in index.html?

<script src="bundle.js"></script>

How does the dev server know where to find index.html? If you look at stdout for the dev server cli, you'll see this line:

<i> [webpack-dev-server] Content not from webpack is served from ......

By default, Webpack dev server will look in the public/ folder for static content, which means by default, it is picking up our index.html and globals.css files and combining them with bundle.js, just like our build script does via the CopyWebpackPlugin.

If you have index.html in a different directory, you can configure the dev server in webpack.config.ts to load public assets from another location.

// webpack.config.ts

devServer: {
  static: {
    directory: path.resolve(__dirname, "your-custom-directory");
  }
}

Wrapping up

And that's it! A vanilla-(ish) React 18 app with TypeScript.

While I would generally recommend using automated tooling like create-react-app or create-next-app, hopefully this setup has been instructive and helps you understand what is going on behind the scenes with many JavaScript build toolchains.