Rendering Legacy Pages inside of a React Component

Recently, I needed to implement an unusual feature for a React front end: a reusable component that displays server-rendered pages from a legacy version of the same app. If you’ve found yourself in a similar situation, I hope this guide will make life easy for you!

What’s the purpose of this component? It lets us re-use part of our legacy server-rendered app without leaving our single-page application. I’ve found it’s a great way to smoothly transition away from the legacy app.

Considerations

This implementation uses React Router to allow navigable multi-page workflows. If you aren’t using React Router, then this component would be even simpler. I’m also going to use TypeScript in my example, but of course, you can use plain old JSX if that’s your thing.

This implementation also requires jQuery. On one hand, there are downsides to using jQuery. It’s about twice as big as React, and it has a scarily large scope. On the other hand, having to use jQuery might be the motivation you need to phase legacy pages out of your application ASAP 😉.

Note that this component has some limitations in order to keep things as simple as possible:

  • If the browser submits a form element contained within the legacy content, it will be intercepted and handled. However, changing the window’s location (e.g. when a link is clicked) will not be handled specially.
  • Navigating back and forth through subsequent pages will clear out any changes that the user makes, such as form controls.
  • The legacy content will not use any custom JavaScript. There are ways to get around this limitation, but they’re outside the scope of this post. Leave me a comment if you need help with this part!

I was using React within the context of the Ruby gem called Webpacker. If you’re not using Webpacker, you may need to compile and serve CSS within the responses coming from your server.

The React Components

This component will actually be a container component with an inner presentational component. Let’s start with the inner component, which is pretty straightforward:


import * as React from "react";

// We only need to pass two properties into this component. `content` is the HTML received from the server.
// We will use `handleForm` to intercept forms defined by `content`
type Props = {
  content: string;
  handleForm: (form: HTMLFormElement) => void;
};

export const LegacyView = (props: Props) => {
  // I'm using React hooks here, but obviously you can use the classic `componentDidMount` 
  // inside of a class component if you're more comfortable with that!
  React.useEffect(() => {
    // Let's be as restrictive here as we can so that jQuery doesn't disturb the rest of the React app
    $(".legacy-page-wrapper form").on("submit", function (event){
      event.preventDefault();
      props.handleForm(this as HTMLFormElement);
    })
  });

  return (
    <div className="app-page">
      // Yes, we are dangerously setting some inner HTML.
      // Personally I'd prefer this along with some integration tests over using an `iframe`
      <div className="legacy-page-wrapper" dangerouslySetInnerHTML={{ __html: props.content }} />
    </div>
  );
}

Note: You’ll to install the npm library `@types/jquery` if you’re also using TypeScript. That’s it for the inner component.

Let’s move on to the container component, which is a bit more interesting. It will keep a map of React Router hashes to LegacyView components. When a form inside of one of those views is submitted, this component will turn the HTML form element into some FormData that can be submitted by Fetch. Then it will take the body of the HTTP response and turn it into a brand new LegacyView.

Here’s the code:


import * as React from 'react';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { LegacyView } from '../legacy-view';

type Props = OwnProps & RouteComponentProps

type State = {
  // Each key in this map is a hash supplied by React Router.
  pages: { [key: string]: JSX.Element};
  // `defaultPage` will help us in the case that a user navigates backwards from a different React Router route into the route that contains this component
  defaultPage: JSX.Element;
  // `requesting` will help us signal to the user that a request is in progress.
  requesting: boolean;
}

type OwnProps = {
  // We'll make a GET request to the `initialUrl` after this component mounts.
  initialUrl: string;
  // `onFinish` is an optional hook that will be triggered when the legacy action is "complete"
  onFinish?: () => void;
}

class LegacyPage extends React.Component<Props, State>{
  constructor(props: Props) {
    super(props);
    this.state = {
      pages: {},
      defaultPage: null,
      requesting: true,
    }
  }

  render = () => {
    if (this.state.requesting) {
      // You can render whatever you'd like here to let the user know they're waiting for a page to load
      return <Loading />
    }

    const existingPage = this.state.pages[this.props.location.key];
    // `existingPage` might not exist for a number of reasons
    if (existingPage) {
      return existingPage;
    }

    if (this.state.defaultPage) {
      return this.state.defaultPage;
    }

    // If there are no alternatives available, we'll start with the initial URL
    this.getInitialPage();
    return (<Loading />)
  }

  componentDidMount = async () => {
    this.getInitialPage();
  }

  // This function adds a page to our `pages` map in the state
  putPage = (content: string) => {
    const page = <LegacyView content={content} handleForm={this.handleForm} />
    const key = this.props.location.key;
    this.setState({
      pages: {
        ...this.state.pages,
        [key]: page,
      },
      defaultPage: page,
      requesting: false,
    })
  }

  getInitialPage = async () => {
    // You'll need to implement `API.getHtml`, perhaps with the Fetch API
    // https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API
    const content = await API.getHtml(this.props.initialUrl);
    this.putPage(content);
  }

  // This function is executed when a `form` element inside the legacy content is submitted
  handleForm = async (form: HTMLFormElement) => {
    this.setState({ requesting: true });
    // If you need to do anything fancy, such as inspect the form for specific properties, this is the place to do it
    this.createNewLegacyPage(form);
  };

  createNewLegacyPage = async (form: HTMLFormElement, extraFormData?: object) => {
    try {
      const url = form.getAttribute('action');
      const method = form.getAttribute('method') || 'POST'
      const formData = bodyFromForm(form);

      // I'll explain `extraFormData` below
      if (extraFormData) {
        _.each(extraFormData, (v, k) => {
          formData.set(k, v);
        });
      }

      // You'll need to implement `API.submitForm` according to your own needs. In my case, the function returns a Response object:
      // https://developer.mozilla.org/en-US/docs/Web/API/Response
      const response = await API.submitForm(url, method, formData);
      const content = await response.text();

      // I'll explain this response header later
      if (response.headers.get("Legacy-Action-Complete") === "complete") {
        if (this.props.onFinish){
          this.props.onFinish();
        }
      }

      else {
        this.pushHistory();
        this.putPage(content);
      }
    }
    catch(error) {
      console.error('Error while fetching legacy page:', error);
    } 
  };

  // This allows the user to navigate backwards within the legacy page stack
  pushHistory = () => {
    const path = this.props.location.pathname;
    const state = this.props.location.state;
    this.props.history.push(path, state);
  }
}

// `bodyFromForm` uses the FormData API to convert an HTML `form` element into an object that the Fetch API can use when sending a POST request
export const bodyFromForm = (form: HTMLFormElement): FormData => {
  let formData = new FormData();
  const elements = form.elements;

  // I prefer immutability whenever possible, but unfortunately the `elements` variable is not iterable
  for (let i = 0; i < elements.length; i++) {
    const element = elements[i] as HTMLInputElement;
    const name = element.name;

    if (name){
      // Yup, we support file inputs!
      const file = element.files ? element.files.item(0) : null;
      const value = file || element.value;
      formData.set(name, value);
    }
  }

  return formData;
} 

export const LegacyPageContainer = withRouter(InnerLegacyPortalPage);

And that’s all of the JavaScript we need! Now we simply need to make a couple of small changes to the legacy HTML.

The Server

First, check out the response header named “Legacy-Action-Complete” which the LegacyPage is looking for. We can use this as a flag to trigger the onFinished action (e.g. changing the current React Router route). The way that you add this header will obviously depend on your server. As an example, the Rails code that I use is `response.set_header(‘Legacy-Action-Complete’, “complete”)`. You should omit this header for things like configuration and settings pages, which can re-render the same page after submission.

Secondly, the responses that your server sends back probably include way more data than you need for this feature, such as headers, footers, and a head element, not to mention html and body elements. In order to only send the inner part of the HTML that you really care about, I recommend sending a special request header–something like “Client-Layout: react.” This would happen inside of the implementation of API.submitForm. Then, your server can observe this request header and do whatever magic is necessary to exclude the outer content of the response.

In my Rails app, for example, I actually had to edit each template in the app/views/layouts/ directory. The templates look for the request header, and then either render their normal content or simply call yield and nothing else.

There are many, many parts of this feature that depend on the exact implementation of the server, the old front end, and the React front end. You’ll want to do lots of integration and manual testing for each legacy page that you put inside your React app. Hopefully, though, this guide will put you on a smooth path toward integrating your React and legacy front ends. If you have any questions, comments, or suggested improvements, I’d love to read them in the comments!