React in a legacy ASP.NET MVC project

I was working on an existing “legacy” ASP.NET MVC 5 project recently. The end-users requested some (fancy) upgrades to the UI. The client UI was just HTML and jQuery, which to be honest worked out great up to this point. The upgrade though, introduces a lot of new dynamic UI elements and views that would be difficult to manage with just jQuery, therefore React feels like a perfect fit for the task. I decided against a re-write but instead, introduce React gradually into the project, and mainly use it for the new UI and only selected areas (components/features) of existing pages. I would like to have multiple “bundles” and entry points for each feature and its code. This is a write-up of the process.

The first thing that I tried was to use ReactJS.NET. The idea is that you write your React components and bundle them into the ASP.NET pipeline. So in your BundleConfig.cs you would do:

// In BundleConfig.cs
bundles.Add(new JsxBundle("~/bundles/my-react-bundle").Include(
    "~/Scripts/App/Component1.jsx",
    "~/Scripts/App/Component2.jsx",
    // You get the idea ..
    
    // .. mix in your js into the bundle
    "~/Scripts/utilities.js",
));

ReactJS.NET also supports server-side rendering and dynamic (on-the-fly) JSX to JS compilation.

I used ReactJS.NET for a while, and although it’s a cool project, I found it slow, hard to debug, and tricky to deploy. The documentation seemed a bit lacking and the whole project seemed a bit … stale.

I decided to go with a standard npm, webpack route instead and build my components and dependencies outside of the ASP.NET pipeline.

First thing I did was, in the root of my ASP.MVC project folder, run:

npm init

Next thing I would need is webpack and webpack-cli (so that you can use webpack in the command line), so I installed them as dev dependencies:

npm install --save-dev webpack webpack-cli

Then I installed react and react-dom:

npm install --save react react-dom

Next I installed babel (and babel related dependencies):

I ran the following:

npm install --save-dev @babel/core babel-loader @babel/preset-env @babel/preset-react

After which I created a babel config (a file called .babelrc that specifies the presets):

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

Then I created a minimal webpack config (webpack.config.js):

module.exports = {
    resolve: {
        // gain the ability to import from different folders and omit the ".jsx"
        extensions: [".js", ".jsx"]
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: {
                    loader: "babel-loader"
                }
            }
        ]
    }
};

This config is pretty straight forward - it simply says that anything with an extension .js or .jsx should go through babel-loader to be compiled down to ES5 (from ES6).

At this point I was ready to build my application. I decided to keep all my client code into a folder called … well “Client” (again on the root of my ASP.NET MVC project), as well as an output folder called “Dist” (for the end-result of the webpack build) in the already-existing Scripts folder. Then I went ahead and updated the webpack.config.js to reflect those changes:

const path = require("path");

module.exports = {
    entry: {
        app: path.resolve(__dirname, "Client/Boot/app.jsx")
    },
    output: {
        filename: "[name].bundle.js",
        path: path.resolve(__dirname, "Scripts/Dist")
    },
    resolve: {
        extensions: [".js", ".jsx"]
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: {
                    loader: "babel-loader"
                }
            }
        ]
    }
};

The output file will correspond to the entry name, so in my case the output file will be /Scripts/Dist/app.bundle.js.

The /Client/Boot/app.jsx looked as follows:

import React from "react";
import ReactDOM from "react-dom";

const TestComponent = () => <h1>Our test component</h1>;

ReactDOM.render(
    <TestComponent />,
    document.getElementById("#component-container")
);

At this point I had React wired up and I needed to include the output bundle into my MVC Razor View. In my Index.cshtml view (of Home/Index) I added the following:

<div id="component-container"></div>

@section Scripts {
    @Scripts.Render("~/Scripts/Dist/app.bundle.js");
}

Now I ran npm run build, and ran my project with Visual Studio (F5). Everything worked at this point. Great!

Next, I wanted to run a dev build and I didn’t want to run the start command after every file change so I added the --watch flag in the start command, I modified my package.json:

"scripts": {
    "start": "webpack --mode development --display-error-details --watch",
    "build": "webpack --mode production"
},

When added, the --watch flag re-compiles your app every time you change a file in the webpack build tree automatically. So my workflow at this point was - run the project in Visual Studio (F5), in the terminal run npm start and open the dev URL to see changes. Modify JS files -> refresh the browser.

One final thing, webpack allows you to have multiple entries and multiple corresponding outputs, which was perfect for the requirements I had, and the way I wanted to use it (a bundle per component/feature). So in my MVC view I could for example have:

<div id="notifications"></div>
<div id="comments"></div>
<div id="fancy-grid"></div>

@section Scripts {
    @Scripts.Render("~/Scripts/Dist/notifications.bundle.js");
    @Scripts.Render("~/Scripts/Dist/comments.bundle.js");
    @Scripts.Render("~/Scripts/Dist/fancy-grid.bundle.js");
}

And my webpack.config.js could look like this:

const path = require("path");

module.exports = {
    entry: {
        "notifications": path.resolve(__dirname, "Client/Boot/Notifications.jsx"),
        "comments": path.resolve(__dirname, "Client/Boot/Comments.jsx"),
        "fancy-grid": path.resolve(__dirname, "Client/Boot/FancyGrid.jsx"),
    },
    output: {
        filename: "[name].bundle.js",
        path: path.resolve(__dirname, "Scripts/Dist")
    },
    resolve: {
        extensions: [".js", ".jsx"]
    },
    module: {
        rules: [
            {
                test: /\.(js|jsx)$/,
                exclude: /node_modules/,
                use: {
                    loader: "babel-loader"
                }
            }
        ]
    }
};

My Client folder I’ve structured as follows:

One final note - I had to of course include the /Scripts/Dist folder to the .gitignore (I also double-checked that node_modules was included in the .gitignore, you can never be too careful).

Overall, I’m happy with how things turned out. I’m looking for tools that will automate the build process and integrate it into Visual Studio, although I don’t particularly mind typing the necessary build commands.