DEV Community

Sarvesh Patil
Sarvesh Patil

Posted on

Microfrontends with React: A Complete Guide from Basics to Advanced

Microfrontends with React: What I Learned (and Why I Almost Ditched It)

I'm gonna be real with you - when I first heard about microfrontends, it sounded like the solution to literally everything. Independent teams! Deploy without fear! Different frameworks per module!

Spoiler: it's not magic. It's actually pretty complicated. But when it works, it's really good.

What's a Microfrontend Anyway?

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

The classic example: you have a header component built by the platform team, a product listing built by the commerce team, and a cart built by the checkout team. All deployed independently. All running at the same time on the same page.

That's a microfrontend setup.

Why I Started Using Them (The Real Reasons)

I was working on this e-commerce platform with like 5 different teams, and our main app became a nightmare. 200KB 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.

Microfrontends seemed like "okay, each team owns their 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 gave us this thing called Module Federation. It's basically a way to load code from different domains/ports at runtime instead of bundling everything upfront.

Here's what I did:

// 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" },
      },
    }),
  ],
};
Enter fullscreen mode Exit fullscreen mode

The important bits:

  • name: tells other apps "I'm the header app"
  • exposes: "I'm sharing this component"
  • shared with singleton: "use MY version of React if you don't have one, don't load React twice"

Then the main app says "I want header":

// Main shell app (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" },
  },
})
Enter fullscreen mode Exit fullscreen mode

And then you import it like:

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

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

export default function App() {
  return (
    <div>
      <Suspense fallback={<div>loading header...</div>}>
        <Header />
      </Suspense>
      <main>{/* rest of app */}</main>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

State Management: The Part That Bit Me

Here's where I messed up initially. I thought "oh I can just use 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 is in shell, but the remote module has its own React instance.

I burned like 3 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);
}, []);
Enter fullscreen mode Exit fullscreen mode

Honestly? This works for simple stuff. User logged in, user 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);
}, []);
Enter fullscreen mode Exit fullscreen mode

This is better. Each module is independent, they communicate via events. If one crashes, others don't care.

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 () => {
      // unsubscribe
      const cbs = this.listeners.get(key);
      cbs.splice(cbs.indexOf(callback), 1);
    };
  }

  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];
  }
}

export default new StateService();
Enter fullscreen mode Exit fullscreen mode

Then in your modules:

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

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

// 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(() => {
    return stateService.subscribe("user", setUser);
  }, []);

  return <header>Hello {user?.name}</header>;
}
Enter fullscreen mode Exit fullscreen mode

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 in shared, now you've got both in production. Your bundle suddenly jumped 200KB.

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: () => <div>Header unavailable</div> };
  })
);
Enter fullscreen mode Exit fullscreen mode

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. Debugging is annoying because code's spread across tabs.

We use docker-compose for local dev now.

When Microfrontends 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 (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 it now because we have to, and it works. But if I could go back, I'd probably keep the main app as a monolith and only use microfrontends for truly independent features (like admin dashboard as a separate app).

Real Gotchas I Hit

  1. CSS collisions - module A sets body { font-size: 16px }, module B expects 18px. Use CSS modules or shadow DOM
  2. Bundle duplication - forgot to mark React as shared, loaded it 3 times. Page was 500KB instead of 200KB
  3. CORS issues - remoteEntry.js wasn't being served with right headers, everything failed silently
  4. State out of sync - one module cached user data, other got new data, confusion everywhere

What I'd Do Differently

If I were starting over, I'd:

  1. Keep it monolithic until you genuinely need microfrontends
  2. Start with just 2-3 remotes, not 10
  3. Have a shared library for state service from day one
  4. Use feature flags to gradually roll out each module
  5. Have ONE person 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

Microfrontends 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.

Top comments (0)