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/*
andtypescript
- TypeScript type definitions and compilerwebpack
,webpack-cli
,webpack-dev-server
- for running webpack builds via command line (see scripts inpackage.json
) and serving in local devts-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="root"
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?
- Allows our editor (I'm using VSCode) to use intellisense for type-checking
- Allows the TypeScript compiler (
tsc
) to know how to compile typescript files (and JSX-flavored files). - 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.