The The Tool Set and Build Process – Adding React to an Existing Web Application
This article is part of series on adding React to an existing server side rendered application.
To be honest, the hardest part of injecting React components into an existing web application is getting the tool set configured. I’ll cover the tools used as well as the configuration to produce ready-to-consume components. This article was written in March 2023, so tools, versions, and configuration may now be different.
This article assumes you are already familiar with a Node package manager like npm or yarn.
Project Orientation
Before diving into the tools and configuration, let’s get oriented with the current application structure and where my little island of React is going to live. Bold files and directories are ones being added.
|-- project-root/ |-- index.php |-- existing-php-source-directory/ |-- node_modules/ |-- react-app/ |-- __tests__/ |-- src/ |-- App/ | |-- app-sub-directories/ | |-- index.tsx |-- WebComponents/ | |-- components-sub-directories/ | |-- index.tsx |-- Admin/ |-- admin-sub-directories/ |-- index.tsx |-- .eslintrc.js |-- jest.config.js |-- package.json |-- tsconfig.json |-- webpack.config.js
Tools
Maybe I’m just an old curmudgeon, but the modern front-end development tool set feels overly complex. You need to pull in multiple tools (most of which have a half dozen similar options) just to be able to create what amounts to a JavaScript file. All my griping aside, I was able to trim the necessary tools down to the following list.
TypeScript
It’s 2023. There are dwindlingly few good reasons to be writing flaky, error prone vanilla JavaScript when we have TypeScript. This also eliminates the need for Babel or some other transcompiler to produce consumable JavaScript files. At the time of writing, we’re using TypeScript v4.9.5.
Webpack
Webpack works nicely with TypeScript to create efficient JavaScript bundles for the different use cases. We can have a bundle for components, bundles for each mini SPA, and so on. At the time of writing, we’re using Webpack v5.75.0.
React
I bundle and deploy React as part of the application, though it is completely possible to just use the React CDN. At the time of writing, we’re using React v18.2.0. This version includes a lot of useful features like functional components, hooks, and suspense.
Jest & react-test-renderer
There are a handful of JavaScript unit testing libraries out there. I’m sure they’re all equally wonderful. Jest plays nicely with React, TypeScript, and is integrated well into phpStorm, which I use for development.
react-test-renderer is provided by React as very simple way to render React components without requiring the DOM. I don’t do any type of snapshot testing, though (I believe asserting against an ever changing UI is difficult to write and fragile to maintain). Instead, I keep all component logic in custom hooks and write tests against the hooks.
At the time of writing, we’re using Jest v29.4.3 and react-test-renderer v18.2.0.
ESLint
I’ve come to rely heavily on ESLint during development. The right set of rules not only keeps the code formatted consistently, but helps prevent all kinds of bugs. It also alerts me to potential issues with accessibility, performance, and security. At the time of writing, we’re using ESLint v8.34.0 with a variety of plugins which I’ll cover in the configuration details.
Configuration
A common theme you’ll notice throughout this article series is that most of my configurations are pretty bare bones. I’m sure there are many features of these tools that I’m not benefiting from. There’s always the option to add more as needed 🙂
Webpack + TypeScript
Let’s get the hard(ish) part out of the way first – wiring TypeScript and Webpack together. We need a few package.json dev dependencies for this – webpack, webpack-cli, webpack-dev-server, html-webpack-plugin, mini-css-extract-plugin, ts-node, ts-loader, css-loader, ignore-loader, cross-env, rimraf
Both Webpack and TypeScript have command line utilities for creating their configuration files. Here are mine (I’ll cover the important bits below).
tsconfig.json
{
"compilerOptions": {
"target": "es6",
"module": "commonjs",
"jsx": "react",
"moduleResolution": "node",
"strict": true,
"noEmit": false,
"allowJs": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true
},
"include": ["react-app"],
"exclude": ["node_modules"],
}
This configuration is compatible with Webpack and will set us up to transpile React jsx/tsx files and produce ES6 JavaScript.
webpack.config.js
const {resolve} = require("path");
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const isProd = process.env.NODE_ENV === "production";
const templateContent = ({htmlWebpackPlugin}) => {
const jsFiles = htmlWebpackPlugin.files.js.map(fileName => {
return `<script src="${fileName}"></script>`;
}).join("\r\n");
const cssFiles = htmlWebpackPlugin.files.css.map(fileName => {
return `<link rel="stylesheet" type="text/css" href="${fileName}"/>`;
}).join("\r\n");
return `${jsFiles} ${cssFiles}`;
};
const config = {
mode: isProd ? "production" : "development",
entry: {
admin: "./react-app/src/Admin/index.tsx",
components: "./react-app/src/WebComponents/index.tsx",
app: "./react-app/src/App/index.tsx",
},
output: {
path: resolve(__dirname, "Web/scripts/dist"),
filename: "[name].js",
clean: true,
},
optimization: {
splitChunks: {
chunks: 'all',
},
runtimeChunk: 'single',
},
resolve: {
extensions: [".js", ".jsx", ".ts", ".tsx"],
},
module: {
rules: [
{test: /\.tsx?$/, use: "ts-loader", exclude: /node_modules/,},
{test: /\.css$/, use: [MiniCssExtractPlugin.loader, 'css-loader']},
{test: /\.js.flow$/, use: 'ignore-loader'},
],
},
plugins: [
new MiniCssExtractPlugin(),
new HtmlWebpackPlugin({
inject: false,
filename: `../../../tpl/bundle-admin.tpl`,
templateContent: templateContent,
chunks: ["admin"],
}),
new HtmlWebpackPlugin({
inject: false,
filename: `../../../tpl/bundle-components.tpl`,
templateContent: templateContent,
chunks: ["components"],
}),
new HtmlWebpackPlugin({
inject: false,
filename: `../../../tpl/bundle-app.tpl`,
templateContent: templateContent,
chunks: ["app"],
}),
],
};
module.exports = config;
Ok, yup. There’s a lot going on here. For now, most of this can be ignored. I’ll cover the important bits for getting Webpack and TypeScript to cooperate.
module
This is the most important part of getting Webpack to work with TypeScript files. Here we tell Webpack to use “ts-loader” to process any .ts or .tsx files. We’ll talk about those other rules later.
resolve
This is the simplest type of resolution configuration and just tells Webpack which files are important to our project.
output
This section tells Webpack where to put the bundled JavaScript files. This can be as simple as a directory or more complex, like this configuration, where we code split distinct sections of the React application. Point this wherever you want your bundles to ultimately end up.
JIT Transpiling/Hot Reloading/Development Build
One really nice feature of React SPAs and Webpack is hot reloading – instantly having your updates reflected in the browser. We won’t get that with a traditional server side rendered PHP application, but we can ensure our TypeScript code is being JIT transpiled and available the next time the page is loaded. Here’s what I have for my package.json scripts section.
package.json
"scripts": {
"build": "rimraf Web/scripts/dist && webpack --env NODE_ENV=production --mode=production",
"dev": "rimraf Web/scripts/dist && webpack --env NODE_ENV=development --mode=development",
"watch": "webpack --watch",
"test": "jest",
"reset": "rimraf node_modules && rimraf yarn.lock",
"lint": "eslint \"react-app/src/**\""
},
Simple, right? webpack –watch will ensure that the development version of the React components are built as specified in the webpack.config.js every time a file change is saved. We’ll cover the other scripts later, but they’re all pretty simple.
Creating an Optimized Production Build
The development build of our React components is perfect for testing and debugging, but for production we want the code minified and bundled optimally.
package.json
"scripts": {
"build": "rimraf Web/scripts/dist && webpack --env NODE_ENV=production --mode=production",
"dev": "rimraf Web/scripts/dist && webpack --env NODE_ENV=development --mode=development",
"watch": "webpack --watch",
"test": "jest",
"reset": "rimraf node_modules && rimraf yarn.lock",
"lint": "eslint \"react-app/src/**\""
},
The build script first clears out the target build directory (rimraf Web/scripts/dist), then we call webpack with the mode set to production (webpack –env NODE_ENV=production –mode=production). In webpack.config.js, we read the NODE_ENV environment variable and set the mode to “production”.
webpack.config.js (relevant lines only)
...
const isProd = process.env.NODE_ENV === "production";
...
mode: isProd ? "production" : "development",
...
Jest + react-test-renderer
I am an unapologetic test-driven developer. So an easy to run test suite is a requirement for any project I work on. Luckily, React is very testable and Jest makes organizing and running tests simple. We need a few package.json dev dependencies for this –jest, jest-environment-jsdom, react-test-renderer, ts-jest
The Jest configuration is very simple, mostly relying on ts-jest for the heavy lifting.
jest.config.js
/** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'jsdom',
testPathIgnorePatterns: ["/node_modules/", "__tests__/fakes/"],
moduleNameMapper: {
'^.+\\.(css|less)$': "<rootDir>/react-app/__tests__/fakes/CSSStub.js"
},
};
The key here is preset: ‘ts-jest’, which makes it possible to just run unit tests written in TypeScript without any additional setup.
The only weird thing in this config is the moduleNameMapper, which points to CSSStub.js. This is a simple way to make sure any css included in React components under test just get ignored. Here is the entirety of the stub.
CSSStub.js
module.exports = {};
Running Tests
With the configuration set, running tests is easy. I mainly just run them directly from the IDE during development, but I can also run jest from the command line of the project root to execute the full test suite. I have a simple helper script in my package.json that aliases this, as well.
"scripts": {
"build": "rimraf Web/scripts/dist && webpack --env NODE_ENV=production --mode=production",
"dev": "rimraf Web/scripts/dist && webpack --env NODE_ENV=development --mode=development",
"watch": "webpack --watch",
"test": "jest",
"reset": "rimraf node_modules && rimraf yarn.lock",
"lint": "eslint \"react-app/src/**\""
},
ESLint
Alright, last tool. This one isn’t necessary to build and package React, but I’ve come to rely on ESLint to help me write cleaner code and catch issues early. I won’t go through all of my rules and plugins, but here are the important parts. These just allow linting of TypeScript files – eslint, @typescript-eslint/eslint-plugin, @typescript-eslint/parser.
.eslintrc.js
var jsExtensions = ['.js', '.jsx'];
var tsExtensions = ['.ts', '.tsx'];
var allExtensions = jsExtensions.concat(tsExtensions);
module.exports = {
env: {
browser: true,
node: true,
jest: true,
"jest/globals": true
},
extends: [
"plugin:@typescript-eslint/recommended" // other plugins not listed for brevity
],
plugins: [
"@typescript-eslint" // other plugins not listed for brevity
],
settings: {
'import/extensions': allExtensions,
'import/parsers': {
'@typescript-eslint/parser': tsExtensions
},
'import/resolver': {
'node': {
'extensions': allExtensions
}
},
"react": {
"version": "detect",
},
},
globals: {
Atomics: "readonly",
SharedArrayBuffer: "readonly",
it: "readonly"
},
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 2018,
sourceType: "module",
project: "./tsconfig.json"
},
rules: {
// all rules here
}
}
Wrap Up
At this point we have everything we need to build React components in TypeScript for usage within a server side rendered (or static) web application. The rest of this series we’ll look at how React is added to different parts of an existing application.