@bandwidth/react-wormhole

Modernize your legacy web app seamlessly with React Portals!

Usage no npm install needed!

<script type="module">
  import bandwidthReactWormhole from 'https://cdn.skypack.dev/@bandwidth/react-wormhole';
</script>

README

React wormhole

Modernize your legacy web app seamlessly with React Portals!

With React 16's introduction of Portals, it's now more versatile than ever - allowing you to inject React-rendered views anywhere in the DOM. That makes it the perfect time to get rid of legacy frontend frameworks by switching to React with an incremental launch strategy. Start replacing widgets, pages, and forms in your existing app with React components today using react-wormhole without disrupting existing experiences.

Preview of functionality

Get started

For this guide, we're assuming you already have your legacy application and your React application on the same page, generally by including a <script> tag in your legacy application which loads your compiled React app code from a locally-running Webpack development server. You should be seeing your legacy app and React app rendering in the same page.

Create a new debug inspector for your React app using createInspector(). Use inspector.mountAndRender to mount the generated root element on the page and render your app into it. Your React app should now have disappeared from the page, and a set of controls are present in the bottom corner.

import { createInspector } from '@bandwidth/react-wormhole';
import App from 'containers/App';

const inspector = createInspector();
inspector.mountAndRender(
  <App/>,
);

In your legacy app, begin replacing widgets, views, forms or pages with target elements from react-wormhole. You can use any logic, feature flagging, or configuration suits your needs to determine which views to replace (we highly recommend using feature flags to control rollout). To replace an element with a portal target element, you just need to do the following:

  • The element should have a data-react-portal-id attribute value set to the portal ID.
  • When the element is inserted into the DOM, you must dispatch a message on the window using window.postMessage with the following shape: { type: 'reactPortalTargetMounted', portalId: 'yourPortalId' }, providing the correct portal ID.
// create and use an element like this instead of your legacy app's view
const portalId = 'portalExample';
const target = document.createElement('div');
target.setAttribute('data-react-portal-id', portalId);
root.appendChild(target);
window.postMessage({
  type: 'reactPortalTargetReady',
  portalId: portalId,
}, '*');

Now, in your React app, wrap the portion of your React tree which you want to render inside the target element with a <Portal to={portalId}> component. You should see the component appear in the appropriate location in your legacy app when your legacy app inserts the target element and posts the message to the window.

import Portal from '@bandwidth/react-wormhole';

<Portal to="portalExample">
  <ExampleView />
</Portal>

You're done!

Not seeing the component? Check the console for errors, or use the debugging tools described later to inspect the state of your React app. Remember, if your React app isn't rendering the components you want to portal through, they won't be on the page!

In Production Apps

When you build your app for production, use environment-based configuration to avoid using the inspector entirely. In Webpack, you could use the webpack.ProvidePlugin to supply your process.env.NODE_ENV value to your bundle, and then write conditional logic to mount your app into a hidden <div> instead of the inspector in a production build.

Features

A drop-in Portal component

Easily mirror any React UI into your legacy app by wrapping it in a <Portal> component.

<Portal to="userCreateForm">
  <UserCreateForm />
</Portal>

Portaled React UI will display in both the legacy app and the React app by default ('mirroring'). You can disable this feature if you'd rather have the portaled elements disappear from the rendered React tree. Mirroring is the default behavior because it gives you a good view into what a React-only app would look like when migration is complete. If you want to turn mirroring off, pass showInReact={false} as a prop to the Portal component.

Tools to track portal target element readiness

Import wormhole to reference a variety of tools to manage portal target elements on the page.

wormhole.getPortalElement(portalId: String)

Queries the DOM to find the target element for the portal ID which you provide. This is used internally by the Portal component to determine where to render its contents. It will return null if no element has been mounted in the DOM for the provided ID.

wormhole.notifyPortalReady(portalId: String)

Call this function with a portal ID when you have mounted your portal target element from your legacy application. Calling this function will trigger your React Portal component targetting that portal to render its contents onto the page.

If your element has not been mounted, this function will throw an error.

wormhole.addPortalListener(portalId: String, listener: Function)

Adds an event listener for a portal target element with the specified portal ID. When the legacy app calls notifyPortalReady, any listeners for that portal will be called with the target element passed as the first parameter. If the element is already on the page when you add a listener, your listener will be called immediately. This is automatically done by the React Portal component as part of the mounting process.

wormhole.removePortalListener(portalId: String, listener: Function)

Removes a previously added event listener for a portal target element. Please pass a reference to the same exact function you originally provided to remove it. This is automatically done by the React Portal component as part of the unmounting process.

React root element with debugging tools

Just because you're portaling your React components into a legacy UI doesn't mean you have to lose the context of the React app you're building behind the scenes. react-wormhole includes debugging tools you can use to view the state of your 'invisible' React app, so you can keep using your browser's React dev tools to inspect state, props and layout as you normally would.

In order to use the tools, render your React application using the inspector created with the provided createInspector function.

import { createInspector } from '@bandwidth/react-wormhole';
import ReactDOM from 'react-dom';
import App from 'components/App';

const inspector = createInspector({
  storageMode: 'localStorage',
  storageKey: 'reactRootDebugState',
  id: 'invisibleReactRoot',
  defaultMode: 'mini',
  defaultPosition: 'top right',
});

inspector.mountAndRender(
  <App />,
);

The inspector exposes a set of debug functions that you can call directly if you want to programmatically control it:

root.show(); // full-screen display
root.mini(); // minimized picture-in-picture in the bottom corner
root.half(); // larger picture-in-picture
root.hide(); // hides the view entirely
root.setPosition(vertical, horizontal); // vertical = 'top'|'bottom', horizontal = 'left'|'right'

To persist the inspector state between tabs and sessions, you may specify a storageMode value in the options for createInspector, either none, localStorage or sessionStorage. Default is none.

storageKey controls the name of the key which the mode is saved to in either localStorage or sessionStorage.

id controls the id applied to the root element node.

defaultMode applies a default display mode if no storage method is used, or if no saved method is present.

defaultPosition applies a default position to the inspector if no storage method is used, or if no saved position is present.

react-wormhole is a library and a pattern

The end goal of using react-wormhole is to tranisition your legacy application to a full, React-only app. Code alone won't make that happen; we also need guiding principles for development, as each legacy application will have its own needs.

The principles to follow are:

  • Don't be too hasty to migrate global state from the legacy app, like routing and authenticated user context. In most cases, we anticipate this will be the last thing to cross over into the React application. During migration, let your React app act as a 'state observer' of global context wherever possible. This lets you defer complicated migrations like authentication and route handling until the final push, when you sunset your legacy app entirely.
  • Do tackle migration feature-by-feature, not page-by-page. Sometimes these approaches will coincide, but not always. Focus efforts on one feature at a time, replacing every related piece of UI with a React portaled view.
  • Do use feature flags to control portals. Empower your migration approach by targetting specific user sets with early access to new React-enabled features. Hide partially-migrated features behind flags until they're ready for public debut.

In the /example directory, you can find a simple example implementation of a 'legacy app' which is in the middle of migrating. There are a few suggestions in code and comments within that may give you some ideas on how to successfully interop between legacy and new code.

For global contextual data, we highly recommend operating in read-only mode from the React side until the migration is complete. You can use react-router to observe routing state from the URL and respond to it within React components. For all other global state, like the logged in user or global settings, it's a good idea to provide some global interop tools specific to your app and write bindings to use them from React.

For instance, there's a very basic higher-order-component in /example/app/withLegacyData.js which uses window.postMessage listeners to observe changes in the legacy app's state and re-render itself when that state changes. Rather than trying to duplicate and synchronize state from the legacy app in Redux or a similar data store, we just listen for updates globally and overwrite the local component state, triggering a re-render with the new data.

For more complex or extensive global state, you might implement a full React Provider pattern using context. How you solve state management is ultimately up to you, but try to avoid state duplication and race conditions.

For non-global state, discretion can be used when moving data under control of the React application. For instance, suppose a user can view a post in the app by navigating to /posts/:postId. You could include a route in your React app which matches this URL, then extract the :postId value from it.

There are now two approaches you could take, depending on your goals for your migration:

Use React to manage data

Using the post ID you got from the URL, you might write the React state management logic to fetch the data for the post from your API by ID and store it in a React state system like Redux. From that state, you could then populate the data passed down to your view, which would be rendered via portal back into the legacy app.

If your app doesn't have a public API for fetching such data, remember that you'll need one eventually to migrate completely to a React client-side app. It's a good idea to write such an API as a part of the work required to complete a 'feature' chunk of work. If a full migration isn't your goal (you just want a React view), you may not choose this route.

Interop with your legacy application to fetch data

Instead of referencing an API, you might expose the data which your legacy app is using to render its view to React with some sort of interop tools. For instance, if your legacy app framework includes an event bus, you may be able to write Javascript global tools to subscribe to events. Or, if you're using a server-rendered page, you could also write the data as JSON into a <script> tag on the page to define it as a global variable, which React could then reference.

Keep in mind, exposing data globally can lead to maintenance and scaling problems long-term. Ultimately, globally available data isn't so different from an API, except over a different protocol. To ensure that your migration is successful, you'll want to approach data interop carefully.