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:
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"
}
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"]
}
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);
})();
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,
};
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,
};
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 };
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",
};
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;
};
Until now the template is serving files at http://localhost:8080/serve, open this URL at your browser.
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>
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.
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();
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>`;
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,
};
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",
};
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>
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;
}
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
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;
.babelrc
{
"presets": ["@babel/preset-env", "@babel/preset-react", "@babel/preset-typescript"]
}
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 {};
__mocks__/file-mock.ts
export default "test-file-stub";
Nice! You can run your components tests.
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" }]
}
}
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.
Top comments (0)