My .NET focused coding blog.

Creating a React App From Scratch

A tutorial of how to setup a React web application from scratch without using any build tools, frameworks or compilers and then gradually evolving it, step-by-step, to include support for the following:

  • JSX in-browser transformation using a standalone build of Babel
  • JSX precompilation using the Babel command-line interface
  • JavaScript/ECMAScript 6 modules (ESM)
  • Consuming packages using the Node Package Manager
  • Bundling and caching using Webpack
  • Live reloading of changes in development mode using the Webpack Development Server
  • CSS including modules and pre-processing using Sass
  • Unit testing using the React Testing Library and Jest
  • JavaScript and CSS linting using ESLint and Stylelint respectively
  • Browser backwards compatibility and polyfilling using Babel, Browserslist, Core-js and PostCSS
  • Type safety and improved tooling support using TypeScript

 
You’ll end up with something that you can use a boilerplate for any React project that doesn’t need any full-stack framework specific features. Hopefully you will also get a better understanding of some of the most commonly used components in the open-source JavaScript ecosystem and what role they play in the context of developing, building and testing a client-side rendered React web application.

1. Start off by creating a new “root” folder where to store the application files:

mkdir reactapp
cd reactapp

2. Create a src folder in the root folder:

mkdir src
cd src

3. Create a components folder in the src folder:

mkdir components
cd components

4. Create an App.js functional component file in the components folder:

function App() {
  return React.createElement("div", null, "Hello React world!");
}

5. Add an index.js file to the src folder:

ReactDOM.createRoot(document.getElementById("root")).render(
  React.createElement(App, null, null)
)

6. Add an index.html file to the src folder with the react and react-dom libraries included as content delivery network (CDN) links:

<!DOCTYPE html>
<html lang="en">
  <head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<title>React Without JSX Demo App</title>
	<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
	<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
  </head>
  <body>
	<noscript>You need to enable JavaScript to run this app.</noscript>
	<div id="root"></div>
	<script src="components/App.js"></script>
	<script src="index.js"></script>
  </body>
</html>

If you then open the index.html file in a web browser, you should see the minimal React application with the App component which simply says “Hello React World!”.

JSX

JSX (JavaScript eXtensible Markup Language) is the syntax extension to JavaScript that is recommended (but not required) to use with React to describe the user interface. It must be compiled to regular JavaScript that can actually be executed in a browser. This is where Babel comes in.

7. Modify the src/components/App.js file to use JSX instead of creating the div HTML element using the React.createElement JavaScript function:

function App() {
  return (
	<div>
	  Hello React world!
	</div>
  );
}

8. Also modify the src/index.js file by replacing the React.createElement function call with the shorter and less verbose JSX syntax:

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

9. Embed a standalone build of Babel in src/index.html and set the type of the scripts to be compiled to text/jsx or text/babel:

<!DOCTYPE html>
<html lang="en">
  <head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<title>React With Transformed JSX Demo App</title>
	<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
	<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
	<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
  </head>
  <body>
	<noscript>You need to enable JavaScript to run this app.</noscript>
	<div id="root"></div>
	<script src="components/App.js" type="text/jsx"></script>
	<script src="index.js" type="text/jsx"></script>
  </body>
</html>

This approach of using the in-browser Babel transformer to transpile the JSX at runtime is not recommended for performance reasons. Also, the modified src/index.html file in will fail to load in the browser using the file:// protocol. This is because you get cross-origin resource sharing (CORS) errors when the babel-standalone script tries to load and compile the JSX files. You can solve the first issue by compiling your JSX code ahead of time (for production scenarios) and the latter by using a web server to host and serve the static files.

NPM

The next step would be to set up support for being able to consume libraries from the Node Package Manager (NPM) registry. It contains over 50,000 packages from the community, including both Babel and React itself.

10. Install the latest version of Node.js including NPM by either downloading an installer from https://nodejs.org/en/download or using a command-line tool. Node.js is not required in order to use React but you’ll need it on your development machine to be able to use development and test based tools such as NPM, Babel and Webpack.

11. Add a boilerplate package.json file to the root folder of the app:

{
  "name": "reactapp",
  "version": "0.1.0",
  "private": true
}

This document is used to manage the dependencies of an application or a package. The available settings are documented here. name and version are required and, unless you’re a library author, you should always remember to mark the package as private to prevent NPM from accidentally publishing your code to a public repository.

Babel

12. With NPM installed, you can now use it to install the Babel core compiler and the accompanied plugin that performs the actual JSX transformation as development dependencies to your app using the following command:

npm install --save-dev @babel/core @babel/plugin-transform-react-jsx

A node_modules folder that contains the code for all external packages that the app depends upon will be added to the root folder. An installed package may depend on another package which in turn depend on yet another package and so on. For example, the above command adds a total of 57 packages to the node_modules folder.

There is a difference between packages that are required by your application at runtime (production dependencies) and packages that are only needed for local development and testing (development dependencies). Packages that are installed using the --save-dev flag represent the latter type and they are added by NPM as devDependencies in the package.json file. The file will look like this after you have run the npm install command:

{
  "name": "reactapp",
  "version": "0.1.0",
  "private": true,
  "devDependencies": {
	"@babel/core": "^7.23.3",
	"@babel/plugin-transform-react-jsx": "^7.22.15"
  }
}

The automatically generated package-lock.json file contains a deterministic representation of the dependency tree. It should not be edited manually but, unlike the node_modules folder, it’s intended to be committed into a source repository. This enables you or someone else to install the exact same dependency versions on another machine. This file is also used by NPM to optimize the installation process by avoiding doing unnecessary network requests for packages that are already installed.

13. Next, add a babel.config.json file in the root directory with the following contents to configure Babel to actually use the JSX compiler plugin:

{
  "plugins": ["@babel/plugin-transform-react-jsx"]
}

14. Install the Babel command-line interface (CLI) as another development dependency:

npm install --save-dev @babel/cli

This will add a tool to the node_modules directory which you can use to compile the files in the src folder:

"node_modules/.bin/babel" src --out-dir outputFolder

If you then look at the .js files that Babel has produced in the outputFolder, you’ll see that they contain regular Javascript code and no JSX. For example, the <div> element in the App component source file (src/components/App.js) has been transformed into a React.createElement JavaScript function call:

function App() {
  return /*#__PURE__*/React.createElement("div", null, "Hello React world!");
}

If you remove the Babel stuff from src/index.html, you can then copy this file to the outputFolder and open it in the browser to run the app:

<!DOCTYPE html>
<html lang="en">
  <head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<title>React With Precompiled JSX Demo App</title>
	<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
	<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
  </head>
  <body>
	<noscript>You need to enable JavaScript to run this app.</noscript>
	<div id="root"></div>
	<script src="components/App.js"></script>
	<script src="index.js"></script>
  </body>
</html>

Scripts

You can automate the steps of compiling the JSX files and copy the HTML file to the output directory by adding a scripts field to the package.json file:

{
  "name": "reactapp",
  "version": "0.1.0",
  "private": true,
  "devDependencies": {
	"@babel/cli": "^7.23.0",
	"@babel/core": "^7.23.3",
	"@babel/plugin-transform-react-jsx": "^7.22.15"
  },
  "scripts": {
	"babel": "npx babel src --out-dir outputFolder && xcopy \".\\src\\index.html\" \".\\outputFolder\\index.html*\" /Y"
  }
}

Any custom script(s) defined in the file can be run using the npm command or npm run-script command:

npm run babel

The above “babel” script will build the app, i.e. run Babel to compile the JSX to regular JavaScript and copy the HTML file to the output directory, on Windows. On Unix-based operating systems, you can use the cp command to copy files:

"babel": "npx babel src --out-dir outputFolder && cp src/index.html outputFolder/index.html"

Modules

For a React component to be reusable and modular, you should define it in a separate file, export it from this file or module and then import it in another file where you intend use it, such as for example in another component. Using ESM and the import and export keywords is the recommended way of writing modular code for the web these days, regardless of whether you’re using React or not.

So far the JSX in src/index.js, which creates an instance of the App component, relies on the compiled components/App.js script file being loaded before the compiled src/index.js file in the src/index.html file for the app to work. In a larger application, it will be a lot easier to maintain the code-base if you use the import statement to define the actual dependencies of an individual module or component directly in its source code file.

15. Export the App function component from the src/components/App.js file using a default (or named) export:

export default function App() {
  return (
	<div>
	  Hello React world!
	</div>
  );
}

This is required for you to be able to import the component in another file.

16. Import the App component in the src/index.js file:

import App from './components/App.js';

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

17. Remove the script tag for the compiled components/App.js file in src/index.html and also set the type attribute of the script tag for index.js to module:

<!DOCTYPE html>
<html lang="en">
  <head>
	<meta charset="utf-8">
	<meta name="viewport" content="width=device-width, initial-scale=1">
	<title>React With Precompiled JSX and ESM Demo App</title>
	<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
	<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
  </head>
  <body>
	<noscript>You need to enable JavaScript to run this app.</noscript>
	<div id="root"></div>
	<script src="index.js" type="module"></script>
  </body>
</html>

If you build the app using the npm run babel command at this point, and then copy the contents of the outputFolder to a web server – modules don’t work with the file:// protocol – the app should still work.

18. Next, install the react and react-dom packages as a (production) dependencies to the app using the following command:

npm install react react-dom

The react library contains the core functionality of React and the react-dom package provides additional methods that are only supported and valid in the context of web applications which run in the browser document object model (DOM) environment.

19. Import the react-dom package as a dependency at the top of the src/index.js file, which already uses the library’s createRoot function to display the App component inside a DOM node in the browser:

import ReactDOM from "react-dom";

The reason why the app has worked so far is that the browser has has been able to resolve the reference to ReactDOM in src/index.js using the CDN link to react-dom.production.min.js in the header of the src/index.html file. If React had provided a default export in the script file, you could have imported it directly from the CDN like this:

import ReactDOM from "https://unpkg.com/react-dom@18/umd/react-dom.production.min.js";

The browser won’t be able to figure out what react-dom is though. To be able to use external dependencies such as NPM packages in your own code like this, you need a module bundler to resolve these dependencies for you at build time.

Webpack

Webpack is a popular “static module bundler for modern JavaScript applications”. It parses through your source code files to generate an internal dependency graph that describes the relationship between all files based on the dependencies imported into each file and the dependencies of these dependencies. It then combines every module that your application requires into a one or more production-ready Javascript files (bundles) that can be loaded in the browser as-is.

20. Install the webpack and webpack-cli packages as development dependencies:

npm install --save-dev webpack webpack-cli

Webpack only understands JavaScript and JSON by default. You can use loaders to add support for other types of files or formats, such as for example JSX.

21. Install the babel-loader package as another development dependency:

npm install --save-dev babel-loader

22. Optionally change the file extensions for the src/index.js and src/components/App.js files from .js to .jsx to indicate that they actually contain JSX and not regular JavaScript.

23. Also remember to change the file extension in the import statement for the App component in the (renamed) src/index.jsx file:

import App from "./components/App.jsx";

24. Add a webpack.config.js configuration file to the root folder of the project to configure Webpack to use the babel-loader to compile all .js and .jsx files that are not located in the node_modules folder:

const path = require("path");

module.exports = {
  entry: "./src/index.jsx",
  output: {
	path: path.resolve(__dirname, "./dist"),
	filename: "bundle.js",
	clean: true
  },
  module: {
	rules: [
	  {
		test: /\.(js|jsx)/,
		exclude: /node_modules/,
		loader: 'babel-loader'
	  }
	]
  }
};

In the above configuration file, I’ve specified the location of the module (src/index.jsx) that Webpack will use as the entry point when it’s building out its internal dependency graph. The default is src/index.js. I’ve also explicitly configured the default location where to emit the Javascript bundle file that it creates (dist/bundle.js). The clean option ensures that the dist folder is cleaned before each build. There are a bunch of other configuration options that you can use to customize the behaviour of Webpack, some of which will be described later on in this post.

25. Add another “build” script to the package.json file that uses the Webpack CLI to create the JavaScript bundle and copies the src/index.html to the dist directory:

"scripts": {
  ...
  "build": "webpack build --node-env=production && xcopy \".\\src\\index.html\" \".\\dist\\index.html*\" /Y"
}

26. Modify the src/index.html file to embed the JavaScript bundle that Webpack creates instead of the compiled index.js file:

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root"></div>
  <script src="bundle.js"></script>
</body>

If you then run the “build” script using the npm run build command and publishes the contents of the dist folder to a web server, you should be able to run the app.

27. The reason why the bundle.js file in the dist folder becomes pretty large despite the minimal code written here is that it includes the React and ReactDOM dependencies. If you’re fine with this, you can remove the CDN links in the src/index.html file.

If you still want to serve the React files through a CDN and at the same time keep your bundle(s) smaller, you can configure Webpack to exclude the dependencies from the bundle by including an externals configuration option in the webpack.config.js configuration file in the root folder:

module.exports = {
  externals: {
	"react": "React",
	"react-dom": "ReactDOM"
  },
  ...

At this point, you should also configure Babel to use the new improved JSX transformation that was introduced in version 17 of React. The old transformation turns JSX into React.createElement(...) calls which not only requires React to be in scope every time you use JSX but also prevents some performance improvements and simplifications. The new JSX transform automatically imports special functions from two new entry points in the React package and calls them, instead of transforming JSX to React.createElement. The change is fully compatible with all existing JSX code so you don’t have to change anything in your components.

28. Upgrade to the new JSX transform by passing {"runtime": "automatic"} as an option to the plugin in the babel.config.json file in the root folder:

{
  "plugins": [
	[
	  "@babel/plugin-transform-react-jsx", {
		"runtime": "automatic"
	  }
   ]
 ]
}

This will be the default option starting from version 8 of Babel by the way.

To get rid of the xcopy/cp command that copies the src/index.html file to the dist output folder, and to avoid having to define the name or path of this folder in more than once place, there is a convenient HtmlWebpackPlugin that can be used to either create an HTML file from scratch or modify an existing one during the build process.

29. Install the html-webpack-plugin package as a development dependency:

npm install --save-dev html-webpack-plugin

30. Configure the plugin in the webpack.config.js configuration file:

...
const HtmlWebpackPlugin = require("html-webpack-plugin");

module.exports = {
  ...
  module: {
	...
  },
  plugins: [
	new HtmlWebpackPlugin({
	  filename: "index.html",
	  template: "src/index.html"
	})
  ]
};

In this case, with the above configuration, the plugin will create a new minimized index.html file in the dist folder based on the contents of the src/index.html template file. It will basically create a copy of the HTML file in the src folder and inject a script tag in its header with the src attribute set to the name of the bundle, whatever that is configured to be in the webpack.config.js configuration file.

31. Remove the script tag for the bundle.js file from the src/index.html file as the plugin will now inject a script tag dynamically.

32. Remove the copy command from the “build” script in the package.json file:

"build": "webpack build --node-env=production"

Once you have configured Webpack to build the project, you probably have no further use for the Babel CLI. You can uninstall the package using the following command:

npm uninstall @babel/cli

Don’t forget to also remove the the “babel” script from the package.json file in the root folder.

DevServer

Webpack comes with a development server that comes in handy during the development and testing of an application. You can use it to run the app directly from the command-line without having to explicitly copying any files to a “real” web server first.

33. Install webpack-dev-server as another development dependency:

npm install --save-dev webpack-dev-server

34. Modify the webpack.config.js configuration file to export a function instead of an object:

const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");

var config = {
  entry: "./src/index.jsx",
  output: {
	path: path.resolve(__dirname, "./dist"),
	filename: "bundle.js",
	clean: true
  },
  module: {
	rules: [
	  {
		test: /\.(js|jsx)/,
		exclude: /node_modules/,
		loader: 'babel-loader'
	  }
	]
  },
  plugins: [
	new HtmlWebpackPlugin({
	  filename: "index.html",
	  template: "src/index.html"
	})
  ]
};

module.exports = (env, argv) => {
  return config;
};

With this change, you can now easily change the behaviour of Webpack according to the specified mode directly inside the configuration file. The mode is specified by using the --node-env CLI option to set the process.env.NODE_ENV environment variable in the script(s) in the package.json file. For the previously mentioned “build” command, I’ve set it to production. This enables some default optimization options.

35. Add some development specific configuration in the function in the webpack.config.js file:

...
const isDevelopmentMode = process.env.NODE_ENV === "development";
...
module.exports = (env, argv) => {

  if (isDevelopmentMode) {
	config.devtool = "inline-source-map";
	config.devServer = {
	  static: {
		directory: path.join(__dirname, "dist"),
	  },
	  port: 5000
	};
  }
  return config;
};

The devServer section is used to tell the development server where to look for the files to be served and what port to use. The inline-source-map option makes it easier to track down errors and warnings to their original location in the source code, which can otherwise be difficult when Webpack bundles several source files into one single bundle. This is what it does in the default production mode.

36. Add a “start” script to the package.json file which uses the webpack serve CLI command to start the development server in development mode:

"scripts": {
	"start": "webpack serve --node-env=development",
	...
}

37. Run the npm run start command and browse to http://localhost:5000 to see the app in action. You can use the --open CLI option to automatically open your default browser after the server has been started:

webpack serve --node-env=development --open

The development server provides live reloading which means that you can edit and save the source files in the src folder to refresh the app while it’s still running in the browser.

Caching

Web browsers cache resources to avoid having to download them from the server more times than necessary. This reduces the server load and bandwidth usage and improves the overall performance and user experience, but it also comes with the challenge of getting newly deployed code to be picked up as expected. Webpack solves this by providing a method of templating the names of the output files using substitutions. A substitution is a bracketed string that gets replaced by a specific value during the bundling process. The [contenthash] substitution can for example be used to append a unique hash value to a filename. Whenever the content of the output file changes, the hash value (and thus the filename) changes as well. This ensures that the file will only remain cached in the browser until its content changes.

38. Modify the webpack.config.js configuration file in the root folder by adding the [contenthash] substitution to the name of the output bundle, in production mode:

...
var config = {
  ...
  output: {
	path: path.resolve(__dirname, "./dist"),
	filename: isDevelopmentMode ? "bundle.js" : "bundle.[contenthash].js",
	clean: true
  }
  ...
};

39. It’s also a good practice to extract third-party libraries, such as react and react-dom, to a separate JavaScript bundle that can be cached separately as they are likely to change less often than your own source code. This can be done by adding an optimization.splitChunks object to the same webpack.config.js configuration file:

...
var config = {
  entry: {
	bundle: "./src/index.jsx"
  },
  output: {
	path: path.resolve(__dirname, "./dist"),
	filename: isDevelopmentMode ? "[name].js" : "[name].[contenthash].js",
	clean: true
  },
  ...
};
...
if (isDevelopmentMode) {
  ...
}
else {
  config.optimization = {
	minimize: true,
	splitChunks: {
	  cacheGroups: {
		dependencies: {
		  test: /[\\/]node_modules[\\/]/,
		  name: "deps",
		  chunks: "all"
		}
	  }
	}
  };
}

Using the above configuration, Webpack will produce an additional file named deps.[contenthash].js that contains the code for the React library itself, including the DOM-specific stuff, and any other production dependencies in the node_modules folder that the application uses at runtime. Note that the hardcoded “dist” name has been replaced by a [name] substitution for the output.name option. [name] is another example of a substitution. It represents the name of a “chunk” or module during the bundling process. Also note that the name for the main bundle that contains the actual application code is configured to be “bundle” in the entry object above. The default is “main”.

CSS

You style your web app and React components using cascading style sheets (CSS). There are several ways to do this. You could for example use an inline style attribute that you set to a JavaScript object:

<div style={{color: "red", fontSize: "32px"}}>
  Hello React world!
</div>

A better approach, from both a maintenance and performance perspective, is to use an external file to separate your styles from your markup.

40. Add a new index.css file to the src folder with the following contents:

body {
  background-color: #D1D5DB;
}

41. Import the CSS file into the src/index.jsx file:

import ReactDOM from "react-dom";
import App from "./components/App.jsx";
import "./index.css";

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

42. Install the css-loader package as a development dependency to the app:

npm install --save-dev css-loader

This is required for Webpack to be able to resolve the CSS file. Remember that Webpack only understands Javascript and JSON by default.

43. Configure Webpack to use the css-loader for all CSS files by adding a new rule in the webpack.config.js file in the root folder:

module: {
  rules: [
	{
	  ...
	},
	{
	  test: /\.css$/,
	  loader: "css-loader"
	}
  ]
}

When you build the app using Webpack, the css-loader will add the CSS into the bundle JavaScript file. You need to use another loader to add the styles to the DOM so they can actually be used in the component(s).

44. Install the style-loader package as another development dependency:

npm install --save-dev style-loader

45. Modify the CSS rule in the webpack.config.js configuration file to use this loader as well:

module: {
  rules: [
	{
	  ...
	},
	{
	  test: /\.css$/,
	  use: [
		"style-loader",
		"css-loader"
	  ]
	}
  ]
}

At this point you should be able to run the app using the npm run start command and see that the background colour has changed according to the body style defined in the src/index.css file.

For production scenarios, it’s recommended to extract the CSS from the JavaScript bundle to enable parallel loading and caching of resources. You can use the mini-css-extract-plugin package to do this.

46. Install the mini-css-extract-plugin as a development dependency:

npm install --save-dev mini-css-extract-plugin

47. Configure Webpack to use the plugin to extract the CSS in production mode – the style-loader can with advantage still be used in development mode as injecting CSS into the DOM using multiple style elements enables hot editing of the CSS – by editing the webpack.config.js configuration file:

...
const MiniCssExtractPlugin = require("mini-css-extract-plugin");

module.exports = (env, argv) => {

  var config = {
	...
	module: {
	  rules: [
		{
		  ...
		},
		{
		  test: /\.css$/,
		  use: [
			isDevelopmentMode ? "style-loader" : MiniCssExtractPlugin.loader,
			"css-loader"
		  ]
		}
	  ]
	},
	plugins: [
	  ...
	]
  };

  if (isDevelopmentMode) {
	...
  }
  else {
	...
	config.plugins.push(new MiniCssExtractPlugin({
	  filename: "styles.[contenthash].css"
	}));
  }
  return config;
};

When you run the npm run build command, Webpack will add a single file that contains all of your CSS to the dist folder by default. The name of the CSS file is determined by the plugin’s filename option. It’s set to styles.[contenthash].css in the configuration above.

48. To optimize and minify the CSS in production builds, install the css-minimizer-webpack-plugin package as another development dependency:

npm install --save-dev css-minimizer-webpack-plugin

49. Then add the plugin to the Webpack configuration by editing the webpack.config.js file once again:

...
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

module.exports = (env, argv) => {

  var config = {
	...
  };

  if (isDevelopmentMode) {
	...
  }
  else {
	config.optimization = {
	  ...
	  minimizer: [
		`...`,
		new CssMinimizerPlugin(),
	  ]
	};
	...
  }
  return config;
};

CSS Modules

When styling individual React components, you generally want to use CSS modules. Unlike CSS classes that live in the global scope, CSS defined inside a module is available only for the component that imported this module. This means that you don’t have to worry about name conflicts when you define a style that is supposed to apply to an individual component only.

50. Add a new App.module.css file to the src/components folder:

.red {
  color: red;
}

.large {
  font-size: 32px;
}

51. Import the CSS file into the App component in the src/components/App.jsx file and use the selectors defined in the CSS module to style the text:

import styles from "./App.module.css"; 

export default function App() {
  return (
	<div className={`${styles.red} ${styles.large}`}>
	  Hello React world!
	</div>
  );
}

The css-loader handles CSS modules for all files whose name ends with “modules.css” by default and without any additional configuration. If you run the app using the npm run start command at this point, you should see the styles being applied to the “Hello React World!” text.

Sass

As your stylesheets are getting more complex and become harder to maintain, you typically use a CSS pre-processor such as Sass to be able to use features like variables, inheritance, nesting or mathematical operators in your CSS files.

Sass stands for Syntactically Awesome Stylesheet. It’s an extension to CSS that must be compiled to regular CSS that can be loaded in a web browser.

52. Install the sass and sass-loader packages as a development dependencies:

npm install --save-dev sass sass-loader

53. Configure Webpack to compile all .css, sass and .scss files to regular CSS using the sass-loader by editing the webpack.config.js configuration file:

module: {
  rules: [
	{
	  ...
	},
	{
	  test: /\.(css|sass|scss)$/,
	  use: [
		isDevelopmentMode ? "style-loader" : MiniCssExtractPlugin.loader,
		"css-loader",
		"sass-loader"
	  ]
	}
  ]
}

54. To verify the that the CSS compilation works as expected, modify the src/index.css file to use a variable that defines the background colour:

$background-color: #D1D5DB;

body {
  background-color: $background-color;
}

55. Optionally rename the file to index.scss and change the import statement in the src/index.jsx file to reflect the new name:

import "./index.scss";

If you then run the npm run start command and browse to http://localhost:5000, you should be able to edit the background-color variable in the src/index.scss file and see the background getting dynamically changed in the browser when you save the file.

Sass also supports the concept of modules, which means that you can split up your Sass across several files. You may for example define several variables in a file called variables.scss and then use these variables in any other file with the @use rule:

//variables.scss:
$background-color: #D1D5DB;

//index.scss:
@use "variables";

body {
  background-color: variables.$background-color;
}

Assets

When it comes to static asset files such as for example images, Webpack 5 and later versions emits them to the output folder without using any additional loaders. You can configure whether you want to emit a specific asset type as a separate file, an inline data URI or as a string of the raw contents of the asset.

56. Configure Webpack to emit .gif, .jpg, .jpeg and .png files as separate files by adding a new rule to the webpack.config.js configuration file:

module: {
  rules: [
	...
	{
	  test: /\.(gif|jpg|jpeg|png)/,
	  type: "asset/resource",
	  generator: {
		filename: "images/[name][ext][query]"
	  }
	}
  ]
}

Setting the asset module type to asset/resource tells Webpack to emit the file to the output directory and export the URL. The default name of the file is a hash number ([hash][ext][query]). You can customize the name using the Rule.generator.filename option as shown above. [name] is the substitution that refers to the name of the file, [ext] is another substitution for the file extension and [query] is yet another substitution for the module query, i.e. any string following “?” in the filename.

With this configuration in place, you can import and use image resources in your components like this:

import image from './image.png'; 
...
<img src={image}></img>

57. Add another rule to the webpack.config.js file to handle .svg files:

{
  test: /\.(svg)/,
  type: "asset",
  generator: {
	filename: "images/[name][ext][query]"
  }
}

Setting the asset module type to asset/inline will inline and inject the Base64 encoded – you can specify a custom encoding algorithm – contents of any imported .svg file into the bundle. If you instead use the general asset type, Webpack will automatically choose whether to emit a file or inline the encoded contents of the file. By default, any file with a size less than 8kb will be treated as an asset/inline module type and any larger file will be treated as asset/resource. You can change this condition using the Rule.parser.dataUrlCondition.maxSize option.

The last module type asset/source is used to export the raw contents of an asset. It can for example be used to inject a .txt file directly into the bundle as-is. If you plan to import .txt files or any other type of file except the ones mentioned here, remember to add a corresponding rule for each such file extension in the webpack.config.js file or add the file extension(s) to the test option of an existing rule in the same file.

Assets referenced in CSS files are handled the same way and by same configuration as assets imported into the JavaScript or JSX files.

SVG

With the above configuration for .svg files, you can import and use them as the src for any image tags in your components. However, rendering a SVG as an image like this is not advisable as it causes it to lose its original scalable vector characteristics. In React, you can instead choose to render a SVG as a component. There is a SVGR tool that transforms .svg files into React components for you so you don’t have to manually create a custom React component for each of your SVG assets.

58. Install the Webpack loader for SVGR as a development dependency:

npm install --save-dev @svgr/webpack

59. Configure Webpack to use the loader by adding another rule for .svg files in the webpack.config.js configuration file in the root folder:

{
  test: /\.(svg)/,
  issuer:/\.(js|jsx|ts|tsx)/,
  resourceQuery: { not: [/url/] }, // exclude react component if *.svg?asset
  use: [
	{
	  loader: "@svgr/webpack",
	  options:  { exportType: "named" }
	}
  ]
}

The issuer option ensures that SVGR will only apply if the SVG is imported from a JavaScript or TypeScript file. Any .svg files used in your CSS won’t be affected. Using the resourceQuery option is an easy way to enable support for importing .svg files as both assets and React components in the same project. The above configuration will ensure that Webpack only uses SVGR to handle SVG imports without a “asset” query parameter appended to the filename. Setting the exportType option to named enables you to import SVG assets using the following syntax:

import { ReactComponent as Svg } from "./file.svg";

The named export defaults to ReactComponent but can be customized with the namedExport option. If you prefer to use the shorter import Svg from "./file.svg" syntax, you can just avoid setting the exportType option or set it to default.

60. Don’t forget to also add a resourceQuery option to the first .svg rule in the webpack.config.js configuration file to instruct Webpack to only apply this rule to .svg files that are imported with the “asset” query parameter appended:

{
  test: /\.(svg)/,
  type: "asset",
  generator: {
	filename: "images/[name][ext][query]"
  },
  resourceQuery: /asset/, // *.svg?asset
}

Using this configuration, you can then handle a SVG as either an asset or a React component depending on whether you add the “url” query parameter to the filename when you import the .svg asset. You can even combine both approaches in the same source file:

import svg from './file.svg?asset'
import Svg from './file.svg'

const SomeComponent = () => {
  return (
	<div>
	  <img src={svg} width="200" height="200" />
	  <Svg width="200" height="200" />
	</div>
  )
}

Unit tests

Pretty soon after you have setup your project, you’ll want to test the behaviour of your React components. This basically means that you render the component, get an element from it or simulate any user interaction, and write an assertion on the output.

The React Testing Library is a recommended and light-weight solution for doing this. It provides a virtual DOM that lets you test your components in isolation without relying on or involving any of their implementation details.

61. Install the @testing-library/react package as a development dependency:

npm install --save-dev @testing-library/react

Then you also need a test runner such as Jest to be able to discover the tests, run them and determine whether they actually pass or fail. Besides the test runner, there is also a convenient jest-dom utility library that provides “matchers”, or functions, that lets you assert on the state of the DOM without having to repeatedly check various attributes of elements. Using it tends to make your tests more declarative and easier to maintain.

62. Install the jest and @testing-library/jest-dom libraries as additional development dependencies:

npm install --save-dev jest @testing-library/jest-dom

63. Add an App.test.jsx file to the src/components folder to test the (very simplistic) functionality of the App component:

import "@testing-library/jest-dom";
import "@testing-library/jest-dom/jest-globals";
import {render, screen} from "@testing-library/react"
import App from "./App.jsx";

test("Rendering of the App component", () => {
  render(<App/>);

  const element = screen.getByText("Hello React world!");

  expect(element).toBeInTheDocument();
});

toBeInTheDocument is an example of a custom matcher provided by the jest-dom library. The render and getByText functions are provided by the React Testing Library.

64. Add a “test” command to the package.json file in the root folder:

        "scripts": {
          ...
          "test": "jest"
        }

When you run the npm run test command, Jest will by default look for any .js, .jsx, .ts and .tsx files inside of __tests__ folders as well as any files with a suffix of .test or .spec (e.g. App.test.jsx) inside any other folders.

If you run the “test” command and get an error saying something like “cannot use import statement outside a module”, it’s because Jest’s support for ESM – at least at the time of writing this – is experimental. You may be able to activate ESM support in your tests by configuring code transforms to be disabled and use experimental Node APIs as explained in the docs.

A more viable approach would be to use Babel to convert code written in ECMAScript 2015/ES6+, such as the src/components/App.test.jsx file, into a backwards compatible version of JavaScript that can also be run in older browsers depending on your configuration.

65. Install the @babel/preset-env package as a development dependency:

npm install --save-dev @babel/preset-env

66. Add the preset to the babel.config.json file in the root folder:

        {
          "presets": ["@babel/preset-env"],
          "plugins": [
            [
              "@babel/plugin-transform-react-jsx", {
                "runtime": "automatic"
              }
            ]
          ]
        }

A preset is a bundle of plugins. In the case of @babel/preset-env, it basically contains a plugin for each modern JavaScript feature. There is for example one plugin for transforming arrow functions (that were introduced in ES6) and another one that validates const variables and so on. There is also an official preset for React that contains the previously mentioned @babel/plugin-transform-react-jsx plugin.

67. Uninstall the @babel/plugin-transform-react-jsx package:

npm uninstall @babel/plugin-transform-react-jsx

68. Replace it with the @babel/preset-react package:

npm install --save-dev @babel/preset-react

69. Modify the babel.config.json file to use the preset instead of the plugin:

        {
          "presets": [
            [
              "@babel/preset-env"
            ],
            [
              "@babel/preset-react", {
                "runtime": "automatic"
              }
            ]
          ]
        }

Jest supports Babel out of the box but using it to transform the import module statements is not enough to be able to run tests on components that import stylesheets and other asset files. You also need to mock these dependencies.

70. Add a jest.config.json configuration file to root folder of the project where you configure Jest to mock out the asset files from the components using mock files:

        {
          "moduleNameMapper": {
            "^(?!.*\\.module\\.(css|sass|scss)$).*\\.(css|sass|scss)$": "<rootDir>/tests/cssMock.js",
            "^.+\\.(gif|jpg|jpeg|png)$": "<rootDir>/tests/fileMock.js",
            "^.+\\.svg\\?(asset)$": "<rootDir>/tests/fileMock.js",
            "^.+\\.svg$": "<rootDir>/tests/svgrMock.js"
          }
        }

A mock file is a JavaScript file where you can implement custom logic to define how an asset of a specific type should be represented in your tests.

71. Create a tests folder in the root folder:

mkdir tests
cd tests

72. Optionally move the src/components/App.test.jsx file to the tests folder and edit the import path for the App component in it:

import App from "../src/components/App.jsx";

73. Create a cssMock.js file in the tests folder that turns CSS imports into empty objects in the tests:

module.exports = {};

74. Create a fileMock.js file in the tests folder that returns the name of an imported file during testing:

const path = require("path");

module.exports = {
  process(sourceText, sourcePath) {
	return {
	  code: `module.exports = ${JSON.stringify(path.basename(sourcePath))};`,
	};
  },
};

75. Create a svgrMock.js file in the tests folder that mocks SVGR components:

export default "svg";
export const ReactComponent = "div";

The recommended approach for mocking CSS Modules is to use an ES6 proxy object, which basically is a wrapper for another object that can intercept and redefine fundamental operations for that object. The idea here is to return the className lookups on imported styles objects in the components as they are. For example, when testing the src/components/App.jsx component, styles.red will be returned as “red” and styles.large will be returned as “large”.

76. There is a recommended package that provides the proxy implementation. Install it as a development dependency:

npm install --save-dev identity-obj-proxy

77. Configure Jest to use the proxy implementation to mock any imported *.module.{css,sass,scss} files by defining another mapping in the jest.config.json configuration file in the root folder:

{
  "moduleNameMapper": {
	...
	"^.+\\.module\\.(css|sass|scss)$": "identity-obj-proxy"
  }
}

If you use more types of files in your application besides stylesheets, images and SVGs, remember to handle these as well by adding the corresponding file extensions to the jest.config.json configuration file and possibly also adding additional mock files.

The default test environment that will be used for testing in Jest is a Node.js environment. When developing a web application, you should configure Jest to run the tests in a browser-like environment.

78. Install the jest-environment-jsdom package as a development dependency:

npm install --save-dev jest-environment-jsdom

79. Modify the jest.config.json configuration file in the root folder to configure Jest to use the browser-like jsdom environment that is provided by the jest-environment-jsdom package (since version 28 of Jest):

{
  "moduleNameMapper": {
	...
  },
  "testEnvironment": "jsdom"
}

Alternatively, you can add a @jest-environment docblock at top of the test file to specify the environment to be used for all tests in that particular file:

/**
 * @jest-environment jsdom
 */

With the above configuration, you should be able to successfully run the test(s) using the npm run test command. Jest has support for mocking implementations or even entire modules when your code reaches this stage, and it can also generate code coverage by simply adding the --coverage flag to “test” command in the package.json file.

Linting

Besides writing unit tests to validate that your code works as expected, you should also use a linter to detect any stylistic or formatting errors in your code and to ensure that you adhere to a certain coding standard. This is especially important when working with an interpreted programming language like JavaScript that isn’t compiled before execution. Even if you use a source code editor or an integrated developing environment (IDE) that has built-in support for static code analysis, it’s a good practice to use a linter as part of your build process.

80. ESLint is a popular linting tool for JavaScript and JSX. Install it as a development dependency along with the eslint-plugin-react package:

npm install --save-dev eslint eslint-plugin-react

eslint-plugin-react is a plugin – a custom extension that let’s you add custom rules that are not included in the core package – that includes React specific rules, such as for example enforcing consistent usage of the component lifecycle methods.

81. Add an eslint.config.js file to the root directory to configure ESLint:

const js = require("@eslint/js");
const reactRecommended = require("eslint-plugin-react/configs/recommended");
const jsxRuntime = require("eslint-plugin-react/configs/jsx-runtime");
const globals = require("globals");

module.exports = [
  js.configs.recommended,
  reactRecommended,
  jsxRuntime,
  {
	files: ["{src,tests}/**/*.{js,jsx}"],
	languageOptions: {
	  globals: {
		...globals.browser,
		...globals.jest,
		...globals.node
	  }
	},
	settings: {
	  react: {
		version: "detect"
	  }
	}
  }
];

The plugin exports the recommended configuration that enforces some common default React rules and the jsx-runtime configuration that disables the relevant rules for the old JSX transform that was used in earlier versions of React. js.configs.recommended is a pre-defined configuration from the @eslint/js dependency (used by the eslint package) that enables the rules that ESLint recommends everyone to use. You can change/override settings from these shareable configurations by editing the configuration file as described in the documentation.

The only setting I’ve specified here is the React version. The linter will produce a warning if you don’t. Setting it to “detect” automatically picks the version you are using.

languageOptions.globals specifies additional objects that should be added to the global scope during linting, such as for example browser global variables. This makes ESLint aware of what document is in the src/index.jsx file for example. The jest globals are required to be able to lint the tests/App.test.jsx Jest test file and the node globals are used when linting the mock files in the same folder. The shareable configurations don’t preconfigure any globals or glob patterns for the files that the configuration should apply to.

82. Install the globals package, which contains the global identifiers for different JavaScript environments, as a development dependency:

npm install --save-dev globals

83. Add a “lint” command to the package.json file that runs ESLint on all .js and .jsx files in the src and tests folders:

"scripts": {
  ...
  "lint": "eslint {src,tests}/**/*.{js,jsx}"
}

If you break a rule in any of your source files, such as for example declaring a variable that is not used anywhere in the code, running the npm run lint command will output an error message. Running the npm run build command will not however. This is because Webpack hasn’t yet been configured to use ESLint.

84. Install the eslint-webpack-plugin package as a development dependency:

npm install --save-dev eslint-webpack-plugin

85. Configure Webpack to use ESLint in the build process by editing the webpack.config.js file in the root folder:

...
const ESLintPlugin = require("eslint-webpack-plugin");

var config = {
  ...
  plugins: [
	...
	new ESLintPlugin({
	  extensions: ['js', 'jsx']
	})
  ]
};
...

If you then run the npm run build command and get an error saying that “No ESLint configuration found”, it’s because the latest version of eslint-webpack-plugin – 4.0.1 at the time of writing this – doesn’t support ESLint’s new configuration system. There is a feature proposal on GitHub. Until it has been implemented, you need to use the legacy configuration format.

86. Add a new .eslintrc.json file in the root folder:

{
  "root": true,
  "extends": [
	"eslint:recommended",
	"plugin:react/recommended",
	"plugin:react/jsx-runtime"
  ],
  "env": {
	"es2024": true,
	"browser": true,
	"jest": true,
	"node": true
  },
  "parserOptions": {
	"sourceType": "module"
  },
  "settings": {
	"react": {
	  "version": "detect"
	}
  }
}

Optionally also remove or rename the eslint.config.js file if you don’t want to duplicate your configuration of ESLint across more than one file.

The es2024 environment specified above sets the ecmaVersion parser option to 15. The default value for languageOptions.ecmaVersion in the new configuration system is “latest”. In the legacy config system it is 5, which means that you must change it in order to be able to lint ES6+ code. languageOptions.sourceType defaults to module for JavaScript files in the new system but the default for parserOptions.sourceType is script in the old system. The rest should be same as before and with the addition of this file the linting should now be enabled when you run the npm run build command.

Browser support

If you have a requirement to support older web browsers, you can configure Babel to compile the modern or next generation JavaScript that you use in your source code files into older JavaScript code that is compatible with whatever browsers you want to support. Babel integrates with the widely used Browserslist tool that provides a query syntax that lets you specify which browsers you actually want to support.

87. Add a .browserslistrc file to the root folder of the application where you specify the browsers that you want your app to target in production and development mode respectively:

[production]
> 0.5%, last 2 versions, Firefox ESR, not dead

[development]
> 0.5%, last 2 versions, Firefox ESR, not dead

The above configuration (which is the same for both production and development but demonstrates how you can easily change this if you want to) corresponds to the defaults query in browserslist. It’s generally recommended as a starting point if you are building a public web application for the global audience. It matches recent versions of popular and supported browsers worldwide and also includes the Firefox Extended Support Release which is updated annually roughly. You can browse to https://browsersl.ist/ to find examples of how to select specific browser versions or versions with a certain usage in a specific region or country. The full list of supported queries can be found on GitHub.

Using a .browserslistrc file is the recommended way to do specify which browsers you want to support. Other options include adding a browserslist key in the package.json file or a target option in the babel.config.json file, or using a BROWSERSLIST environment variable. In Babel 7 and earlier versions, the default behaviour is to transform the code to be ES5 compatible if you don’t specify any targets at all. Unlike Browserslist, Babel 7 and earlier versions don’t use the defaults query when there are no targets found in any of the configuration files. If you want to use the defaults query as-is, without explicitly specifying the actual defaults like I’ve done in the .browserslistrc file above, you can remove the .browserslistrc file and instead pass defaults as a target in the babel.config.json configuration file:

{
  "presets": [
	[
	  "@babel/preset-env",  {
		"targets": "defaults",
		"debug": true 
	  }
	]
  ],
  ...
}

Setting the debug option to true is helpful to be able to determine the actual targets by looking at the output of the npm run build command.

Polyfills

Using Babel to compile modern JavaScript to a backwards compatible version is not enough to make an application work in older browsers. Any new language features that are not purely syntactic, such as any new types and functions, must be polyfilled for your code to work in browsers that don’t natively support the version of JavaScript that your code has been written in.

A polyfill is essentially a piece of code that tries to mimic the functionality of a missing native implementation of an API that your code relies on. If you for example use a Promise in JavaScript to represent the result of an asynchronous operation and want to be able to run your code in a browser that doesn’t support ES6, you need a polyfill. If you on the other hand want to be able to use arrow functions, you need a compiler such as Babel to transform the code into a different syntax that older browsers understand. So a polyfill can add shims for missing types, functions and objects but it cannot make a browser or compiler accept another syntax.

88. As of version 7.4.0 of Babel, polyfills are handled by first installing the core-js package as a development dependency:

npm install --save-dev core-js

This package contains a collection of polyfills to support both stable and experimental features of the latest ECMAScript specification.

89. Next, you configure how Babel should handle polyfills and the version of core-js to target in the babel.config.json file in the root folder:

{
  "presets": [
	[
	  "@babel/preset-env", {
		"useBuiltIns": "entry",
		"corejs": "3.33"
	  }
	],
	...
  ]
}

Specifying the minor version is recommended as “3” will be interpreted as “3.0” which means that polyfills for the latest features may not be included. Setting the useBuiltIns option to entry will make Babel look for an import "core-js" or import "core-js/stable" statement in the src/index.jsx entry file at build time and replace it with imports for the individual polyfills that are required for the target browsers you have specified in the .browserslistrc file (or using any of the other configuration options).

90. Add this import statement at the top of the src/index.jsx file (or use import "core-js"; if you also want to polyfill proposed features):

import "core-js/stable";

The other useBuiltIns option is usage. It will automatically import the required polyfills in each JavaScript/JSX file when the usage of some feature that is unsupported in any of your target browsers is detected in that file. The bundler will still load the same polyfill only once so using this option generally results in smaller bundles. The caveat here is that polyfills that are required by your dependencies in the node_modules folder, such as for example the react library, will not be detected as this code is not processed with Babel.

Using polyfills comes with the challenge of finding the right balance between the compatibility, performance and maintainability of your application. They do undoubtedly add to the overall size of the output bundle, and by default Webpack emits a warning when the bundle exceeds the recommended size limit of 244kb. If you run into this, the obvious solution to avoid increased load times (even in modern browsers) is to reduce the number of target browsers. Another option is to try to lazy load polyfills dynamically when they are needed or provide different bundles for different browsers. Also note that if you use APIs that are not part of the ECMAScript specification or/and are not handled by core-js, such as for example the browser fetch API, you may need to install additional packages to get backwards compatibility for these features depending on which browsers you are targeting and what APIs they support natively.

PostCSS

Just like with JavaScript, the support for modern CSS varies between different browsers. PostCSS is a popular tool that lets you transform modern CSS into something that most browsers can understand. You can say that is to CSS what Babel is to JavaScript.

91. Install the postcss package as a development dependency:

npm install --save-dev postcss

The postcss tool itself parses the CSS into an abstract syntax tree (ABT). It then relies on a large ecosystem of configurable plugins to do various things with the CSS using an API that the AST provides, before converting the AST back into a string that gets outputted to a file that can be loaded in the browser.

92. The postcss-preset-env package is a plugin pack for PostCSS that performs transformation and polyfilling of some (but not all) missing CSS features. Install it as a development dependency:

npm install --save-dev postcss-preset-env

The package uses the cssdb and data from the MDN (Mozilla Developer Network) Web Docs and caniuse.com to determine which transformations and fallbacks you need based on the target browsers you have specified in the very same .browserslistrc file that Babel and core-js use when transforming and polyfilling your JavaScript code. Plugins that aren’t needed will be skipped to reduce the size of the CSS bundle.

The preset also uses the autoprefixer plugin to add vendor prefixes to your CSS, also based on what’s actually needed according to the configured browser support list. Vendor prefixes, such as for example -webkit and -moz, are used to implement non-standardized CSS features that aren’t (or were not) fully implemented across different browsers. They become rarer and rarer these days as browser vendors have begun to replace them with feature flags, but you may still need prefixes depending on how old browsers you are targeting. Using autoprefixer, you don’t have to explicitly write any vendor prefixes in your CSS files yourself.

93. You configure PostCSS by creating a postcss.config.js file in the root folder of the project. In this file you specify the plugins that you want to process your CSS with, and some optional options:

module.exports = {
  plugins: [
	[
	  "postcss-preset-env", { 
		env: process.env.NODE_ENV
	  }
	],
  ],
};

The only option I’ve specified here is the env option. It’s passed to autoprefixer and used by browserslist to determine what browsers to target when you have multiple environments, such as production and development, configured in the .browserslistrc file. There is a stage option that determines which CSS features to polyfill, based upon their stability in the process of becoming implemented web standards. It can be 0 (experimental) through 4 (stable), or false. The default is 2.

94. To be able to use PostCSS with Webpack, you then need to install the postcss-loader package as another development dependency:

npm install --save-dev postcss-loader

95. The next step is to configure Webpack to use the loader for your CSS files by editing the webpack.config.js file in the root folder:

...
{
  test: /\.(css|sass|scss)$/,
  use: [
	isDevelopmentMode ? "style-loader" : MiniCssExtractPlugin.loader,
	"css-loader",
	"postcss-loader",    
	"sass-loader"
  ]
}
Stylelint

Another useful PostCSS plugin is Stylelint. It lints the CSS in order to help you to avoid errors and enforce conventions in your stylesheets, just like ESLint does with JavaScript.

96. Install the stylelint package as a development dependency:

npm install --save-dev stylelint

Stylelint uses configurable rules to determine what the linter looks for and complains about. None of these rules are enabled by default. You enable them by either adding them one by one to a required configuration object that the linter expects to find in a .stylelintrc/.stylelintrc.{cjs,js,json,yaml,yml}/stylelint.config.{cjs,mjs,js} file or in the packages.json file, or you can extend or base your configuration on an existing shareable configuration that contains a pre-configured set of rules.

97. Install the stylelint-config-standard-scss package as a development dependency:

npm install --save-dev stylelint-config-standard-scss

This config extends the stylelint-config-standard shared configuration and configures its rules for SCSS. stylelint-config-standard is the official standard shareable config for Stylelint. It in turns extends the stylelint-config-recommended config, which enables rules that help you avoid errors, and turns on additional rules to enforce modern conventions found in the CSS specifications.

98. Create a .stylelintrc.json file in the root folder of the project where you configure Stylelint to use the stylelint-config-standard-scss configuration as-is:

{
  "extends" : "stylelint-config-standard-scss"
}

In this file you can extend the shareable configuration by adding your own rules and/or override any existing rules that are defined in the NPM packages. Refer to the docs for details.

99. Add a “lintstyles” command to the packages.json file in the root folder that uses Stylelint to lint all .css, sass and .scss files inside the src and tests folders:

"scripts": {
  ...
  "lintstyles": "stylelint \"{src,tests}/**/*.{css,sass,scss}\""
}

You can then run the npm run lintstyles command to lint the CSS in your project. Any linting errors will be written to the console.

100. For integration with Webpack, install the stylelint-webpack-plugin package as another development dependency:

npm install --save-dev stylelint-webpack-plugin

101. Then configure Webpack to use the plugin by editing the webpack.config.js file in the root folder:

...
const StylelintPlugin = require("stylelint-webpack-plugin");
...
var config = {
  ...
  plugins: [
	...
	new StylelintPlugin()
  ]
};

With this configuration, your project should fail to build when Stylelint detects a flaw in any of your CSS files. You can add the --fix CLI option to the stylelint command in the packages.json file to automatically fix the reported rule violations whenever possible.

Typescript

TypeScript is a syntactic superset or extension to JavaScript that adds type safety and support for better tooling in source code editors. It’s compiled to plain JavaScript using a compiler such as Babel or the official tsc compiler that Microsoft provides. Which compiler to use depends on your requirements. Using Babel is generally the recommended approach if you require polyfills or when you want to integrate TypeScript into an existing JavaScript project or configurable build pipeline as is the case here.

102. Install the @babel/preset-typescript preset package as a development dependency:

npm install --save-dev @babel/preset-typescript

103. Configure Babel to use the preset by adding it in the babel.config.json configuration file in the root folder:

 {
   "presets": [
	 ...
	 [
	   "@babel/preset-typescript"
	 ]
   ]
 }

104. Change the file extensions for the src/index.jsx, src/components/App.jsx and tests/App.text.jsx files from .jsx to .tsx.

105. Change the file extension in the import statement for the App component in the (renamed) tests/App.text.jsx file:

import App from "../src/components/App.tsx";

106. Do the same thing in the (also renamed) src/index.tsx file:

import App from "./components/App.tsx";

107. Also change the file name for the entry point in the webpack.config.js configuration file to src/index.tsx, and instruct Webpack to use the babel-loader for .ts and .tsx files by adding these file extensions to the first Rule object in the same file:

 var config = {
	 entry: {
	   bundle: "./src/index.tsx"
	 },
	 ...
	 module: {
	   rules: [
		 {
		   test: /\.(js|jsx|ts|tsx)/,
		   exclude: /node_modules/,
		   loader: "babel-loader"
		 },
		 ...
	   ]
	 },
	 ...
 };

Since TypeScript understands and extends JavaScript, you can apply it to a JavaScript project incrementally. You will still be able to compile your “old” JavaScript files without making any changes to the them. If you for example run the “build” and “test” commands at this point, they should still succeed even if you haven’t added any TypeScript types or syntax to your code.

Linting

ESLint also supports TypeScript but it requires you to install some additional packages as the default JavaScript parser cannot natively handle TypeScript-specific syntax.

108. First, install the typescript package, which contains the tsc compiler, as a development dependency:

npm install --save-dev typescript

109. Then install the @typescript-eslint/parser and @typescript-eslint/eslint-plugin packages as additional development dependencies:

npm install --save-dev @typescript-eslint/parser @typescript-eslint/eslint-plugin

The @typescript-eslint/parser is used to parse the TypeScript source files and the @typescript-eslint/eslint-plugin package contains the recommended TypeScript-specific rules.

110. Configure ESLint to use the @typescript-eslint/parser for .ts and .tsx files by using the overrides key in the .eslintrc.json configuration file:

 {
   ...
   "overrides": [
	 {
	   "files": ["{src,tests}/**/*.{ts,tsx}"],
	   "plugins": [
		 "@typescript-eslint"
	   ],
	   "extends": [
		 "eslint:recommended",
		 "plugin:react/recommended",
		 "plugin:react/jsx-runtime",
		 "plugin:@typescript-eslint/recommended"
		],
		"parser": "@typescript-eslint/parser"
	 }
   ]
 }

111. Using the above configuration, you can lint both JavaScript and TypeScript code if you just modify the “lint” command in the packages.json file to also include TypeScript files:

 "scripts": {
   ...
   "lint": "eslint {src,tests}/**/*.{js,jsx,ts,tsx}"
 }

112. To lint .ts and .tsx files as part of your Webpack build command, also remember to specify these file extensions for the ESLintPlugin in the webpack.config.js configuration file in the root folder:

 var config = {
   ...
   plugins: [
	 ...
	 new ESLintPlugin({
	   extensions: ["js", "jsx", "ts", "tsx"]
	 })
	 ...
   ]
 };

A downside of using Babel to compile TypeScript instead of the tsc compiler is that you don’t get any type checking during the transition to JavaScript. For example, Babel won’t complain – neither will ESLint – about the following const declaration in a .ts or .tsx file despite the fact that the value 123 is not a string:

const notAString: string = 123;

A common solution to this issue is to use tsc compiler to perform the type-checking before Babel performs the transformation of the TypeScript code into plain JavaScript.

113. Create a tsconfig.json file in the root folder of the project where you specify what files to compile, as well as any number of compiler options that define how the tsc compiler should behave and what rules that should be enforced during the compilation process:

 {
   "compilerOptions": {
	 "allowImportingTsExtensions": true,
	 "jsx": "preserve",
	 "module": "ESNext",
	 "moduleResolution": "bundler",
	 "noEmit": true,
	 "noImplicitReturns": true,
	 "noUnusedLocals": true,
	 "noUnusedParameters": true,
	 "strict": true,
	 "target": "ESNext"
   },
   "include": [
	 "src/**/*",
	 "tests/**/*",
   ],
   "exclude": [
	 "node_modules",
	 "dist"
   ]
 }

Setting allowImportingTsExtensions to true is required for the compiler to allow you to import files with the .tsx extension in your code (more on that below). module and moduleResolution specify the compiler’s module resolution strategy and the target option setting controls which JavaScript features that should be downleveled to support older browsers that are not compatible with the language version your code has been implemented in. The default value is ES3 but since the noEmit option is used to tell the tsc compiler to not output any files, all downleveling is unnecessary here because Babel will handle the actual conversion of TypeScript to JavaScript. The jsx option is required when compiling JSX. It controls how the JSX constructs are emitted. Setting it to preserve will keep the JSX as part of the output to be further consumed by Babel. The rest of the options listed above are optional and related to what kind of type checking you want. There is a comprehensive official documentation that describes all available compiler options and values you can use.

TypeScript uses declaration files to provide type information about JavaScript libraries or modules that haven’t been ported to TypeScript. These files allow you to use external JavaScript code in your TypeScript code while still benefiting from static type checking and intellisense in editors. A declaration file usually has a d.ts file extension and contains type declarations for functions, classes, variables and other elements that are exported from the JavaScript library. If you for example import a file named “library.js” in your code, TypeScript will by default look for a declaration file named “library.d.ts” in the same folder. This is why importing .ts and .tsx files is disabled by default and why you need to use the allowImportingTsExtensions option to enable it. If you are developing a library to be consumed by other applications, you typically use the declaration option to tell the compiler to generate a .d.ts file for every TypeScript or JavaScript file in your project.

114. Like with most popular JavaScript libraries, declaration files that contain type declarations for the react, react-dom and jest libraries are provided by the community-driven DefinitelyTyped project. Install these as development dependencies (although they may already be present in the node_modules directory because they are used by the @testing-library/react and @testing-library/jest-dom packages respectively):

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

The @types scope is by convention used for NPM packages that contains declaration files for JavaScript libraries.

115. Add a “tsc” script to the package.json file which runs tsc to compile your TypeScript files, using the configured settings in the tsconfig.json file:

 "scripts": {
   ...
   "tsc": "tsc"
 }

If you then run the npm run tsc command, you will get some errors.

116. The src/index.tsx file cannot be compiled because the compiler claims that the property createRoot doesn’t exist on an imported type. This can easily be fixed by changing the second import statement to import the react-dom/client APIs for which there is a client.d.ts declaration file included in the @types/react-dom package:

import ReactDOM from "react-dom/client";

This leads to another compilation error in the same file which is because of a type mismatch. According to the lib.d.ts declaration file that ships with the typescript package along with declaration files for all of the standardized built-in APIs available in JavaScript, the return type of the document.getElementById function is HTMLElement but the createRoot method expects a Element | DocumentFragment.

117. You can fix this by using type assertion to explicitly specify a more specific type in the src/index.tsx file:

 const root = document.getElementById("root") as Element;
 ReactDOM.createRoot(root).render(
   <App />
 );

Type assertions are removed by the compiler and won’t affect the runtime behaviour of your code. You can safely use them whenever you have information about the type of a value that TypeScript doesn’t know about for whatever reason.

The src/components/App.tsx file cannot be compiled at this stage because TypeScript “cannot find module ‘./App.module.css’ or its corresponding type declarations”. You solve this by creating your own declaration file for CSS modules and every other kind of assets that you are, or will be, importing into your source code files. This will enable TypeScript to correctly handle these module declarations.

118. Create an assets.d.ts file in the root folder of the app where you define module declarations to instruct the TypeScript compiler to handle values imported from image files as constant strings and values imported from CSS modules as constant objects that have keys and values of type string:

 declare module "*.module.css" {
   const classes: { readonly [key: string]: string };
   export default classes;
 }

 declare module "*.module.sass" {
   const classes: { readonly [key: string]: string };
   export default classes;
 }

 declare module "*.module.scss" {
   const classes: { readonly [key: string]: string };
   export default classes;
 }

 declare module "*.gif" {
   const src: string;
   export default src;
 }

 declare module "*.jpg" {
   const src: string;
   export default src;
 }

 declare module "*.jpeg" {
   const src: string;
   export default src;
 }

 declare module "*.png" {
   const src: string;
   export default src;
 }

 declare module "*.svg?asset" {
   const src: string;
   export default src;
 }

 declare module "*.svg" {
   import React from "react";
   export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
   const src: string;
   export default src;
 }

The type for the .svg files that are configured to be handled by SVGR is declared as React.FC<React.SVGProps<SVGSVGElement>>. This is a type that represents a functional component for rendering SVG elements in React. .svg files that are imported as assets using the “asset” query are handled the same way as the images.

119. Add the assets.d.ts file to the array of file patterns in the tsconfig.json file to include when compiling the TypeScript:

 {
   "compilerOptions": {
	 ...
   },
   "include": [
	 ...
	 "assets.d.ts"
   ],
   "exclude": [
	 ...
   ]
 }

After you have done this, the npm run tsc command should succeed.

120. The final step is then to use the tsc compiler to perform the type checking as part of the “build” and “start” commands in the packages.json file:

 "scripts": {
   "start": "npm run tsc && webpack serve --node-env=development",
   "build": "npm run tsc && webpack build --node-env=production",
   ...
   "tsc": "tsc"
 }

Wrapping Up

As discovered in this post, developing, building and testing a React web app requires the usage of quite a lot of components that each serve a specific purpose and of which most are developed and maintained by a third-party. For example, the loaders used by Webpack are not maintained by the team behind the module bundler itself and core-js isn’t part of the Babel project or even backed by a company. Each tool is luckily open-sourced and can usually be configured in many different ways depending on your requirements. You should refer to the documentation on NPM or GitHub, and to the links provided throughout this text, for more information about how to customize the behaviour of a specific NPM package.

There are obviously many ways to define a “blank” React app template but the fully “ejected” setup explained here should provide a good starting point and may serve as an alternative to the create-react-app tool that Facebook provides. At least if you want to be in full control over the details of what’s going on behind the scenes when it comes to the tools being used to build and test your app, why they are used and exactly how they are configured.

I’ve uploaded the final “template” to GitHub. Besides the mentioned NPM packages and configuration files, it also includes a .gitignore file to ignore the contents of the node_modules and dist folders when you make a commit in Git. The easiest way to use the template is to clone the repository into a local directory (assuming your have installed and configured Git) on your computer:

git clone https://github.com/mgnsm/react-csr-app-template.git
cd react-csr-app-template

Then remove the .git folder using the rd /s /q .git command on Windows or rm -r .git on Unix, install the dependencies using the npm install command and start to add more components and install additional NPM packages as needed.

You may also of course want to strip out things that you don’t intend to use. If you are developing some proprietary, internal software software for an enterprise, you may have the luxury to only provide support for the latest browsers or even a single vendor specific browser. In this case, you won’t likely need to use any polyfills at all. You should at least modify the queries in the .browserslistrc file to narrow down the list of supported browsers to a bare minimum of what targets you actually need to support.

What’s Next?

If you have read this far and wonder where to go next, you may want to look into using a production-grade full-stack React framework like Next.js, Remix or Gatsby depending on what kind of app you are building and the requirements you have. These can for example be used to pre-generate HTML from your components, split up your code bundle(s) based on individual navigation routes or add support for suspense-enabled data fetching. Some of these features require server-side support.

Regardless of whether you choose to go into that route, you probably want to setup continuous integration and deployment for the app. Publishing it, even at an early stage, is useful for being able to share minimum viable products (MVPs) with customers and/or other stakeholders as well as for being able to use online services to test the behaviour and performance of the app across different browsers, operating systems and devices.

You probably also want to configure your preferred editor to make your React code clearer to read and faster to write, and install the React Developer Tools in your browser.

Thanks for reading. If you have any questions or feedback related to this blog post, please post a comment below.



Leave a comment