How to create a React app from scratch (2024)

A guide for a simple development environment setup, with Typescript support and fast build

·

12 min read

How to create a React app from scratch (2024)

This is a guide for setting up the frontend of a web application that can import libraries from npm: the node package manager.

The article's title mentions React, but this is done for the purpose of helping people find this guide (aka "SEO"). Although we'll use React throughout the tutorial, the knowledge acquired from reading this guide is laregely transferrable to any kind of web project.

Preamble

You are trying to learn web development in 2023, and everyone seems to be using this thing called "react", but how are you supposed to use it?

You visit the official website, expecting to be able to download a "react.js" file that you can add to your static javascript directory, but no such file is provided.

Instead, they ship an npm package.

"npm" is a package manager for node.js, which is a runtime for executing javascript code outside the browser.

For historical reasons, it became the de-facto standard to ship all types of javascript libraries, even those meant to run inside the browser.

When you write javascript code for nodejs, you can import libraries that you have installed locally via npm, like this:

import React from "react"

When node.js starts interpreting/executing this code, it looks for a folder called "react" inside the node_modules/ directory. From there it looks for a special file package.json that provides some important metadata about this package that allows node.js to use it.

Now, trying to execute this exact same code in the browser will not work; the browser has no idea where to import "react" from.

Early in the life of the node.js ecosystem, a tool called "browserify" became popular: it allows code written for node.js to be executed in the browser.

It basically does this by combining all the packages imported by your script into a single javascript file that can be loaded into the browser.

This encouraged Javascript library developers to use npm as a package manager not only for server-side libraries, but for client-side Javascript libraries.

Later, another bundling tool called "webpack" would take its place to become the de-facto build tool for client-side javascript code.

There was only one problem: it was too complicated to setup properly.

For a long time, the official recommended way to start a new React project, as endorsed by the official website, was to use a tool called "create-react-app" (also known as: CRA).

This tool took care of creating a complicated webpack configuration that you don't need to worry about. The idea is that you can just start writing code and never worry about how the build system works. You get an easy to invoke command that would launch a "dev server" for local development, and another command for producing production build.

There are a few problems with this approach:

  • CRA installs a huge number of npm packages that you never asked for.

    When I tried running it at the time of this writing, it installed 1464 npm packages and takes 392MB of disk space, yet the resulting development environment does not support typescript out of the box!

  • The compliation process is very slow: creating a production build could take several minutes for non-trivial projects.

    You don't notice this when you start using it. You only notice it a few months down the road, when you have thousands of lines of code.

  • The configuration it creates is very complicated; most people have a very hard time trying to understand it if they need to tweak anything about it.

    By default, the configuration is hidden from you. You can ask CRA to expose the configuration, and it would happily oblige, but then you're on your own. You've "voided the warranty" so to speak. It will add a "config" and "scripts" directories to your workspace. Good luck trying to figure it out.

Now a days, the new official react documentation does not recommend create-react-app anymore, instead it recommends you install a full fledged framework that utilizes react, such as "next.js" or "remix.js", among others.

Now, there might be some reasons why using a framework could make sense, but I don't think it's the right thing for the official react documentation to make that recommendation. They should give simple instructions that are easy to follow to setup a bare-bones application.

If you are totally new to web development, the situation might appear desparate: apparently setting up a development environment is so complicated that you have no choice but to rely on big frameworks.

Luckily, the situation is not that desparate. There are other ways to start a new web project in 2023 that are much simpler.

What to expect from this guide

This guide aims to help you understand how to setup a development environment for web application frontends where you can:

  • Use npm packages

  • Write code in Typescript (optional)

  • Have very fast build times

  • Integrate with backend

  • Tweak the build system if needed

The guide is based on esbuild.

Please note however that this guide is not associated with esbuild in any way. I don't personally know the creator of esbuild, nor did I consult him in creating this guide.

Step by step

We’ll go through the following process step by step:

1. Setup an npm project and install dependencies

Create an empty directory and give it an appropriate name. For the purposes of this guide, I will call this directory "my-react-app".

In the directory, create the following file with the following content:

package.json:

{
    "private": true
}

The filename `"package.json"`` is not optional. The file must have this exact name. This will allow npm to recognize this directory as a project directory. When we run npm commands, npm may take the liberty to edit this file.

2. Install required packages

To create a react project, we need to install two packages: "react" and "react-dom".

npm install --save react react-dom

To setup our development environment, we’ll install "esbuild".

npm install --save-dev esbuild

Because we passsed --save and --save-dev to the above commands, npm edited our package.json file and it now looks like this:

{
    "private": true,
    "dependencies": {
        "react": "^18.2.0",
        "react-dom": "^18.2.0"
    },
    "devDependencies": {
        "esbuild": "^0.19.7"
    }
}

Note that the version numbers reflect the latest version as of the time of this document. In the future, version numbers will probably be different.

3. Project skeleton

We’ll create two directories: "www" and "src"

The www folder will contain static files we want to serve as-is

The src folder will contain source code in typescript (or javascript) that will use packages from npm and will need to be "bundled" before being loaded into the browser.

The content of the files will be bare minimum, with a grain of salt to verify the output.

www/index.html:

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>My React App!</title>
</head>
<body>
    <div id="root"></div>
    <script src="js/main.js"></script>
</body>
</html>

src/main.tsx:

import React from "react"
import ReactDOM from "react-dom/client"

let rootDiv = document.getElementById("root")
let root = ReactDOM.createRoot(rootDiv)
root.render(<App />)

function App(): JSX.Element {
    return <div>Hello, my new React App!</div>;
}

All we have is html code that creates a body element with one div, and javascript code that renders a specific component onto that div, designating it as the root.

The html file loads "js/main.js", even though there's no such path yet! This simply anticipates the output path from the build process.

4. Run a dev server

With esbuild installed, we can run a local dev server that bundles and serves this code on demand.

Run the following command:

npx esbuild src/main.tsx --outdir=www/js --servedir=www --bundle --sourcemap

I haven't at all explained how to use esbuild, but the command should be self-explanatory. You give it the entry point as the first argument, and then some parameters and flags. The --servedir=www tells it to run in server mode, so that instead of producing an actual build, it just runs a local server that serves files from the given directory. The --outdir parameter would normally tell esbuild where to place output files, but when running in serve mode, it doesn't actually put any files there; instead, the server will behave as if output files were placed there (but will serve them directly from RAM).

The output from esbuild should tell you which url to open. By default it should be http://127.0.0.1:8000/.

You can now open that URL in your web browser, and you should see an empty page with the plain text:

Hello, my new React App!

You can edit the src/main.tsx file to change the text label. When you save the file, esbuild will detect the save automatically and perform a rebuild. When you reload the page, you should see your changes reflected immediately.

This is all you need to get started. Sure, I didn't show you yet how to create a production build, but at least you now can start writing code and see the results reflected to you in the web browser running on your machine.

There are only 7 npm packages installed, and they take up 14MB of disk space. About 9.2MB of that is the esbuild binary.

Now, if you ask me, 5MB of javascript is still a lot of code, and 7 packages is 4 more packages than we asked for, but this result is orders of magnitude better than what you get from the initial CRA install with its nearly 1500 packages and 500MB of disk space.

4.1 Alternative dev mode

While the dev server is useful for quickly starting to develop UIs locally, it's not immediately clear how to use this when you have your own web server that you need to connect to.

This sections provides an alternative method that is more compatible with classic web development, where you already have a web server running, and you want to serve your html/css/js assets from the same server.

Simply put, use the esbuild command in watch mode, and just output to "www/js", or whatever the equivalent is for your server setup.

npx esbuild src/main.tsx --outdir=www/js --bundle --sourcemap --watch

This assumes you have your web server configured to run locally and use www as the static files directory.

This shoulud work well even with large projects. It's highly unlikely, even in extreme circumstances, for esbuild to spend more than 1 second to perform a rebuild when in watch mode.

Now, the unfortunate side effect is that this will pollute your js directory with build artifacts from development mode. If you follow this approach, you may want to add this path to the ignore list in your source control system, and perdiodically clear "www/js" directory.

5. Production build

I will define what a production build is in terms of old school web development: a production build gives you a set of directories that your production web server can serve statically, and they would include all of the html, js, and css files.

You can actually use the almost the exact same command we used for the local dev server. Just remove the --servedir parameter, and replace --sourcemap with --minify. That alone would be enough to produce a production-ready output file at www/js/main.js

npx esbuild src/main.tsx --outdir=www/js --bundle --minify

Here's what I get when I run it on my machine:

~/code/my-react-app
❯ npx esbuild src/main.tsx --outdir=www/js --bundle --minify

  www/js/main.js  138.8kb

⚡ Done in 15ms

You can now take the www folder and serve it publicly.

Now, maybe we don't want to pollute the www directory with build artifacts.

Maybe we have other things we want to do during our build process.

Generally speaking, we want more control over the build process.

It should be trivial to create a shell script that does what we want, but I think it'd be interesting to see how we can also do that in a node.js script.

Remember that node.js is a Javascript runtime, so we can use it to write short cross-platform scripts.

It should also be noted that esbuild exposes a Javascript API that can be invoked from node.js scripts.

Create build.mjs:

import * as esbuild from "esbuild"
import * as fs from "node:fs"

fs.rmSync("dist", { recursive: true })
fs.mkdirSync("dist")
fs.cpSync("www/", "dist/", { recursive: true })

/** @type {esbuild.BuildOptions} */
let buildOptions = {
    entryPoints: ["src/main.tsx"],
    outdir: "dist/js",
    bundle: true,
    minify: true,
    logLevel: "info"
}

try {
    esbuild.buildSync(buildOptions)
} catch {}

Now you can run:

node build.mjs

Here's what I see in my shell:

❯ node build.mjs

  dist/js/main.js  138.8kb

⚡ Done in 14ms

The script produces a "dist" directory by copying the contents of "www" into it then running the esbuild compiler and telling it to place the output js files in "dist/js".

This small build script can be used as a base to do more interesting things.

You can make it as simple or as complicated as you want.

For example, if you used the alternative dev mode method from section 4.1, you may want to add a command to delete the contents of "www/js" before copying the contents of "www" into "dist".

A couple of notes:

  • The logLevel param is required to reproduce the output from the CLI (command line interface) version. If you don't put this parameter, there will no output.

  • I wrap the call to buildSync in a try catch because an error during build can throw an exception and print a superfluous stacktrace.

    If you're curious, you can trigger an error by importing a non-existent package and check the output in the terminal.

6. Typechecking

This section is completely optional. You can develop web applications in Javascript.

However, many people (including the author of this document) prefer to use Typescript instead, in order to provide static type checking.

In general, you can use a text editor that supports typescript out of the box, such as "VSCode" or "Zed".

For more robust error reporting, you can run the typescript compiler in a terminal.

First, we need to install the compiler:

npm install --save-dev typescript

We also need to install type definitions for react

npm install --save-dev @types/react @types/react-dom

Next we need to configure the compiler by having a file called tsconfig.json. The filename is not optional.

Now, configuration files have a tendency to get very complicated, so for the purposes of this guide, we'll try to make it as bare minimum as possible, while still being useful.

tsconfig.json:

{
    "compilerOptions": {
        "baseUrl": "src",
        "jsx": "react",
        "noEmit": true,
        "strict": true,
        "skipLibCheck": true,
        "esModuleInterop": true,
    }
}

Now, we can run the compiler to check for errors

npx tsc

We can also run it in watch mode:

npx tsc --watch

Watch mode is useful to have continuously running in a terminal window all the time as you develop.

Note: normally, tsc would try to compile the typescript files and produce output .js files, so we turn off this feature with the noEmit option in the tsconfig file. to turn off the production of output files.

If we had not specified this option in the tsconfig file, we'd have to pass --noEmit flag on the command line.

6.1 Typescript for projects other than React

If you are using a different library, for example, Preact, you will need two additional config params inside "compilerOptions": "jsxFactory" and "jsxFragmentFactory".

What you put in there depends on how you import the library.

If you import the library this way:

import * as preact from "preact"

Then you need to configure these parameters as such:

    "jsxFactory": "preact.h",
    "jsxFragmentFactory": "preact.Fragment",

As an alternataive, you can write your imports this way:

import {h, Fragment} from "preact"

and configure the typescript compiler this way:

    "jsxFactory": "h",
    "jsxFragmentFactory": "Fragment",

Conclusion

In this guide, we saw how to setup a development environment that allows us to develop web frontends using modern javascript libraries from the node package manager (npm) using esbuild.