Creating a React, Webpack Module Federation with shared state micro-frontend app using react-context-slices

roger gomez
8 min readJul 14, 2023

--

Welcome to a hands-on micro-frontend project setup using Lerna, React, and Webpack Module Federation. In this article, we’ll explore how react-context-slices can simplify shared state management in your micro-frontend architecture. With react-context-slices, you can effortlessly handle shared state among micro-frontend projects while also managing local shared state within each micro-frontend.

Our project structure will consist of a host, two remotes, and a shared state library. Within the shared state library, we’ll define two slices: one using React Context and the other using Redux, both representing a counter state. In one remote, we’ll create buttons to increment the shared state counters, while the other remote will display the current values of those counters. By bringing it all together on the host, we’ll showcase a fully functional micro-frontend app with two counters — one using Redux and the other using React Context.

We’ll also explore the flexibility of react-context-slices by implementing local shared state within each micro-frontend project. This powerful feature demonstrates how you can seamlessly combine local and shared state management, enabling modular and scalable micro-frontend development.

Let’s do it

You create a folder named mfe and inside of it you create a folder named packages:

mkdir mfe
cd mfe
mkdir packages

In the mfe folder, you run:

yarn init -y

this will create a package.json file.

Now edit this package.json file to be like this:

{
"name": "mfe",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"private": true,
"workspaces": [
"packages/*"
]
}

Basically, we added the private key and workspaces key.

Now install Lerna as a dev dependency. Run:

yarn add -D lerna -W

The -W flag is necessary to allow yarn to install it when having workspaces.

Now your package.json file should look something like this:

{
"name": "mfe",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"private": true,
"workspaces": [
"packages/*"
],
"devDependencies": {
"lerna": "^7.1.3"
}
}

Finally, add the scripts key to it:

{
"name": "mfe",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"private": true,
"workspaces": [
"packages/*"
],
"devDependencies": {
"lerna": "^7.1.3"
},
"scripts": {
"start": "lerna run --parallel start",
"build": "lerna run build",
"serve": "lerna run --parallel serve"
}
}

Now run:

npx lerna init

This will init a git repository and create a lerna.json file. If you run:

npx lerna list

should list the list of packages, wich now it’s 0.

Now go to the packages folder and create in it a host folder, a remote1 folder, a remote2 folder and a shared_state folder:

cd packages
mkdir host
mkdir remote1
mkdir remote2
mkdir shared_state

Go to host folder and run:

yarn init -y

Do the same for the other folders in the packages folder. This will create package.json in each of these folders.

Now if you go to root folder (mfe) and run :

npx lerna list

you should see that Lerna detects four packages.

Next, it’s to install dependencies in host, remote1, and remote2. In each of them do:

yarn add -D webpack webpack-cli html-webpack-plugin webpack-dev-server babel-loader @babel/core @babel/preset-env @babel/preset-react serve

Don’t forget to install react and react-dom dependencies in host, remote1, and remote2:

yarn add react react-dom

For the local shared state within each micro-frontend project we will use react-context-slices, so we must install it too in host, remote1, and remote2:

yarn add react-context-slices

Now create a webpack.config.js file in host folder and paste the following:

// host/webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const { dependencies } = require("./package.json");

module.exports = {
entry: "./src/index",
mode: "development",
devServer: {
port: 3000,
},
module: {
rules: [
{
test: /\.(js|jsx)?$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-react"],
},
},
],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: "Host",
remotes: {
Remote1: "Remote1@http://localhost:3001/moduleEntry1.js",
Remote2: "Remote2@http://localhost:3002/moduleEntry2.js",
},
shared: {
...dependencies,
"react-context-slices": {},
react: {
singleton: true,
},
"react-dom": {
singleton: true,
},
shared_state: {
requiredVersion: require("../shared_state/package.json").version,
},
},
}),
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
],
resolve: {
extensions: [".js", ".jsx"],
},
target: "web",
};

Do the same with remote1 and remote2 with the following content:

// remote1/webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const { dependencies } = require("./package.json");

module.exports = {
entry: "./src/index",
mode: "development",
devServer: {
port: 3001,
},
module: {
rules: [
{
test: /\.(js|jsx)?$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-react"],
},
},
],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: "Remote1",
filename: "moduleEntry1.js",
exposes: {
"./root": "./src/root",
},
shared: {
...dependencies,
"react-context-slices": {},
react: {
singleton: true,
},
"react-dom": {
singleton: true,
},
shared_state: {
requiredVersion: require("../shared_state/package.json").version,
},
},
}),
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
],
resolve: {
extensions: [".js", ".jsx"],
},
target: "web",
};
// remote2/webpack.config.js
const HtmlWebpackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const { dependencies } = require("./package.json");

module.exports = {
entry: "./src/index",
mode: "development",
devServer: {
port: 3002,
},
module: {
rules: [
{
test: /\.(js|jsx)?$/,
exclude: /node_modules/,
use: [
{
loader: "babel-loader",
options: {
presets: ["@babel/preset-env", "@babel/preset-react"],
},
},
],
},
],
},
plugins: [
new ModuleFederationPlugin({
name: "Remote2",
filename: "moduleEntry2.js",
exposes: {
"./root": "./src/root",
},
shared: {
...dependencies,
"react-context-slices": {},
react: {
singleton: true,
},
"react-dom": {
singleton: true,
},
shared_state: {
requiredVersion: require("../shared_state/package.json").version,
},
},
}),
new HtmlWebpackPlugin({
template: "./public/index.html",
}),
],
resolve: {
extensions: [".js", ".jsx"],
},
target: "web",
};

Next, we will add the scripts key to package.json in host, remote1, and remote2. For host the package.json will be:

{
"name": "host",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.22.9",
"@babel/preset-env": "^7.22.9",
"@babel/preset-react": "^7.22.5",
"babel-loader": "^9.1.3",
"html-webpack-plugin": "^5.5.3",
"serve": "^14.2.0",
"webpack": "^5.88.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
},
"dependencies": {
"react": "^18.2.0",
"react-context-slices": "^9.1.1",
"react-dom": "^18.2.0"
},
"scripts": {
"start": "webpack serve",
"build": "webpack --mode=production",
"serve": "serve dist -p 3000"
}
}

For remote1 will be:

{
"name": "remote1",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.22.9",
"@babel/preset-env": "^7.22.9",
"@babel/preset-react": "^7.22.5",
"babel-loader": "^9.1.3",
"html-webpack-plugin": "^5.5.3",
"serve": "^14.2.0",
"webpack": "^5.88.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
},
"dependencies": {
"react": "^18.2.0",
"react-context-slices": "^9.1.1",
"react-dom": "^18.2.0"
},
"scripts": {
"start": "webpack serve",
"build": "webpack --mode=production",
"serve": "serve dist -p 3001"
}
}

And for remote2 it will be:

{
"name": "remote2",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"devDependencies": {
"@babel/core": "^7.22.9",
"@babel/preset-env": "^7.22.9",
"@babel/preset-react": "^7.22.5",
"babel-loader": "^9.1.3",
"html-webpack-plugin": "^5.5.3",
"serve": "^14.2.0",
"webpack": "^5.88.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1"
},
"dependencies": {
"react": "^18.2.0",
"react-context-slices": "^9.1.1",
"react-dom": "^18.2.0"
},
"scripts": {
"start": "webpack serve",
"build": "webpack --mode=production",
"serve": "serve dist -p 3002"
}
}

For host, remote1, and remote2 folders, add in each a src folder with the following files in it: index.js, bootstrap.js, app.js, root.js, and slices.js.

The contents of index.js, bootstrap.js, root.js, and slices.js files will be (for host, remote1, and remote2):

// src/index.js
import("./bootstrap");
// src/bootstrap.js
import React from "react";
import ReactDOM from "react-dom/client";
import { Provider } from "shared_state";
import Root from "./root";

const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<Provider>
<Root />
</Provider>
</React.StrictMode>
);
// src/root.js
import React from "react";
import { Provider } from "./slices";
import App from "./app";

const Root = () => (
<Provider>
<App />
</Provider>
);

export default Root;
// src/slices.js
import getHookAndProvider from "react-context-slices";

export const { useSlice, Provider } = getHookAndProvider({
slices: {
count1: { initialArg: 0 },
count2: { initialState: 0, reducers: { increment: (state) => state + 1 } },
},
});

Now the content of app.js file will be different for each of the packages. Let’s see the content for host package:

// host/src/app.js
import React, { lazy, Suspense } from "react";
import { useSlice } from "shared_state";
import { useSlice as useLocalSlice } from "./slices";

const Remote1App = lazy(() => import("Remote1/root"));
const Remote2App = lazy(() => import("Remote2/root"));

const App = () => {
const [count1, setCount1] = useSlice("count1");
const [count2, reduxDispatch, { increment }] = useSlice("count2");
const [localCount1, setLocalCount1] = useLocalSlice("count1");
const [localCount2, localReduxDispatch, { increment: localIncrement }] =
useLocalSlice("count2");

return (
<>
<Suspense fallback="loading...">
<Remote1App />
</Suspense>
<Suspense fallback="loading...">
<Remote2App />
</Suspense>
<div>
<button onClick={() => setCount1((c) => c + 1)}>+</button>
{count1}
</div>
<div>
<button onClick={() => reduxDispatch(increment())}>+</button>
{count2}
</div>
<div>
<button onClick={() => setLocalCount1((c) => c + 1)}>+</button>
{localCount1}
</div>
<div>
<button onClick={() => localReduxDispatch(localIncrement())}>+</button>
{localCount2}
</div>
</>
);
};

export default App;

Now the content for app.js file in remote1 package will be:

// remote1/src/app.js
import React from "react";
import { useSlice } from "shared_state";
import { useSlice as useLocalSlice } from "./slices";

const App = () => {
const [, setCount1] = useSlice("count1");
const [, reduxDispatch, { increment }] = useSlice("count2");
const [localCount1, setLocalCount1] = useLocalSlice("count1");
const [localCount2, localReduxDispatch, { increment: localIncrement }] =
useLocalSlice("count2");

return (
<>
<div>
<button onClick={() => setCount1((c) => c + 1)}>+</button>
</div>
<div>
<button onClick={() => reduxDispatch(increment())}>+</button>
</div>
<div>
<button onClick={() => setLocalCount1((c) => c + 1)}>+</button>
{localCount1}
</div>
<div>
<button onClick={() => localReduxDispatch(localIncrement())}>+</button>
{localCount2}
</div>
</>
);
};

export default App;

And the content for app.js file in remote2 will be:

// remote2/src/app.js
import React from "react";
import { useSlice } from "shared_state";
import { useSlice as useLocalSlice } from "./slices";

const App = () => {
const [count1] = useSlice("count1");
const [count2] = useSlice("count2");
const [localCount1, setLocalCount1] = useLocalSlice("count1");
const [localCount2, reduxDispatch, { increment }] = useLocalSlice("count2");

return (
<>
<div>{count1}</div>
<div>{count2}</div>
<div>
<button onClick={() => setLocalCount1((c) => c + 1)}>+</button>
{localCount1}
</div>
<div>
<button onClick={() => reduxDispatch(increment())}>+</button>
{localCount2}
</div>
</>
);
};

export default App;

As you can see in remote1 we have the buttons to interact with the counters of the shared state among the micro-frontend projects and in remote2 we see the value of these counters.

In each of the micro-frontend projects we also have local counters, to prove the coexistence of a local shared state within a micro-frontend project with a shared state among the micro-frontend projects.

Next is to create a public folder in each package (host, remote1, and remote2) with an index.html file in it with the following content:

<!DOCTYPE html>
<html>
<head></head>
<body>
<div id="root"></div>
</body>
</html>

Now we are done with the host, remote1, and remote2 packages and it’s time to complete the shared_state library. For this go to the shared_state folder and run:

yarn add react-context-slices

This is the library we need to manage state, either with Redux slices or React Context slices. Next open package.json of this folder and make it look like this (specifically the main key):

{
"name": "shared_state",
"version": "1.0.0",
"main": "./src/index.js",
"license": "MIT",
"dependencies": {
"react-context-slices": "^9.1.1"
}
}

Then create a src folder with an index.js file in it and a slices.js file in it with the following content each:

// shared_state/src/index.js
export * from "./slices";
// shared_state/src/slices.js
import getHookAndProviderFromSlices from "react-context-slices";

export const { useSlice, Provider } = getHookAndProviderFromSlices({
slices: {
count1: { initialArg: 0 },
count2: {
initialState: 0,
reducers: {
increment: (state) => state + 1,
},
},
},
});

This is how in react-context-slices you define slices. The first one is a React Context slice and the second one is a Redux slice.

And that’s it. Now you are ready to run the project. Go to the root folder (mfe) and run:

npm start

Open in the browser a tab pointing to localhost:3000 and you should see the app up and running. Interact with the buttons and see the values increase.

Additionally, you can build and serve the app to see if it works. Run from the root folder (mfe):

npm run build && npm run serve

And open localhost:3000 in the browser tab to see the app up and running.

Congratulations! Now you have a micro-frontend app with a shared state among the micro-frontend projects coexisting with a local shared state within each micro-frontend project, using react-context-slices for both.

--

--