Microfrontends with React: A Complete Guide from Basics to Advanced

Published: (December 16, 2025 at 12:55 AM EST)
7 min read
Source: Dev.to

Source: Dev.to

What’s a Microfrontend Anyway?

Think of it like this – instead of one massive React app that everyone commits to, you split it into smaller React apps that live separately and talk to each other.

Classic example:

  • a header component built by the platform team
  • a product listing built by the commerce team
  • a cart built by the checkout team

All deployed independently, all running at the same time on the same page. That’s a micro‑frontend setup.

Why I Started Using Them (The Real Reasons)

I was working on an e‑commerce platform with about five different teams, and our main app became a nightmare:

  • 200 KB bundle just for vendor‑dashboard stuff that 90 % of users never saw.
  • A change to the header meant rebuilding everything.
  • Deploy conflicts happened every day.

Micro‑frontends seemed like “okay, each team owns its domain, they ship code independently, no more conflicts.”
That part actually works—when you set it up right.

How Module Federation Actually Works

Webpack 5 introduced Module Federation, a way to load code from different domains/ports at runtime instead of bundling everything up front.

Header app (runs on localhost:3001)

// webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;

module.exports = {
  mode: "production",
  entry: "./src/index",
  output: {
    path: __dirname + "/dist",
    filename: "[name].[contenthash].js",
  },
  devServer: {
    port: 3001,
    headers: {
      "Access-Control-Allow-Origin": "*",
    },
  },
  plugins: [
    new ModuleFederationPlugin({
      name: "header",
      filename: "remoteEntry.js",
      exposes: {
        "./Header": "./src/Header",
      },
      shared: {
        react: { singleton: true, requiredVersion: "^18.0.0" },
        "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
      },
    }),
  ],
};

Important bits

PropertyMeaning
nameTells other apps “I’m the header app”.
exposesDeclares what this app shares (the Header component).
shared (with singleton)“Use my version of React if you don’t have one; don’t load React twice.”

Main shell app (runs on localhost:3000)

new ModuleFederationPlugin({
  name: "mainApp",
  remotes: {
    header: "header@http://localhost:3001/remoteEntry.js",
  },
  shared: {
    react: { singleton: true, requiredVersion: "^18.0.0" },
    "react-dom": { singleton: true, requiredVersion: "^18.0.0" },
  },
});

Consuming the remote component

import React, { lazy, Suspense } from "react";

const Header = lazy(() => import("header/Header"));

export default function App() {
  return (
    <Suspense fallback={<div>loading header…</div>}>
      <Header />
      {/* rest of app */}
    </Suspense>
  );
}

This works surprisingly well. The header app loads independently, React only loads once, and everything plays nice.

State Management: The Part That Bit Me

Here’s where I messed up initially. I thought, “I can just use the Context API across modules!”

Nope. Context only works within a single React tree. When you lazy‑load a component from a different webpack bundle, it’s technically a different React root. Your context provider lives in the shell, but the remote module has its own React instance. I burned about three days figuring that out.

What actually works

Option 1 – URL + localStorage (Simple approach)

// When user logs in in the auth module
const user = { id: 123, name: "Alice" };
localStorage.setItem("user", JSON.stringify(user));
window.location.hash = "#user-logged-in";

// In other modules, listen to storage events
useEffect(() => {
  const handleStorageChange = (e) => {
    if (e.key === "user") {
      const newUser = JSON.parse(e.newValue);
      setUser(newUser);
    }
  };
  window.addEventListener("storage", handleStorageChange);
  return () => window.removeEventListener("storage", handleStorageChange);
}, []);

Honestly? This works for simple stuff (user login, preferences, basic state). Not great for complex state.

Option 2 – Window Events (Better)

// Auth module emits an event
function handleLogin(user) {
  const event = new CustomEvent("app:user-login", { detail: user });
  window.dispatchEvent(event);
}

// Header module listens
useEffect(() => {
  function handleUserLogin(e) {
    setUser(e.detail);
  }
  window.addEventListener("app:user-login", handleUserLogin);
  return () => window.removeEventListener("app:user-login", handleUserLogin);
}, []);

Each module stays independent and communicates via events. If one crashes, the others keep running.

Option 3 – Shared State Service (What We Do Now)

// shared-state-service.js – tiny module both import
class StateService {
  constructor() {
    this.listeners = new Map();
    this.state = {
      user: null,
      theme: "light",
      cart: [],
    };
  }

  subscribe(key, callback) {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, []);
    }
    this.listeners.get(key).push(callback);

    // Return an unsubscribe function
    return () => {
      const cbs = this.listeners.get(key);
      const idx = cbs.indexOf(callback);
      if (idx > -1) cbs.splice(idx, 1);
    };
  }

  setState(key, value) {
    this.state[key] = value;
    const cbs = this.listeners.get(key) || [];
    cbs.forEach((cb) => cb(value));
  }

  getState(key) {
    return this.state[key];
  }
}

// Export a singleton
export const stateService = new StateService();

Usage in any micro‑frontend

import { stateService } from "./shared-state-service";

// Update
stateService.setState("user", { id: 1, name: "Bob" });

// Subscribe
useEffect(() => {
  const unsubscribe = stateService.subscribe("user", setUser);
  return unsubscribe;
}, []);

This gives us a lightweight, framework‑agnostic way to share state without pulling in a full‑blown store.

TL;DR

  • Micro‑frontends let independent teams ship code without stepping on each other’s toes, but they add runtime complexity.
  • Webpack 5’s Module Federation makes it possible to load separate bundles on the fly while sharing a single React instance.
  • State sharing can’t rely on React Context alone; pick a strategy that fits your app’s complexity (localStorage + events, custom window events, or a tiny shared‑state service).

When set up correctly, the payoff is huge: faster builds, independent deployments, and a healthier codebase. Just be prepared for the extra plumbing. Happy federating!

// shared/state-service.js
class StateService {
  constructor() {
    this.state = {};
    this.listeners = new Map();
  }

  setState(key, value) {
    this.state[key] = value;
    if (this.listeners.has(key)) {
      this.listeners.get(key).forEach((cb) => cb(value));
    }
  }

  getState(key) {
    return this.state[key];
  }

  subscribe(key, cb) {
    if (!this.listeners.has(key)) {
      this.listeners.set(key, new Set());
    }
    this.listeners.get(key).add(cb);
    // Return an unsubscribe function
    return () => this.listeners.get(key).delete(cb);
  }
}

export default new StateService();

Using the service in your modules

Auth module

// Auth module
import stateService from "shared/state-service";

function handleLogin(user) {
  stateService.setState("user", user);
}

Header module

// Header module
import stateService from "shared/state-service";
import { useEffect, useState } from "react";

export default function Header() {
  const [user, setUser] = useState(stateService.getState("user"));

  useEffect(() => {
    // Subscribe to changes and clean up on unmount
    return stateService.subscribe("user", setUser);
  }, []);

  return <>Hello {user?.name}</>;
}

This is what we use now. Tiny, works reliably, each module stays independent.

The Stuff Nobody Talks About

1. Version Conflicts Are Real

You’ll have situations where module A needs lodash@4 but module B needs lodash@3. They’re both in shared, so you end up with both versions in production and your bundle suddenly jumps ~200 KB.

What we do: Lock versions at the shell level. All remotes must use the versions we specify.

2. Error Handling Is Painful

const Header = lazy(() =>
  import("header/Header").catch((err) => {
    console.error("header load failed", err);
    // Return fallback component
    return { default: () => <>Header unavailable</> };
  })
);

If the header app is down in production, your entire page doesn’t break—but you need to handle this explicitly.

3. Local Development Gets Messy

Running header on 3001, sidebar on 3002, main app on 3000? You need a script that starts all three. DevTools become confusing, and debugging is annoying because code is spread across tabs.

We now use docker‑compose for local development.

When Micro‑frontends Actually Make Sense

  • Multiple independent teams that deploy on different schedules
  • Large apps where different domains truly don’t talk to each other much
  • Different performance requirements (e.g., cart is performance‑critical, admin dashboard isn’t)

When They’re Overkill

  • Small team, single app
  • Heavy cross‑domain state sharing
  • When you actually like your monolith

Honestly? We use them now because we have to, and it works. If I could go back, I’d probably keep the main app monolithic and only use micro‑frontends for truly independent features (like an admin dashboard as a separate app).

Real Gotchas I Hit

  • CSS collisions – module A sets body { font-size: 16px }, module B expects 18px. Use CSS modules or Shadow DOM.
  • Bundle duplication – forgot to mark React as shared, loaded it three times. Page ballooned to 500 KB instead of 200 KB.
  • CORS issuesremoteEntry.js wasn’t served with the right headers; everything failed silently.
  • State out of sync – one module cached user data, another got new data → confusion everywhere.

What I’d Do Differently

  • Keep it monolithic until you genuinely need micro‑frontends.
  • Start with just 2–3 remotes, not 10.
  • Have a shared library for the state service from day one.
  • Use feature flags to gradually roll out each module.
  • Assign ONE person to own the shell/orchestration code.

Resources That Actually Helped

  • Module Federation docs are decent.
  • Zack Jackson’s talks are gold.
  • Honestly, your own code is the best teacher.

Micro‑frontends aren’t the future for everyone. They’re a tool that solves specific problems really well. Just don’t use them because they sound cool. Learned that the hard way.

Back to Blog

Related posts

Read more »

Preferred Web Tech Stacks in Australia

Why Tech Stack Selection Matters in Australia Australian businesses prioritize quality, security, and performance. Websites are expected to work seamlessly acr...