Mounting multiple React apps on a page where other content is rendered server-side

Identically to mounting a single React app on a page, react-dom can be used to mount multiple smaller React apps to manage different regions of a page. Here, we’ll mount a Nav component and a Footer component, providing interactive, React-driven functionality to replace any static content that was inside those elements when the page was loaded.

Note: Because we’re not assuming these regions were initially rendered by react-dom/server, we’ll use render(), but hydrate() would be a good function to look at if that’s the case for your project.

// index.tsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Nav } from './components/nav/Nav';
import { Footer } from './components/footer/Footer';

const init = () => {
  const navElement = document.getElementById('app-nav')!;
  const navRoot = createRoot(navElement);
  navRoot.render(<Nav />);

  const footerElement = document.getElementById('app-footer')!;
  const footerRoot = createRoot(footerElement);
  footerRoot.render(<Footer />);
};

init();

Using code splitting with dynamic imports

Code splitting refers to breaking apart a single JavaScript bundle at specific points to defer loading functionality through separate chunks of JavaScript. This is generally expressed through async calls to a dynamic import() function exposed through the runtime of a bundler.

Assuming you’re using webpack or a similar bundler, for functionality that may not be present on every page, or that should be out of the critical path, dynamically importing that module can be a nice optimization. Let’s add a commenting feature that loads JS and renders only if it’s present.

How to configure code splitting: If this is the first time you’re looking at code splitting with dynamic imports, the webpack code splitting guide contains enough information to get off the ground. With TypeScript and/or Babel, it’s important to ensure you aren’t replacing ESNext dynamic imports with ES2015 or CommonJS expressions. Default exports also get interesting, especially if using TypeScript (depending on options), so sticking with named exports will minimize difficulty.

// index.tsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Nav } from './components/nav/Nav';
import { Footer } from './components/footer/Footer';

const init = async () => {
  // ...

  // Find a commenting region that may not exist on every page:
  const commentsElement = document.getElementById('app-comments');
  if (commentsElement) {
    try {
      // Dynamically import the component that manages this feature:
      const { Comments } = await import('./components/comments/Comments');
      const commentsRoot = createRoot(commentsElement);
      commentsRoot.render(<Comments />);
    } catch (e) {
      // Handle a dynamic import error as you see fit; in this case, if the
      // commenting functionality fails to load, we might omit it silently:
      console.error(e);
    }
  }
};

init();

Sharing events or state across multiple React apps

Just because we have multiple React apps on a page doesn’t mean we can’t share events or state across those app boundaries. Even non-SPA, multi-page applications can run into scenarios where sharing state between different sections of a page is useful.

Sharing a publish/subscribe channel across the apps is straightforward if we pass that as a prop to each root component that needs it. For instance, if you plan on using RxJS across your website, a Subject would work here. Otherwise, a lighter solution would work equally well.

By subscribing to this event stream and setting any state they need at their own root component — or with a shared hook or higher order component to assist with this root state management — each of these apps can react to shared events published by any other app.

// index.tsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Subject } from 'rxjs';
import { Nav } from './components/nav/Nav';
import { Footer } from './components/footer/Footer';

type Event = { type: 'example'; payload: number };

const init = async () => {
  const event$ = new Subject<Event>();

  const navElement = document.getElementById('app-nav')!;
  const navRoot = createRoot(navElement);
  navRoot.render(<Nav />);

  const footerElement = document.getElementById('app-footer')!;
  const footerRoot = createRoot(footerElement);
  footerRoot.render(<Footer />);
  // ...
};

init();

Using Redux for global state management would also work, wrapping each of these top-level components with a Provider connected to a shared Redux store.

// index.tsx

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './store';

const init = async () => {
  const navElement = document.getElementById('app-nav')!;
  const navRoot = createRoot(navElement);
  navRoot.render(
    <Provider store={store}>
      <Nav />
    </Provider>,
  );

  const footerElement = document.getElementById('app-footer')!;
  const footerRoot = createRoot(footerElement);
  footerRoot.render(
    <Provider store={store}>
      <Footer />
    </Provider>,
  );

  // ...
};

init();