Most developers have experienced working with an application that has grown significantly over the years, getting very hard to maintain. We keep spending hours trying to understand the patterns, if any. There is never enough time or budget to modernize the app and get it all done “the better way.” This has happened to me often and, fortunately, time reveals the right path—one way or another.

The Problem

“We cannot solve our problems with the same thinking we used when we created them..”

– Albert Einstein

At Headspring, we have an internal project management application with multiple features built using MVC, while others use React. Over the years, we have faced multiple challenges because of the time required for developers to understand the different patterns. I’m sure you’ve run into this maintenance issue at least once in your developer’s life. If not, you’re in luck.

My challenge is to modernize the UI of this complex application. The goal is to standardize, define good practices, and simplify multiple React applications running in the same server.

At this time, I expect you’ll have some knowledge of React, Yarn, Node, Jest, and Webpack, as these are the tools we will be using in the journey.

The application includes about nine features, not all built equally. There are features for logging employee time and billable expenses, managing the staffing schedule, invoicing, managing billable and non-billable projects, and others. Some of the features are using the default MVC implementation, while others support a more luxurious UI experience, running entirely in React. The React features are hooked into the MVC routing workflow, which we internally call “hybrid applications” instead of “single-page applications.” A hybrid MVC application contains controllers that render empty containers with additional assets (JavaScript and CSS) required for the feature initialization.

diagram-of-hybrid-react

To solve our maintenance problem, we need to define a clear way to modernize gradually but consistently. To standardize the features, we need to join all separate applications into a shared directory, centralize the build tools and dependencies, and improve support to run locally, all together for debugging, which is not possible right now. We also need to support cache-busting to prevent caching assets (JavaScript and CSS files) in the browser with each release and improve the release package’s creation. We divided this whole process into three steps: Current State, Planning, and Implementation.

Current State

The first step is to understand the code base, existing patterns, dependencies, and tools used with their versions.

The application currently has three features that will need to be centralized and standardized; Time Entry, Expenses, and Staffing Schedule. These features are using React as the primary UI library, Yarn and Node to manage dependencies and build processes, and Jest for test coverage. The Time Entry and Expenses features are the only ones with some test coverage at this time.

The React features exist in sub-directories with a separate package.json, Webpack configuration, and build process, each of them doing their own thing. The distribution files are placed in a parent folder (wwwroot) for IIS to pick them up as static assets.

The application has out-of-date dependencies, which we will upgrade. We created the features using a scaffolding tool called Create React App, but we customized them from the original version. This customization means we cannot upgrade easily, and we traded increasing customization for losing future support.

Planning

After understanding the current state of the application, we are ready to create a plan to solve the problems we have identified.

First, we need to standardize the current application and patterns by creating a new directory with all features in sub-directories.

Next, we upgrade Node, NPM, and the Create React App Tool. The NPM packages used are moved with their exact version (if possible) to ensure we do not create compatibility issues. To prove patterns are easy to follow, we migrate an existing MVC feature to React. Indeed, we develop new components and hook the new functionality into the organization pattern we have established.

The last step is to update the .NET project to support the new features and changes we have established.

Implementation

To centralize all the features and create a directory to group them all, we decided to use ClientApps as the name. Then, we add a sub-directory for each functionality using the same name of the feature: TimeEntry, StaffingSchedule, and Clients. Finally, we move the existing code into the new sub-directories.

clientapps folder structure

To continue providing support in the future, we upgrade Node and NPM; we use NVM for Windows to assist with handling different versions of NPM locally (there is an original version for MAC and Linux).

Next, we use the latest version of the Create React App tool, without extracting the source files (and without running the “eject” command). This tool is an easy-to-use solution to get up and running quickly with all the essential features we need to keep our tooling up-to-date and guarantee future support and maintenance.

We previously identified all existing NPM dependencies required for each feature, so now we install them using Yarn.

To support customizing the Create React Tool without extracting the source files, we use an NPM package called React App Rewired, and a new file with the name config-overrides.js.

const path = require('path');
const fs = require('fs');
const paths = require('react-scripts/config/paths');
const rewireReactHotLoader = require('react-app-rewire-hot-loader');
const ManifestPlugin = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const appDirectory = fs.realpathSync(process.cwd());

module.exports = {
  webpack: overrideWebpack,
  paths: overridePaths
};

function overrideWebpack(config, env) {

  // split entry points per feature
  config.entry = {
    main: paths.appIndexJs,
    timeEntry: paths.timeEntryJs,
    staffingSchedule: paths.staffingScheduleJs,
    clients: paths.clientsJs
  };

  if(env === 'development') {
    overrideDevelopment(config, env);
  } else {
    overrideProduction(config, env);
  }

  // remove HtmlWebpackPlugin
  config.plugins = replacePlugin(config.plugins, (name) => /HtmlWebpackPlugin/i.test(name), () => {});

  // remove service worker generation
  config.plugins = replacePlugin(config.plugins, (name) => /GenerateSW/i.test(name), () => {});

  // replace ManifestPlugin
  config.plugins = replacePlugin(config.plugins, (name) => /ManifestPlugin/i.test(name), new ManifestPlugin({ fileName: 'asset-manifest.json' }));

  // Enable hot loading
  config = rewireReactHotLoader(config, env);

  return config;
}

// Utility function to replace plugins in the webpack config files used by react-scripts
function replacePlugin(plugins, nameMatcher, newPlugin) {
  const pluginIndex = plugins.findIndex((plugin) => {
    return plugin.constructor && plugin.constructor.name && nameMatcher(plugin.constructor.name);
  });

  if (pluginIndex === -1)
    return plugins;

  const nextPlugins = plugins.slice(0, pluginIndex).concat(newPlugin).concat(plugins.slice(pluginIndex + 1));

  return nextPlugins;
}

function overrideProduction(config, env) {
  // split chunks to support splitting by feature and vendors the bundles
  config.optimization = {
    splitChunks: {
      cacheGroups: {
        vendor: {
          chunks: 'all',
          name: 'vendors.main',
          test: /[\/]node_modules[\/]/,
          filename: 'js/vendors.main.js?v=[chunkhash:10]',
          enforce: true
        }
      }
    }
  };

  // configure one bundle file per entry
  config.output.path = paths.appBuild;
  config.output.filename = 'js/[name].[hash:8].js';

  // override output for CSS
  config.plugins = replacePlugin(config.plugins, (name) => /MiniCssExtractPlugin/i.test(name), new MiniCssExtractPlugin({
    filename: 'css/[name].[contenthash:8].css',
    chunkFilename: 'css/[name].[contenthash:8].chunk.css',
  }));
}

function overrideDevelopment(config, env) {
  // disable chunks and support split by feature
  config.optimization.runtimeChunk = false;
  config.optimization.splitChunks = {
    cacheGroups: {
      default: false
    }
  };

  // configure one bundle file per entry
  config.output.filename = 'js/[name].bundle.js';
}

function overridePaths(paths, env) {
  paths.timeEntryJs = resolveApp('src/timeEntry/index');
  paths.staffingScheduleJs = resolveApp('src/staffingSchedule/index');
  paths.clientsJs = resolveApp('src/clients/index');
  paths.appBuild = resolveApp('../../wwwroot/clientApps');
  return paths;
}

function resolveApp(relativePath) {
  return path.resolve(appDirectory, relativePath);
}

This package will help override Create React App’s desired configuration without extracting the source files and losing upgrade support. This file is one of the critical elements required for the modernization process to work. Next, we will cover the content of this file step-by-step. 

Specify what to override

We use module exports to override Webpack configuration and the paths used by Webpack—remember Webpack as our build tool.

module.exports = {
    webpack: overrideWebpack,
    paths: overridePaths
};

Add override paths

Overriding paths help facilitate Webpack with the exact location of each feature.

function overridePaths(paths, env) {
    paths.timeEntryJs = resolveApp('src/timeEntry/index');
    paths.staffingScheduleJs = resolveApp('src/staffingSchedule/index');
    paths.clientsJs = resolveApp('src/clients/index');
    paths.appBuild = resolveApp('../../wwwroot/clientApps');
    return paths;
}

Split the bundle

Splitting the bundle for each feature helps us separate all assets so they can be accessed as needed, without loading everything.

config.entry = {
    main: paths.appIndexJs,
    timeEntry: paths.timeEntryJs,
    staffingSchedule: paths.staffingScheduleJs,
    clients: paths.clientsJs
};

Determine production or development

We use the default environment variable to determine if we need to build the production or local development environment.

if (env === 'development') {
    overrideDevelopment(config, env);
} else {
    overrideProduction(config, env);
}

Building for production

Our production build separates assets from vendors (typically, dependencies coming from node modules) to create a separate CSS and JavaScript bundle that can be load centralized. Then, we create a CSS and a JavaScript file for the custom implementation. Each file contains a hash that will be used for cache busting, to determine if the file has changed from the last build to allow the browser to retrieve a fresh copy.

function overrideProduction(config, env) {
    // split chunks to support splitting by feature and vendors the bundles
    config.optimization = {
        splitChunks: {
            cacheGroups: {
                vendor: {
                    chunks: 'all',
                    name: 'vendors.main',
                    test: /[\/]node_modules[\/]/,
                    filename: 'js/vendors.main.js?v=[chunkhash:10]',
                    enforce: true
                }
            }
        }
    };

    // configure one bundle file per entry
    config.output.path = paths.appBuild;
    config.output.filename = 'js/[name].[hash:8].js';

    // override output for CSS
    config.plugins = replacePlugin(config.plugins, (name) => /MiniCssExtractPlugin/i.test(name), new MiniCssExtractPlugin({
        filename: 'css/[name].[contenthash:8].css',
        chunkFilename: 'css/[name].[contenthash:8].chunk.css',
    }));
}

Building for development

For local development, we need a simple, easy-to-fetch asset that we can use for development. In this case, we just create a simple JavaScript file that contains everything on it, including vendor and custom code (both CSS and JavaScript). Notice that we are not creating separate files per feature for the local development, but we can if we need to in the future.

function overrideDevelopment(config, env) {
    // disable chunks and support split by feature
    config.optimization.runtimeChunk = false;
    config.optimization.splitChunks = {
        cacheGroups: {
            default: false
        }
    };

    // configure one bundle file per entry
    config.output.filename = 'js/[name].bundle.js';
}

Cleanup, manifest, and hot load

At the end of the configuration file, we find a couple of commands to perform additional cleaning and standardization. We are removing the support of generating an HTML file and a service worker because we are not using them. We standardized the default manifest file name to something we can control so that we don’t get surprises in any future upgrades. Finally, we enabled hot loading to work on top of the React Rewire tool, so we can still support the auto-rendering feature of React components when they change during development.

// remove HtmlWebpackPlugin not needed
config.plugins = replacePlugin(config.plugins, (name) => /HtmlWebpackPlugin/i.test(name), () => { });

// remove service worker generation not needed
config.plugins = replacePlugin(config.plugins, (name) => /GenerateSW/i.test(name), () => { });

// replace ManifestPlugin filename
config.plugins = replacePlugin(config.plugins, (name) => /ManifestPlugin/i.test(name), new ManifestPlugin({ fileName: 'asset-manifest.json' }));

// Enable hot loading for local debugging
config = rewireReactHotLoader(config, env);

Enable server-side support

We will do an additional server-side change to support the new release files and structure we have created. 

New Manifest Provider

We add a new C# class that will help us obtain the JSON content of the UI Manifest we created during the UI build process. The Manifest Provider internally generates a stream reader, reads the JSON file from the static content in the same server, and deserializes the JSON object as a qualified class. We map each JSON property to the desired class property, and the value includes the URL for each asset with a hash for cache-busting.

{
    "vendors.main.css": "/css/vendors.main.ba032798.chunk.css",
    "vendors.main.js": "/js/vendors.main.js?v=140b47d6b1",
    "vendors.main.css.map": "/css/vendors.main.ba032798.chunk.css.map",
    "vendors.main.js.map": "/js/vendors.main.js.map?v=140b47d6b1",
    "clients.js": "/js/clients.8fa228b4.js",
    "clients.js.map": "/js/clients.8fa228b4.js.map",
    "main.js": "/js/main.8fa228b4.js",
    "main.js.map": "/js/main.8fa228b4.js.map",
    "staffingSchedule.css": "/css/staffingSchedule.2b9e9d32.css",
    "staffingSchedule.js": "/js/staffingSchedule.8fa228b4.js",
    "staffingSchedule.css.map": "/css/staffingSchedule.2b9e9d32.css.map",
    "staffingSchedule.js.map": "/js/staffingSchedule.8fa228b4.js.map",
    "timeEntry.css": "/css/timeEntry.b83ad131.css",
    "timeEntry.js": "/js/timeEntry.8fa228b4.js",
    "timeEntry.css.map": "/css/timeEntry.b83ad131.css.map",
    "timeEntry.js.map": "/js/timeEntry.8fa228b4.js.map"
  }

Update MVC Views

Updating the MVC features is the next big step to hook each React feature properly. We load either the development build asset or the production assets, depending on a .NET environment variable. The manifest file provides the location for each asset we generated.

The following is an example of the one-time update we need to perform for each feature:

@{
    ViewBag.Title = "Time & Materials Entry";
    var manifest = new ProjectManagerManifestProvider().RetrieveManifest<TimeEntryManifest>();
}

<div class="page-container">
    <div id="root"></div>
</div>

@section script {
    <environment include="Development">
        <script src="http://localhost:3000/js/timeEntry.bundle.js" type="text/javascript"></script>
    </environment>
    <environment exclude="Development">
        <script src="@(manifest.VendorsMainJs)" type="text/javascript"></script>
        <script src="@(manifest.TimeEntryJs)" type="text/javascript"></script>
    </environment>
}

@section style {
    <environment exclude="Development">
        <link rel="stylesheet" href="@(manifest.VendorsMainCss)" />
        <link rel="stylesheet" href="@(manifest.TimeEntryCss)" />
    </environment>
}

In the example above, we created an instance of the new Manifest Provider to obtain the JSON content as a C# qualified class, then added the Script and Style HTML tags required to initialize the feature. The JavaScript will automatically render the high-level React component into the designated HTML element once it loads. Notice that we are only loading what is needed for the feature to work correctly.

Bonus Tip!

As bonus content, I would like to briefly explain how the UI build is hooked to the regular .NET build, for automation. If you are very familiar with the Create React Tool, there is an NPM command in the package.json to build for production (or CI). We use a Target element in the *.csproj file to call this command before the C# build.

<Target Name="TimeEntryApp" BeforeTargets="BeforeBuild" Condition=" '$(Configuration)' == 'Release' ">
    <Exec WorkingDirectory="$(ProjectDir)ClientAppspm" Command="npx yarn ci" ContinueOnError="false" />
</Target>

We do not use the same approach for local development. Instead, we use the start command of the Create React Tool to initiate all the React features with a separate development server.

Conclusion

In a world where UI technologies change quickly, there will always be challenges we need to confront. But the most important thing to remember is that we need to be able to understand the problem, create a plan, and implement the solution—in that order. There will be cases in which the program will not necessarily take you to the desired resolution, but failure is just another step to success. That failure might just be the solution to a similar problem that you now feel comfortable solving.

After many years of understanding problems, planning, and implementing solutions, I have concluded that decisions like the ones involved in this exercise are time sensitive, or else the problem can grow exponentially. Still, you cannot solve the problem if you cannot first confirm it is a problem. Having several features with different patterns helped us understand the complexity we were facing during maintenance. Therefore, we determined it was a good time to prevent an exponentially growing problem from advancing.

Solutions like this do not guarantee that you will not need to re-execute the same process again. But one thing is for sure: The application will be in a better position, making it more convenient to scale into the next better solution. Now, I want to challenge you to take this exercise to the next level by applying it to your organization and formulating a strategy for transforming your old, inconsistent application into a modern one that can be better maintained and scaled.

Let's Talk

Have a tech-oriented question? We'd love to talk.