Setup React with Typescript and esbuild.

jleonardo007

Leonardo Bravo

Posted on August 24, 2021

Setup React with Typescript and esbuild.

When we want to develop a React app the number one choice is Create React App (CRA), it is a complete framework ready to develop and ship your app, but this is the Javascript ecosystem and always gonna be a bunch of alternatives so one of them can be a development template similar to CRA based on Typescript and esbuild.

What is esbuild? you ask, well esbuild is like it's homepage describes it: "An extremely fast JavaScript bundler" and this is true, go to the homepage to checkout the benchmarks.

DISCLAIMER: this guide has the purpose to show how you can setup React, Typescript and esbuild as modules bundler, so you can use it in small projects, if this is not your case I strongly recomend use CRA.

Said that, let's go to write some code lines. First checkout the folder structure:
folder structure

As you see it, within this folder structure are the tipical folders public and src, like CRA src folder contains an entry point index.tsx this one is gonna be used by esbuild to generate the bundles, also includes another files that I explain bellow, the public folder contains the index.html that is used by the development server, the esbuild folder contains the files serve.ts and build.ts that creates the development server and builds the app respectively also includes a config file used by both files, the rest files are config files used by eslint and Jest (yes, this template also includes the popular test runner). Before dive into each folder and their respectives files checkout the package.json and tsconfig.json.

package.json

"scripts": {
    "type-check": "tsc",
    "start": "yarn type-check && ts-node esbuild/serve",
    "build": "yarn type-check && ts-node esbuild/build",
    "test": "yarn type-check && jest"
  },
  "dependencies": {
    "@types/node": "^12.0.0",
    "@types/react": "^17.0.0",
    "@types/react-dom": "^17.0.0",
    "esbuild": "^0.12.21",
    "open": "^8.2.1",
    "react": "^17.0.2",
    "react-dom": "^17.0.2",
    "ts-node": "^10.2.1",
    "typescript": "^4.1.2"
  },
  "devDependencies": {
    "@babel/preset-env": "^7.15.0",
    "@babel/preset-react": "^7.14.5",
    "@babel/preset-typescript": "^7.15.0",
    "@testing-library/jest-dom": "^5.14.1",
    "@testing-library/react": "^12.0.0",
    "@testing-library/user-event": "^13.2.1",
    "@types/jest": "^26.0.15",
    "babel-jest": "^27.0.6",
    "eslint": "^7.32.0",
    "eslint-plugin-jest-dom": "^3.9.0",
    "eslint-plugin-jsx-a11y": "^6.4.1",
    "eslint-plugin-react": "^7.24.0",
    "eslint-plugin-react-hooks": "^4.2.0",
    "eslint-plugin-testing-library": "^4.11.0",
    "jest": "^27.0.6"
  }
Enter fullscreen mode Exit fullscreen mode

These are all the dependencies you need to make this template works succesfully, maybe you found unfamiliar the open package, this one is gonna be used by serve.ts to open your default browser, the rest are tipical dependencies you find within a React-Typescript app. As follow, there are the scripts field, the type-check script as you guess is used to run the Typescript compiler before the another scripts. The rest scripts are related with the folders mentioned previously and are gonna be explain each other bellow.

tsconfig.json

{
  "ts-node": {
    "extends": "ts-node/node14/tsconfig.json",
    "transpileOnly": true,
    "files": true,
    "compilerOptions": {
      "target": "es6",
      "module": "commonjs",
      "esModuleInterop": true,
      "moduleResolution": "node"
    }
  },
  "compilerOptions": {
    "target": "es6",
    "baseUrl": "src",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "jsx": "react-jsx"
  },
  "include": ["src"],
  "exclude": ["node_modules"]
}
Enter fullscreen mode Exit fullscreen mode

About tsconfig.json the field compilerOptions setups the Typescript compiler when the type-check script runs, ts-node field setups the ts-node package this one allows execute the scripts start and build. Now, checkout the other scripts:

start

This script executes the serve.ts file, this file uses the esbuild.serve() method as follow:

function startDevServer() {
  createServer(async (...args) => {
    const res = args[1];
    try {
      const publicHTML = await readFile(join(PUBLIC_PATH, "index.html"), { encoding: "utf-8" });
      res.end(publicHTML);
    } catch (error) {
      console.log(error);
    }
  }).listen(DEV_SERVER_PORT, () => {
    console.log(`Development server is now running at ${DEV_SERVER_URL}`);
  });
}

(async () => {
  const server = await serve(serveOptions, transformOptions);
  const { host: HOST, port: PORT } = server;

  console.log("ESBuild is now serving your files at:");
  console.table({ HOST, PORT });

  startDevServer();
  await open(DEV_SERVER_URL);
})();
Enter fullscreen mode Exit fullscreen mode

First an IIFE is called, then the serve method is called, this method creates a local server that serves the bundled files (js, css and static files) based on serveOptions and transformOptions. This objects are provide by the config file mentioned previously.

serveOptions

export const serveOptions: ServeOptions = {
  servedir: "www",
  host: "127.0.0.1",
  port: 8080,
};
Enter fullscreen mode Exit fullscreen mode

serveOptions sets the server, this is http://localhost:8080.

transformOptions

export const transformOptions: BuildOptions = {
  entryPoints: ["src/index.tsx"],
  outdir: "www/serve",
  bundle: true,
  format: "esm",
  inject: ["esbuild/config/react-shim.ts"],
  loader: serveLoader,
};
Enter fullscreen mode Exit fullscreen mode

transformOptions sets esbuild that outputs the bundles at URL: http://localhost:8080/serve, this object has two keys, inject and loader. inject uses the file react-shim.ts this file allows auto import React:

react-shim.ts

import * as React from "react";
export { React };
Enter fullscreen mode Exit fullscreen mode

loader uses the object serveLoader, this loader sets esbuild to process static files as "dataurl" at development, the other option is process static files as "file" but is more convenient serve files as "dataurl".

const serveLoader: ILoader = {
  ".png": "dataurl",
  ".jpg": "dataurl",
  ".webp": "dataurl",
  ".jpeg": "dataurl",
  ".gif": "dataurl",
  ".svg": "dataurl",
};
Enter fullscreen mode Exit fullscreen mode

Based on the entry point file extension esbuild knows that have to process jsx syntax.

ServeOptions and TransformOptions are types provide by esbuild, ILoader is a type based on Loader type (also provide by esbuild).

ILoader

type ILoader = {
  [key: string]: Loader;
};
Enter fullscreen mode Exit fullscreen mode

Until now the template is serving files at http://localhost:8080/serve, open this URL at your browser.

localhost:8080

With this in mind, we can create an index.html file at public folder that consumes the files at http://localhost:8080/serve as follow:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta name="description" content="Web app created using React and ESbuild" />
    <link rel="manifest" href="manifest.json" />
    <!--
      Styles sheets provide by your React app are serve by the developement server running at http://localhost:8080/
      this server is created by Esbuild when executes the "start" script.
    -->
    <link rel="stylesheet" href="http://localhost:8080/serve/index.css" />
    <title>React ESbuild template with Typescript</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--
      The JS files are serve same way that the style sheets are.
    -->
    <script src="http://localhost:8080/serve/index.js" type="module"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now only left serve index.html, the function startDevServer at serve.ts takes care of this, first initializates a http server at http://localhost:3000, then reads the index.html and sends this one on each request.

start script

Well done! Now we can develop react apps, you only to do is reload your browser to view the changes you do.

build

The build script executes the build.ts file as follow:

import {
  PUBLIC_PATH,
  buildOptions,
  DEV_LINK_TAG,
  DEV_SCRIPT_TAG,
  BUILD_LINK_TAG,
  BUILD_SCRIPT_TAG,
  HTML_COMMENTS,
} from "./config";

const { readFile, writeFile, copyFile } = promises;

async function createHTMLFileAtBuildPath() {
  await copyFile(join(PUBLIC_PATH, "favicon.ico"), join("build", "favicon.ico"));
  await copyFile(join(PUBLIC_PATH, "manifest.json"), join("build", "manifest.json"));
  await copyFile(join(PUBLIC_PATH, "robots.txt"), join("build", "robots.txt"));

  const HTMLFileAtPublicPath = await readFile(join(PUBLIC_PATH, "index.html"), {
    encoding: "utf-8",
  });
  const HTMLFileAtBuildPath = HTMLFileAtPublicPath.replace(
    HTML_COMMENTS,
    "<!--Files generate by ESbuild-->"
  )
    .replace(DEV_LINK_TAG, BUILD_LINK_TAG)
    .replace(DEV_SCRIPT_TAG, BUILD_SCRIPT_TAG);

  writeFile(join("build", "index.html"), HTMLFileAtBuildPath, { encoding: "utf8" });
  console.log("Your build has been created succesfully");
}

buildSync(buildOptions);
createHTMLFileAtBuildPath();
Enter fullscreen mode Exit fullscreen mode

First imports some constants from config, these are used to process the index.html file at build time.

export const DEV_SERVER_PORT = 3000;
export const DEV_SERVER_URL = `http://localhost:${DEV_SERVER_PORT}`;
export const PUBLIC_PATH = "public";
export const HTML_COMMENTS = /<!--[\s\S]*?-->/g;
export const DEV_LINK_TAG = `<link rel="stylesheet" href="http://localhost:8080/serve/index.css" />`;
export const DEV_SCRIPT_TAG = `<script src="http://localhost:8080/serve/index.js" type="module"></script>`;
export const BUILD_LINK_TAG = `<link rel="stylesheet" href="index.css">`;
export const BUILD_SCRIPT_TAG = `<script src="index.js" type="module"></script>`;
Enter fullscreen mode Exit fullscreen mode

Then esbuild.buildSync() method is called, it processes the src/index.tsx file based on buildOptions object and outputs the generate bundles at build folder.

export const buildOptions: BuildOptions = {
  entryPoints: ["src/index.tsx"],
  outdir: "build",
  bundle: true,
  sourcemap: true,
  minify: true,
  format: "esm",
  inject: ["esbuild/config/react-shim.ts"],
  target: ["es6"],
  loader: buildLoader,
};
Enter fullscreen mode Exit fullscreen mode

buildOptions uses a diferent loader, this is because at build time the static files are output at build folder and pointed by esbuild in this path.

const buildLoader: ILoader = {
  ".png": "file",
  ".jpg": "file",
  ".webp": "file",
  ".jpeg": "file",
  ".gif": "file",
  ".svg": "file",
};
Enter fullscreen mode Exit fullscreen mode

After esbuild.buildSync runs createHTMLFileAtBuildPath() is called, first copies the files from public path to build path, then replaces the index.html developent tags by build tags and writes the new index.html at build folder.

index.html at build folder

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="icon" href="favicon.ico" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <meta name="theme-color" content="#000000" />
    <meta name="description" content="Web app created using React and ESbuild" />
    <link rel="manifest" href="manifest.json" />
    <!--Files generate by ESbuild-->
    <link rel="stylesheet" href="index.css">
    <title>React ESbuild template with Typescript</title>
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root"></div>
    <!--Files generate by ESbuild-->
    <script src="index.js" type="module"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

To run the start and build scripts without any kind of issue we need to add some files at src folder. First a env.d.ts this file allows us use external modules or files eg: the spinning React logo is a .svg file if we not declare this extension Typescript marks as an error, the solution is simple declare ".svg" file at .env.d.ts.

declare module "*.svg" {
  const content: any;
  export default content;
}
Enter fullscreen mode Exit fullscreen mode

You can declare all the external files or modules that you need. Another file we need is jest-setup.ts which allows add some global config like auto import react and testing-library/jest-dom assertions.

import "@testing-library/jest-dom";
import * as React from "react";
window.React = React; // Auto import React
Enter fullscreen mode Exit fullscreen mode

test

This template is incomplete if does not include a test runner, as I mentioned later, the files jest.config.ts and .babelrc are for setup Jest. These files:

jest.config.ts

import type { Config } from "@jest/types";

const config: Config.InitialOptions = {
  verbose: true,
  testEnvironment: "jsdom",
  setupFilesAfterEnv: ["<rootDir>/src/jest-setup.ts"],
  transform: {
    "^.+\\.[t|j]sx?$": "babel-jest",
  },
  moduleNameMapper: {
    "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$":
      "<rootDir>/src/__mocks__/file-mock.ts",
    "\\.(css|less)$": "<rootDir>/src/__mocks__/style-mock.ts",
  },
};

export default config;
Enter fullscreen mode Exit fullscreen mode

.babelrc

{
  "presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
}
Enter fullscreen mode Exit fullscreen mode

Also we must create a mocks folder at src for mocking css and external files see moduleNameMapper at jest.config.ts

__mocks__/styles-mock.ts

export {};
Enter fullscreen mode Exit fullscreen mode

__mocks__/file-mock.ts

export default "test-file-stub";
Enter fullscreen mode Exit fullscreen mode

Nice! You can run your components tests.
running test

Of course eslint is also include at this template.

.eslintrc

{
  "extends": [
    "eslint:recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "plugin:jsx-a11y/recommended",
    "plugin:testing-library/react",
    "plugin:jest-dom/recommended"
  ],
  "parserOptions": {
    "sourceType": "module",
    "ecmaVersion": "latest"
  },
  "env": { "browser": true, "es6": true, "jest": true },
  "rules": {
    "react/react-in-jsx-scope": "off",
    "react/prop-types": ["enabled", { "ignore": "ignore", "customValidators": "customValidator" }]
  }
}
Enter fullscreen mode Exit fullscreen mode

And that's it, to develop React apps apart CRA all we need is a modules bundler, and esbuild is a powerful, flexible and faster one. You can find the entire code at Github and go deep at implementation details. Hope this guide results useful for you.

Caveats

When you change any file at src folder esbuild.serve() refresh automatically files at http://localhost:8080/serve but you need to refresh your browser to see the new changes at your app.

💖 💪 🙅 🚩
jleonardo007
Leonardo Bravo

Posted on August 24, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related