Creating a React App From Scratch
Posted: November 20, 2023 Filed under: React | Tags: Babel, CSS, ESLint, JavaScript, Jest, PostCSS, React, Stylelint, TypeScript, Webpack Leave a commentA 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.