Sharing React Query across micro frontends with single-spa and webpack module federation

Arnaud Dostes
8 min readMay 20, 2022

--

In this article, I will show how to setup multiple micro frontends using single-spa and module federation. Each micro frontend will be using React Query to fetch and cache data. I will then demonstrate how multiple micro frontends can use the same QueryClient to share cached data. The purpose is to avoid having a micro frontend make a network call if the data has already been fetched by another micro frontend without the developer having to do anything, and for the user to benefit from the performance improvement.

We will look at two different ways to achieve this:

  • Using customProps where a host app instantiates the QueryClient and passes it to the micro frontends
  • Using a bi-directional host where a host app instantiates the QueryClient and exposes it to micro frontends using module federation

Just show me the code

Using custom props: https://github.com/arnaudNYC/sspa-wmf-rq/tree/main

Using a bi-directional host: https://github.com/arnaudNYC/sspa-wmf-rq/tree/with-remote-host

Sharing ReactQuery using customProps

In this first part, a host app imports two React micro frontends using module federation. The host app passes the same ReactQuery client to each micro front end using custom props so they can share the same cache.

The project is organized in a monorepo using lerna composed of four packages:

├── packages
│ ├── host
│ ├── app1
│ ├── app2
│ └── shared

I chose a monorepo to facilitate code sharing between the micro frontends. Keep in mind that each micro frontend is built so it can run and be developed in isolation of other micro frontends. A developer can run app1 or app2 by themselves without needing anything to be up and running.

  • host: the single-spa root config that contains the html file that is shared by all the micro frontends. It is responsible for booting up the micro frontends and need be, mapping them to individual routes.
  • app1: a micro frontend
  • app2: another micro frontend. It is nearly identical to app1
  • shared: a component library containing a component named Example that uses useQuery to fetch data.

The two micro frontends app1 and app2 will both be importing the Example component from the shared project to demonstrate the sharing of the data cache.

The host, app1 and app2 packages will be built using webpack, and the shared package will be built using rollup.

First, let’s have a look at the ‘shared’ project

shared
├── index.html
├── package.json
├── rollup.config.js
└── src
├── Example.jsx
└── index.js

The Example.jsx file contains the logic for fetching data from the github api. A button allows the user to display the component, and the query is configured to run when the component mounts. This is so later we can decide when the query is executed. The query has a staleTime set to Infinity so it will only ever execute once, again this is to demonstrate that once the call is made, it won’t ever be made again.

// Example.jsximport React from "react";
import { useQuery } from "react-query";
function Example() {
const [show, setShow] = React.useState(false);
return (
<div>
<div>
<button onClick={() => setShow(!show)}>Show/Hide</button>
</div>
{show ? <RepoData /> : null}
</div>
);
}
const queryOptions = {
staleTime: Infinity,
};
function RepoData() {
const { isLoading, error, data, isFetching } = useQuery(
"repoData",
() =>
fetch(
"https://api.github.com/repos/tannerlinsley/react-query"
).then((res) => res.json()),
queryOptions
);
if (isLoading) {
return "Loading...";
}
if (error) {
return "An error has occurred: " + error.message;
}
return (
<>
<h3>{data.name}</h3>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>{" "}
<strong>✨ {data.stargazers_count}</strong>{" "}
<strong>🍴 {data.forks_count}</strong>
<div>{isFetching ? "Updating..." : ""}</div>
</>
);
}

Thanks to module federation, React Query will be provided by the consuming apps, so we can exclude it along with React and React-dom:

// rollup.config.jsimport babel from "@rollup/plugin-babel";
import resolve from "@rollup/plugin-node-resolve";
import pkg from "./package.json";export default {
input: "src/index.js",
plugins: [
resolve({
extensions: [".js", ".jsx"],
}),
babel({
babelHelpers: "runtime",
plugins: ["@babel/plugin-transform-runtime"],
presets: ["@babel/preset-react"],
}),
],
output: [
{
file: pkg.module,
format: "esm",
sourcemap: true,
},
],
external: ["react", "react-dom", "react-query"],
};

That’s it for the ‘shared’ project, there’s really nothing more to it.

Now let’s look at the app1 project.

app1
└── src
├── App.jsx
├── index.js
├── main.jsx
└── singleSpaEntry.js
  • App.jsx is the top most component of this app, it imports the Example component from ‘shared’, and sets up the QueryClientProvider. Note that it receives the QueryClient instance as a prop, this is important!
import React from "react";
import { QueryClientProvider } from "react-query";
import { Example } from "@demo/shared";
function App({ title = "", queryClient }) {
return (
<QueryClientProvider client={queryClient}>
<h2>{title}</h2>
<Example />
</QueryClientProvider>
);
}

In the next step, we’ll configure app1 so it can run by itself, so we’re going to create a main.jsx that calls ReactDom.render in which the QueryClient is instantiated

// main.jsximport React from "react";
import ReactDOM from "react-dom";
import { QueryClient } from "react-query";
import App from "./App";const c = new QueryClient();ReactDOM.render(<App queryClient={c} />, document.getElementById("app"));

So far this is very standard, but for module federation to work, we need what is described as an asynchronous chunk loading operation. It’s not necessary to really understand what that is, just that the entry point of the app is and index.js file that imports the main.jsx file:

// main.jsimport("./main");
export {};

That’s it for the application to run by itself. However, we want to expose it so our single-spa host app can invoke it, so we need a single spa entry file:

// singleSpaEntry.jsimport React from "react";
import ReactDOM from "react-dom";
import singleSpaReact from "single-spa-react";
import App from "./App";const appLifeCycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App,
errorBoundary() {
// https://reactjs.org/docs/error-boundaries.html
return <div>This renders when a catastrophic error occurs</div>;
},
});
export function bootstrap(props) {
return appLifeCycles.bootstrap(props);
}
export function mount(props) {
return appLifeCycles.mount(props);
}
export function unmount(props) {
return appLifeCycles.unmount(props);
}

Finally, our webpack config will specify the index.js file as an entry point so our application can work in development mode, and exposes the singleSpaEntry with module federation so it can be loaded from the host app:

// webpack.config.jsconst path = require("path");const HtmlWebpackPlugin = require("html-webpack-plugin");
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
mode: "production",
entry: {
app: path.join(__dirname, "src", "index.js"),
},
output: {
filename: "[name].[chunkhash].bundle.js",
path: path.resolve(__dirname, "dist"),
clean: true,
},
module: {
rules: [
{
test: /\.jsx?$/,
loader: "babel-loader",
exclude: /node_modules/,
options: {
presets: ["@babel/preset-react"],
},
},
],
},
resolve: {
extensions: ["*", ".js", ".jsx"],
modules: ["node_modules", path.resolve(__dirname, "src")],
},
devServer: {
port: "3001",
hot: false,
},
devtool: "source-map",
plugins: [
new HtmlWebpackPlugin({
template: path.join(__dirname, "public", "index.html"),
}),
new ModuleFederationPlugin({
name: "app1",
filename: "remoteEntry.js",
exposes: {
"./App": "./src/singleSpaEntry",
},
shared: [
//
"react-dom",
"react-query",
"react",
"single-spa-react",
],
}),
],
};

Finally, let’s create some scripts in package.json

"scripts": {
"start": "webpack serve --mode development",
"build": "webpack",
"serve": "serve dist -p 3001"
}

Now when running yarn start we should see:
- http://localhost:3001 <- the app
- http://localhost:3001/singleSpaEntry.js <- the single spa entry ready to be loaded using module federation from our host app

Note here that in development mode, we only have live reloading, and no hot module replacement. It turns out that hmr and module federation don’t work together. In a real life scenario, we can have one development mode with live reloading and hmr without module federation, another dev mode with live reloading, no hmr and module federation, and prod mode with just module federation.

App2 is nearly identical to App1 except for the ‘name’ in the webpack config, and the app runs on port 3002.

The last step is to configure the host app. This is a vanilla JS app which will load the micro frontends app1 and app2 using module federation and then execute them using single-spa. Remember how the queryClient is passed in as a prop to the App.jsx component? Well, the host app is responsible for instantiating it and passing it to the micro frontends.

// src/index.js
import("./register");
// src/register.js
import { registerApplication, start } from "single-spa";
import { QueryClient } from "react-query";const queryClient = new QueryClient();registerApplication({
name: "app1",
app: () => import("app1/App"),
activeWhen: "/",
customProps: {
title: "App 1 running on host",
queryClient,
},
});
registerApplication({
name: "app2",
app: () => import("app2/App"),
activeWhen: "/",
customProps: {
title: "App 2 running on host",
queryClient,
},
});
start();

Note the options object passed to the registerApplication function from single-spa. The import statement is resolved using module federation, and the customProps object is passed to the App.jsx exposed from app1 and app2. Since the same queryClient is passed to both apps, they will automatically share the same cache.

In the finished demo, we see App1 and App2 running side by side, App1 making a network request, and App2 using that data instead of making the same network request.

Recap:

  • shared exports the Example component which uses React Query to make a network call.
  • App1 and App2 import the Example component from shared, and are exposed as single-spa applications using module federation.
  • host: a single-spa host applications which loads separate micro frontends using module federation. Each separate micro frontend receives the same instead of ReactQueryClient in order to share the same data cache.

Using a bi-directional host

In the first example, each micro frontend is exposed using module federation, and what they receive from the host is passed in as custom props. In this second example, instead of passing the common QueryClient as custom props, the host app is going to be configured to expose it using module federation, and micro frontends can import it as a regular module. We still want each micro frontend to be able to run independently so we’re also going to add a bit of code for when the host app is not available.

  • App1 and App2

The App component will still receive the QueryClient from its props, but now we have to import it, so we’re going to create an AppRemote.jsx that will take care of importing from the host app:

// AppRemote.jsximport React from "react";
import App from "./App";
import queryClient from "host/queryClient";function AppRemote(props) {
return <App {...props} queryClient={queryClient} />;
}
export default AppRemote;

And this AppRemote is now exposed instead of the original App

// singleSpaEntry.js
import App from "./AppRemote";
const appLifeCycles = singleSpaReact({
React,
ReactDOM,
rootComponent: App,
errorBoundary() {
// https://reactjs.org/docs/error-boundaries.html
return <div>This renders when a catastrophic error occurs</div>;
},
});

The rest of the code remains unchanged so it can run in isolation as previously. However we do need to tell webpack where to get the host from. For that, we just declare the host as a remote.

// packages/app1/webpack.config.js
// packages/app2/webpack.config.js
new ModuleFederationPlugin({
name: "app2",
filename: "remoteEntry.js",
exposes: {
"./App": "./src/singleSpaEntry",
},
remotes: {
host: "host@http://localhost:3000/remoteEntry.js",
},
shared: [
//
"react-dom",
"react-query",
"react",
"single-spa-react",
],
}),

The host now has to expose the queryClient since it’s expected by the micro frontends:

// packages/host/webpack.config.jsnew ModuleFederationPlugin({
name: "host",
remotes: {
app1: "app1@http://localhost:3001/remoteEntry.js",
app2: "app2@http://localhost:3002/remoteEntry.js",
},
exposes: {
"./queryClient": "./src/queryClient",
},
filename: "remoteEntry.js",
shared: ["react-query"],
}),

We also need to create the queryClient so the micro frontends can share it.

// packages/host/src/queryClientimport { QueryClient } from "react-query";const queryClient = new QueryClient();export default queryClient;

The host is now a bi-directional application since it both exposes one of its modules, and imports remote modules using module federation.

Monorepo + single-spa + webpack module federation + React Query is an incredibly powerful combination. The monorepo encourages code discoverability and reuse, single-spa allows to orchestrate different microfront ends on the same page, and module federation allows to develop and deploy micro frontends separately. Finally, ReactQuery allows to share server state between micro frontends without making extraneous network requests.

Thanks to Ariel Perez for reviewing this code and his suggestions!

--

--

Arnaud Dostes

Previously Paris and Geneva, currently New York. Can also be found at https://dev.to/arnaud