Converting a back-end-rendered colossus to React

Kamil Przekwas
Engineering at Alfa
12 min readJan 7, 2020

This is an extended write-up of a talk we presented at the React London Meetup on 26th November 2019.

Over the last 20 months, we have worked on converting our existing mature application into a React-driven single-page application (SPA).

Over 30 years, Alfa Systems has evolved from a green-screen system, to a Windows application, and finally to a web application. The original web application was purely server-side, rendered with a Java back-end and a proprietary UI framework developed in-house. With only a small amount of vanilla JavaScript and jQuery on top, this provided a simple solution for our engineers to create consistent screens quickly.

Progression of Alfa Systems UI

Nowadays, modern web applications are expected to be highly responsive and interactive. We recognised that we needed a better solution to vastly improve the user experience and allow us to implement more interactive and responsive features. We decided to uplift our existing application, settling on React as our technology of choice.

One constraint we imposed on ourselves was to minimise the back-end effort needed for this conversion. We wanted our back end to remain largely decoupled from the efforts we were undertaking on the front end. This way we would minimise the disruption to our product, allowing us to introduce a React-centric approach, gradually.

Limiting Factors

With a wide array of supported features and inherent configurability, our existing application presented us with some interesting challenges.

We had hundreds of existing screens, most of them represented by their own distinct view definitions in our back-end server-side code.

As we had a fully server-side-rendered application, the server was in charge of the entire navigation within the product. This logic was stateful, which meant the server would maintain the navigational state for each authenticated user at all times. The application also allowed our users to open and close tabs, each of which would have their own state, akin to browser tabs. Similarly to browser tabs, it allowed users to open multiple instances of the same tab.

Possible Approaches

We explored various different approaches. The two most notable options involved either creating a separate single-page application with all the new functionality in React, or building a ‘micro-front end’ around our existing features, whilst treating the new features as a separate micro-front-end entity in its own right.

A separate application would offer a fragmented and flawed user experience. To complete their desired workflows, users would have to switch constantly between the old and new applications. Whilst this would allow us to uplift some of the existing functionality incrementally into the new React front end-centric approach, it wouldn’t be without serious limitations. As many of the existing and planned new features are cross-cutting in nature, meaning that features often interact with a shared sidebar and other shared parts of the application chrome, it would not be a feasible solution.

On the other hand, we were very keen to try the micro-front end approach. Micro-front ends are all about splitting big and complex pieces of architecture into smaller, more manageable pieces, whilst being explicit about dependencies between them. This way the existing screens could render in their own separate containers and interact together either via the back-end (which could be facilitated through micro-services) or through a thin layer of JavaScript.

Micro-front ends increase operational flexibility and scalability, but the cost of this is an increase in complexity. With our back end in control of navigation in the existing application, we would have trouble introducing cross-cutting changes which affect the existing application as well as the newly built features. In addition to this, micro-front ends would increase the cost of managing these two solutions as the technology stack slowly diverges between them. Managing styling, state and the API layer becomes very costly. We thought the micro-front end solution wouldn’t allow us to provide the best possible user experience of a seamless uniform product to our users. Architecturally, micro-front ends would be a very sound solution which we might revisit in the future. Prioritising user experience over architectural purity was, in the end, the primary reason we decided to follow a different approach.

Multipart Form Data Solution

Our proposed solution involved introducing an “interop” layer on top of the application. The first step was to convert the application into an SPA by introducing a single React root. We could then convert the existing chrome of the application to React; this included the header, sidebar, footer and some FABs for feature-level action buttons. Only the inner tab content on existing screens remained server-side-rendered.

This approach also left it open for us to use React components for some of the simpler functionality within the server-rendered content, such as date pickers and tooltips. The server-rendered content could include hook points which we could then intercept on the React side and mount React components in specified places. That way, we could minimise the amount of server-side content. In the longer term, we are also planning to introduce the ability to have server-rendered content alongside React content in a single tab, which would bring us closer to the micro-front end approach.

This approach allows us to add value immediately by introducing pure React content into the application. The user can then switch seamlessly between pure React tabs and hybrid tabs, and the app navigation is controlled on the client side. We consider this to be a transitional stage for us that will allow us to build new features whilst maintaining backwards compatibility with the existing features.

Since the server is still aware of navigation within the existing tabs, we needed a solution that allows us to add new tabs and keep track of the navigational state on the client side, whilst the server was still aware of the existing tabs currently open in the application. This is where our multipart/form-data solution comes into play.

Traditionally, multipart/form-data media type is an HTTP encoding format used for posting user-filled data to an application, in particular when it includes large chunks of data such as files. It allows you to specify different MIME encoding types and package them in a single HTTP request. We immediately saw the benefit this could give us for managing our hybrid solution for rendering server-side content within a client-side application. We couldn’t simply return the HTML from the server in response to a client’s HTTP request; the client would not have enough information as to what the response actually means, and how it fits into the application’s chrome. Using multipart/form-data instead of plain HTML for server-side responses allows us to add important metadata that will describe the server-side navigational state at the time of making the request.

Alongside the inner page HTML, we can include metadata that describes the list of the currently opened tabs, sidebar contents, screen-level actions and so on. And we can include more pieces of data that the client can interpret in a desired way. For example, we can use this format to display a dialog on top of a server-side-rendered page. This is a very robust way of getting all the data we need to merge the server-side navigational state with the client-side state and display the server-rendered content in a single request. Having this data in a single request also has the added benefit of minimising the server-side effort we need in order to make this transitional approach work.

Front-End Architecture

Given the multipart solution, we needed a sound front-end architecture that would allow us to control navigation within the page. We achieved this with a combination of React Context and routing.

Simplified Front-End Architecture

Our top-level ServerContext holds the state of the server-rendered features. It also includes callbacks facilitating clicks, form submissions and other interactions in the existing UI. In its provider, this context reads the multipart responses as well as any additional environmental data (if, for example, you want to use a RESTful resource to retrieve initial server-side navigational state when reloading the page for the first time). We also have a separate IntegrationContext which holds the list of currently open tabs and the active tab as well as facilitating navigation between existing tabs and new React tabs. If a new back-end-driven tab is open in the application, the IntegrationContext provider would read from the SonicContext to collate the navigational state of the application into one.

In order to render back-end-driven HTML content from the multipart response, we need a simple component which extracts the HTML blob for a given key and gives us some predefined form handlers for clicks, submission and other form actions.

This makes it very easy to display server-driven HTML content anywhere in the page in the following manner:

<ServerContent multipartKey=”popup” ref={formRef} />

For new front-end-driven, fully React content we can have a simple API which registers React tabs as separate route components. Using most major React routing solutions, we can register a separate navigational path for each of the React tabs. We can load the underlying tab components lazily to optimise the bundle size of our application:

The back-end-driven multipart rendering mechanism is not affecting this API — they are intentionally distinct and decoupled. This way, after the initial page load when the ServerContext is already initialised, the client no longer needs to make any direct calls to the server to retrieve HTML as long as the user stays on a React tab. What is particularly advantageous about this mechanism is that we can slowly convert existing screens to be fully front-end React-based, and this mechanism will remain unaffected by the transition.

With the help of React Router or Reach Router we can have a clear mechanism for registering legacy server-rendered content as well as new React tabs based on the current application routing state. The IntegrationContext provider is deliberately placed below the ServerContext provider as it reads from it to establish the combined navigational state within the application. The new React components used within the chrome and application tabs can read directly from IntegrationContext.

We intentionally omitted internal implementation details of ServerContext and IntegrationContext providers, as this will largely depend on what is contained within the page’s chrome and how the server side handles actions on existing tabs.

Coupling Server-Side Data Model To The Front End Can Be Good

One concern we had when moving to a front-end-rendered application and creating new, fully React-rendered screens was losing type safety inherent in strongly-typed languages such as Java. Whenever we used to make a change in the back-end data model, we immediately knew which areas of the application were affected; particularly useful for performing impact analysis across hundreds of screens before making a change. But with purely front-end-driven content, this is no longer the case.

GraphQL and TypeScript together allow us to address this challenge. GraphQL allows us to expose our entire server-side data model and query for the information we need. It also makes the back-end schema easily available to us on the front end. The back-end data model only needs to be exposed once and we can query for the information we need with complete type safety from TypeScript.

There is an abundance of libraries, such as Apollo tooling and GraphQL code generator, which we can use to generate TypeScript definitions automatically for our back-end schema and GraphQL queries. We can set up an automated script that does it for us at any stage in our development lifecycle.

This way, if anything in our schema changes, the TypeScript compiler will let us know immediately if our code needs to be updated.

Consistency

As demonstrated, there is a lot we can do to convert our well established application to React, but mixing existing screens with React-rendered screens has a price: lack of consistency between screens. When users navigate between server-rendered content and new purely front-end-driven content, they might find the differences jarring. There are differences in both appearance and behaviour of old features and new React-rendered components.

One approach we considered involved introducing a back-end middleware layer which would replace our existing middleware to output a form of JSON domain-specific language (DSL) instead of plain HTML. We could then use this output on the front end and render real React components for the back-end-rendered features. The advantage of this approach would be that it would use real React components from our React design system component library. This way, whenever we made a change in the design system, the back-end-rendered screens would also get updated.

Whilst this approach gives us a single source of truth in our component library, it would require a major effort both on the front end and back end. We would prefer to avoid such significant back-end changes, especially as we are unsure of the performance impact. Our existing back-end-rendered features were also tested with our legacy Selenium-based end-to-end testing framework, which wouldn’t be well suited to the rewritten screens. We did not want to rewrite the existing tests as making a major change to the testing framework whilst making a huge architectural jump was not ideal. This solution would still be a stopgap en route to moving all existing screens to React. Therefore, after weighing all the costs and benefits, we decided against it.

Visual Coherence

Instead, we came up with an alternative solution: visual coherence. This is a complete rework of our existing back-end-rendered components to make them visually coherent with the new corresponding React components.

We gave ourselves two constraints. Firstly, no DOM modifications. Having a lot of Selenium tests meant we couldn’t go with an off-the-shelf component library as the tests needed to continue to work with the existing screens without the need for major changes. Our second constraint was not to introduce any additional JavaScript. This was mainly to avoid further maintenance costs outside of the React implementations. Principally, we wanted to uplift the styling of the existing components to make them closely resemble our React components.

Here is an example of our button changes. Our React button is built on top of material-ui, so it has a ripple effect on focus. Besides the background colour, the two buttons look and behave pretty differently — we want one consistent button in our application. Now, we could style the material button to look and behave like the existing one, but we did not want to make material-ui look worse. We also wanted to be as close as possible to the end state.

In our uplifted coherent button, we’ve lost ripple on focus, but otherwise the button is identical. We applied similar changes across the board, so that every component is visually coherent with only minor behavioural differences, with the average user unlikely to notice the difference.

This approach is a compromise whereby we are balancing visual and behavioural equivalence against future maintenance costs. With this approach, navigating between old and new features will not be jarring and will maintain a cohesive, one-app experience.

There are naturally disadvantages to this approach. Firstly, we cannot make the existing components behave like React features and we need to be prepared to make behavioural compromises. We found closing the visual gap is enough to avoid the suboptimal user experience of jarring changes when moving between old and new features. Secondly, there is a maintenance cost involved. Our uplifted CSS needs to be revised as our React components evolve in the future. We were able to reduce this burden greatly by not making any DOM modifications or adding additional JavaScript.

We see visual coherence as a temporary solution while we convert all of our existing back-end-rendered features to React.

Parting Words

We have covered a lot of concepts in this post. Together they have allowed us to introduce modern features such as runtime theming, which affect both the back-end-rendered and React-rendered parts of the screen.

We have solved four big problems that allowed us to unlock modern front-end development with React in our core Alfa Systems product:

  • Introducing React incrementally alongside hundreds of existing screens.
  • Reconciling complex navigation on the server side with client-side navigation.
  • Introducing additional safety of strong typing with TypeScript and GraphQL.
  • Consistency between the existing screen and the new React components.

Just as importantly, we’ve achieved a modern stack on the front end that we love working with, using the latest React patterns such as hooks and context in combination with TypeScript and GraphQL.

--

--