Code of Frankenstein: Iteratively Migrating ASP.NET MVC Razor to React & TypeScript

Photo by freestocks on Unsplash

“So, you’ve Frankenstein’d it?!” my product manager questioned as I explained the status of our partially migrated UI application.

Maybe ruffled at first, I quickly embraced that they were indeed correct. What we had built was a somewhat elegant yet slightly unsettling hybrid app, shimmed together with just the right components to bring it to life. It soon became known as Project Frankenstein.

he year prior, I had joined my new dev team, and I have to confess I was not happy about inheriting an application built on top of ASP.NET MVC, Razor Pages, Bootstrap, and jQuery. As a front-end developer living in 2020, I felt like it was 1999 all over again, and I promised myself that when the time was right, I would return back to the future.

Well, that time eventually came, but when it did, I realized I had limited capacity and zero experience dropping a JS app into an existing .NET project.

With some introductory research and careful thought, I laid out the objectives and began my experiment:

  1. Introduce this new technology with minimal effort
  2. Scaffold it to run side-by-side with the legacy app
  3. Allow the team to iteratively migrate the old app to the new one
  4. Ultimately decouple the client-side code into a separate repository

The Monster Parts

The idea was to introduce a front-end stack based on latest technology, strong community support, and the ability to foster rapid application development.

The React, TypeScript, and Redux combo backed by Babel and webpack was an easy choice.

CSS’ing

Deciding how to style the UI was a bit more challenging. Should we use CSS in JS, CSS Modules, Styled Components, or a CSS extension like SCSS?

While all viable options, the winning solution was importing co-located SCSS files with selectors scoped by a component’s name. Additionally, BEM conventions were utilized for writing more maintainable CSS.

TheMonster.scss

.the-monster {
display: flex;
position: relative;

...

&__eyes {
background: #556839;
border-bottom-left-radius: 25px;
border-bottom-right-radius: 25px;
height: 45px;
left: 30px;
position: absolute;
top: 100px;
width: 170px;
}

&__eye {
background: #fff;
border-bottom-left-radius: 80px;
border-bottom-right-radius: 80px;
height: 35px;
position: absolute;
width: 55px;

&--left {
left: 20px;
}

&--right {
left: 95px;
}
}
...}

Inspired with permission from Design Frankenstein Code Art by Shreyasi Patil

TheMonster.tsx

import './TheMonster.scss';

const TheMonster = (): JSX.Element => (
<div className="the-monster">
<div className="the-monster__face">
<div className="the-monster__hair" />
<div className="the-monster__eyes">
<div className="the-monster__eye the-monster__eye--left">
<div className="the-monster__eye-ball" />
</div>
<div className="the-monster__eye the-monster__eye--right">
<div className="the-monster__eye-ball" />
</div>
</div>
<div className="the-monster__mouth" />
</div>
</div>
);

export default TheMonster;

Development Tools

To make life easier and not have to deal with cumbersome code reviews, Prettier, Stylelint, and ESLint were all wired-up and Husky + lint-staged made sure developers couldn’t sneak any funk into the repo.

Unit Testing

For testing, Jest and React Testing Library (RTL) simply did the trick.

Note: At the time of writing this article, Enzyme didn’t have support for React 17.

Actiones secundum fidei: If you’re still not on the unit testing bandwagon, the first step is admitting you have a problem. Check out Martin Fowler’s Testing Guide, read Chapter 9 of Clean Code by Uncle Bob, or just get yourself a copy of Test-Driven Development by Kent Beck.

Patching it Together

Frankenstein-Dotnet-React

To accompany the experiment below, I’ve created a demo app since the best way to learn is by doing. I recommend cloning this repo and switching between branches to see the before and after as you go along.

Start off by checking out the 1-initial branch and running npm install from the App directory.

Routing Requests

Our first step is to map a new controller route in Startup.cs. We chose the name “app” with a trailing wildcard so it would match any route defined in the React app. The important thing to identify here is making sure this route is defined before any others so .NET doesn’t override it.

Startup.cs

app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute(
"app",
"App/{*page}",
new { Controller = "App", Action = "Index" }
);
...
});

Next, create anAppController.cs file in your Controllersdirectory followed by the accompanying view: Views/App/Index.cshtml.

public class AppController : Controller
{
public IActionResult Index()
{
return View();
}
}

Compiling the Source

After setting up webpack to compile your app, you have to configure the build to output the assets to ASP.NET’s public www folder to properly serve them. This example assumes we are in an App directory in the root of the ASP.NET project.

webpack.common.js

module.exports = {
entry: ['react-hot-loader/patch', './src/index'],
output: {
path: path.resolve(__dirname, '../wwwroot/dist'),
filename: '[name].[contenthash].js',
},
...
}

If using the DevServer for local development, be sure to configure it to output to disk.

webpack.dev.js

devServer: {
...,
devMiddleware: {
writeToDisk: true,
},
},

Serving the Assets

Although our JS and CSS files are compiling, they aren’t accessible yet, so let’s serve them from the Razor View we created above. Note the use of asp-href-include and asp-src-include which allow us to wildcard our asset names for supporting caching and chunked bundles.

App/Index.cshtml

@section Styles {
<link asp-href-include="dist/*.*.css" rel="stylesheet" />
}

<div id="app"></div>

@section Scripts
{
<script asp-src-include="dist/*.*.js"></script>
}

Bringing it to Life

Jump to the 2-add-react branch and run another npm install.

Execute npm start and run the .NET application. In seconds, you should see an open browser running your React code all hosted by the legacy codebase.

Navigate to https://localhost:5001/app/the-monster. The inner page is rendered via a React component.

Adding More Parts

Migrating Full Pages

Now that we have .NET running React, it’s time to write some modern JS! Let’s start with migrating the Privacy page referenced in the demo app.

Switch to the 3-page-migrations branch.

Directory Structure

Redux’s Code Structure docs give a good example on how to layout React Redux projects.

We specifically recommend organizing your logic into “feature folders”, with all the Redux logic for a given feature in a single “slice/ducks” file”.

Let’s follow the same structure in this demo.

Create a features/Privacy directory and a Privacy.tsx component.

const Privacy = (): JSX.Element => (
<div className="privacy">
<h1>Privacy Policy</h1>
<p>Use this page to detail your site&apos;s privacy policy.</p>
</div>
);

export default Privacy;

Routing in React

The defacto standard for routing in React is quite simply react-router. Here is a pretty basic example utilizing our new Privacy feature:

import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';

import { Privacy } from './features/Privacy';

const App = (): JSX.Element => {
return (
<div className="app">
<Router basename="/app">
<Switch>
<Route path="/privacy">
<Privacy />
</Route>
</Switch>
</Router>
</div>
);
};

export default App;

Updating Titles

We not only need to control page titles and more in our React app, but how can we update such things in the wrapping server-side application?

Fortunately, react-helmet is a manager for all things in the document head. Unfortunately, at the time of this article, react-helmet had compatibility issues with React 17, so we’ll have to use react-helmet-async as our saving grace.

Make sure the root App.tsx component is bootstrapped with <HelmetProvider> from react-helmet-async, then update Privacy.tsx to set the page title using the <Helmet> component:

import { Helmet } from 'react-helmet-async';

const Privacy = (): JSX.Element => (
<div className="privacy">
<Helmet>
<title>Privacy Policy</title>
</Helmet>

<h1>Privacy Policy</h1>
<p>Use this page to detail your site&apos;s privacy policy.</p>
</div>
);

export default Privacy;

Partial Page Migrations

Multiple Apps and Entries

Let’s say your legacy ASP.NET site has a _Layout.cshthml file that holds the common structure of your application including the navigation and footer. Time is limited this sprint, and you merely want to migrate the footer to React.

To start, create a new Footercomponent but don’t import it into your main React app as it’s going to be living stand-alone on a Razor View.

components/Footer/Footer.tsx

import './Footer.scss';

const Footer = (): JSX.Element => (
<footer className="footer">
<div className="container">
&copy; 2021 - Frankenstein - <a href="/app/privacy">Privacy</a>
</div>
</footer>
);

export default Footer;

Notice we are not using <Link to="/app/privacy"> above. Since this is a stand-alone component, it’s not wrapped in react-router , and thus, Link will not work.

Now, create a file called footer.tsx in the root of the Appdirectory beside index.tsx. This file will wire up a completely new React app that solely loads the Footercomponent.

import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom';

import { Footer } from './components/Footer';

ReactDOM.render(
<StrictMode>
<Footer />
</StrictMode>,
document.getElementById('footer')
);

Afterward, we have to configure webpack to build our new Footer React app and output the assets.

webpack.common.js

module.exports = {
entry: {
main: ['react-hot-loader/patch', './src/index'],
footer: ['react-hot-loader/patch', './src/footer'],
},
...
}

As a last step, you’ll want to include the distributables on _Layout.cshtml and add the container element referenced within footer.tsx. Note that we hard code footerin the file names as we only want to load those specific assets.

<link asp-href-include="dist/footer.*.css" rel="stylesheet" />...<div id="footer"></div>...<script asp-src-include="dist/footer.*.js"></script>

Tip: If you were replacing a portion of another page, let’s say the Homepage, you would include the distributables directly on that View, e.g. Home/Index.cshtml.

Teaching it to Talk

As a final step, let’s integrate our application with a live API, specifically Google’s Book API, to fetch and display details about Mary Shelley’s book Frankenstein. Follow these instructions to get your Google API key.

Checkout 4-redux and execute npm install.

Making API Calls

For data retrieval and rendering, we are going to wire up Redux Toolkit and React Redux, making specific use of RTK Query.

RTK Query is a powerful data fetching and caching tool. It is designed to simplify common cases for loading data in a web application, eliminating the need to hand-write data fetching & caching logic yourself.

Follow the boilerplate in Typescript Quick Start and Configure the Store for help with setup.

Be sure to wrap the app with Provider from react-redux within index.tsx and any other respective entry-point files.

import { StrictMode } from 'react';
import * as ReactDOM from 'react-dom';
import { Provider } from 'react-redux';

import { store } from './app/store';
import App from './App';

ReactDOM.render(
<StrictMode>
<Provider store={store}>
<App />
</Provider>
</StrictMode>,
document.getElementById('app')
);

We will need to create a new API slice that connects to the Google Books API. This will act as our service for components to fetch data through. New up a servicesdirectory and drop books.ts inside.

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';import { Book, BookResponse } from './types';

const GOOGLE_API_KEY = process.env.GOOGLE_API_KEY;

export const booksApi = createApi({
reducerPath: 'booksApi',
baseQuery: fetchBaseQuery({
baseUrl: 'https://www.googleapis.com/books/v1/',
}),
endpoints: (builder) => ({
getBook: builder.query<Book, string>({
query: (id) => `volumes/${id}?key=${GOOGLE_API_KEY}`,
transformResponse: (response: BookResponse) => {
return {
title: response.volumeInfo.title,
description: response.volumeInfo.description,
image: response.volumeInfo.imageLinks.thumbnail,
link: response.volumeInfo.previewLink,
};
},
}),
}),
});

export const { useGetBookQuery } = booksApi;

Rather than dealing with deeply nested data, we are making use of transformResponse to flatten out the response and cleanly return it to any callers.

You may also notice we are pulling the API Key off process.env. Here, we use Dotenv to load environment variables from a .env file along with some slight modifications to the webpack file which you can see below.

webpack.common.js

// Register Dotenv toward the top of the file
require('dotenv').config();
...plugins: [
...
new webpack.EnvironmentPlugin(['GOOGLE_API_KEY']),
],

For ease, copy .env.example to .env and set your GOOGLE_API_KEY. This file will not get committed to source control as it’s ignored.

Power Developer Tip: As per The Twelve-Factor App methodology, store config in the environment.

Rendering the Data

Our next step is to render the data provided by the books service created above. You can simply import the useGetBookQuery method and call it in a component by passing the Id of the specific book we want to load.

Also see Using (RTK Query) Hooks in Components.

src/features/Book.tsx

import { useGetBookQuery } from '../../services';

import './Book.scss';

const FRANKENSTEIN_BOOK_ID = 'WbNDDwAAQBAJ';

const Book = (): JSX.Element => {
const { data, error, isLoading } = useGetBookQuery(FRANKENSTEIN_BOOK_ID);

return (
<div className="book">
{error ? (
<>Oh no, there was an error</>
) : isLoading ? (
<>Loading...</>
) : data ? (
<>
<h3>{data.title}</h3>
<img alt={data.title} className="book__img" src={data.image} />
<p
dangerouslySetInnerHTML={{
__html: data.description,
}}
></p>
<a href={data.link} rel="noreferrer" target="_blank">
Preview
</a>
</>
) : null}
</div>
);
};

export default Book;

Out of the box, RTK Query gives us the ability to handle data and status in multiple ways, whether we need to display error messages or a loading element. Checkout Frequently Used Query Hook Return Values for more options.

After adding a new route in App.tsx and a link in the navigation menu, you should see data loaded on the Book page.

Completing the Experiment

We now have a working hybrid app that satisfies the objectives: a minimal effort upgrade, modern and legacy code running side-by-side, and a path forward to eventually deprecate the old app.

What’s Next?

Our final objective should be breaking out this new React app into its own repo and using the .NET project purely as an API.

But Why?
It would allow front-end developers to run the app without knowledge of ASP.NET, make it easier to host the compiled static assets on a CDN, simplify the client-side test/build/deploy pipeline, ease with switching the API to different technology (e.g. Clojure), and the list goes on.

And How?
I’ll leave that up to you to figure out 😉.

Memento:
Modernizing legacy codebases can be a taxing effort if not done correctly, but we don’t always have to start from scratch. As I’ve modeled above, with smart thinking and a practical approach, there are definite ways to incrementally improve existing programs.

In fact, Paul Jones has an epic talk on this very subject…

Steps Toward Modernizing a Legacy Codebase

A 20 yr developer vet that's gone from Jr Dev to Staff/Mgmt. Loves front-end, polyglot development, and DevOps. Christ Follower / Father / American.