Home / Frontend / Micro Frontend: Tách App React Lớn Với Module Federation
Frontend

Micro Frontend: Tách App React Lớn Với Module Federation

Tìm hiểu micro frontend với Module Federation: host và remote React, shared dependencies, chia sẻ auth state runtime

Một codebase React khổng lồ, năm team cùng đẩy commit vào đó, và mỗi lần release là một lần nín thở: chỉ cần một team làm vỡ build là cả công ty kẹt deploy. Ai cũng muốn tách nhỏ để deploy độc lập, nhưng chia sao cho các app vẫn share được React và vẫn nói chuyện được với nhau?

Bài này build một hệ micro frontend thật với Module Federation: một host lazy-load một remote, share React như một singleton, rồi fix một bug kinh điển mà gần như ai làm micro frontend cũng dính — React Context không vượt qua được ranh giới giữa hai app.

Source code: github.com/jsiovn/micro-frontend — clone về chạy song song khi đọc bài.

1. Micro frontend là gì và khi nào cần

Micro frontend là ý tưởng áp dụng cách chia service của backend cho phía frontend: thay vì một Single-Page App khổng lồ, bạn tách UI thành nhiều app nhỏ, mỗi app do một team sở hữu, build và deploy độc lập, rồi ghép lại thành một trang duy nhất ở runtime.

Điểm mấu chốt nằm ở hai chữ runtime. Bạn không copy code của nhau lúc build, cũng không publish npm package rồi chờ nhau bump version. Host tải code của remote qua HTTP đúng lúc người dùng cần tới màn hình đó — remote deploy version mới, host thấy ngay ở lần load sau mà không cần build lại.

Monolith SPAMicro frontend
DeployCả app trong một lầnTừng app độc lập
OwnershipNhiều team chung một buildMỗi team một app
Blast radius khi build lỗiToàn bộ appChỉ app bị lỗi
Tech stackMột stack chungCó thể khác nhau theo app
Chi phí vận hànhThấpCao — versioning, orchestration

Repo trong bài có đúng hai app: một host chạy ở :3000 giữ phần shell (layout, nav, auth), và một remote analytics chạy ở :3001 expose một dashboard. Host tải remote qua một file manifest, và cả hai dùng chung một bản React.

View Mermaid diagram code
flowchart LR
    User([Người dùng]) --> Host["Host :3000<br/>shell + auth"]
    Host -->|"fetch mf-manifest.json"| Remote["Remote :3001<br/>analytics dashboard"]
    Host -.->|"dùng chung"| Shared["react, react-dom<br/>@jsiovn/shared"]
    Remote -.->|"dùng chung"| Shared

    style User fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style Host fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style Remote fill:#241832,stroke:#c08cff,stroke-width:1.5px,color:#d4b3ff
    style Shared fill:#1a2c08,stroke:#c7ff50,stroke-width:1.5px,color:#c7ff50

Micro frontend giải một bài toán về tổ chức, không phải về kỹ thuật. Phần cuối bài sẽ nói rõ khi nào nên tránh — trước hết hãy build cái đã.

2. Setup: Bun workspaces và hai app Rsbuild

Repo là một Bun workspace với bốn package. Host và remote là hai app Rsbuild riêng; sharedmocks là package nội bộ dùng chung.

1
2
3
4
5
micro-frontend/
├── host/                # MF host, chạy ở :3000 — shell + auth
├── remote-analytics/    # MF remote, chạy ở :3001 — analytics dashboard
├── shared/              # @jsiovn/shared — types dùng chung (auth store thêm ở mục 6)
└── mocks/               # @jsiovn/mocks — MSW handlers cho mock API

Root package.json khai báo workspace và dùng Turborepo để chạy hai app song song:

1
2
3
4
5
6
7
8
9
10
{
  "name": "micro-frontend",
  "private": true,
  "packageManager": "bun@1.3.11",
  "workspaces": ["host", "remote-analytics", "shared", "mocks"],
  "scripts": {
    "dev": "turbo run dev",
    "build": "turbo run build"
  }
}

Quickstart chỉ có vài lệnh — cần Node ^20.19 hoặc >= 22.12 (repo pin 22 trong .nvmrc) và Bun ≥ 1.3:

1
2
3
4
git clone https://github.com/jsiovn/micro-frontend.git
cd micro-frontend
bun install
bun run dev   # host :3000 + remote :3001 chạy cùng lúc qua Turborepo

Mở :3000 là host với remote nhúng bên trong; mở :3001 là remote chạy standalone với mock data của riêng nó. Chạy được độc lập là một tính chất quan trọng của remote: nó là một app hoàn chỉnh, không phải một mảnh code chỉ sống nhờ host.

3. Cấu hình Module Federation cho host và remote

Module Federation chia vai rõ ràng: remote expose một hoặc nhiều module, host khai báo remote đó như một remote rồi import module như bình thường. Cả hai phía cấu hình qua @module-federation/rsbuild-plugin.

3.1. Remote expose một component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { defineConfig } from "@rsbuild/core";
import { pluginReact } from "@rsbuild/plugin-react";
import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";

export default defineConfig({
  server: { port: 3001 },
  plugins: [
    pluginReact(),
    pluginModuleFederation({
      name: "remoteAnalytics",
      dts: false,
      exposes: {
        "./analytics-dashboard": "./src/analytics-dashboard",
      },
      shared: {
        react: { singleton: true, eager: true },
        "react-dom": { singleton: true, eager: true },
        nanostores: { singleton: true, eager: true },
        "@nanostores/react": { singleton: true, eager: true },
        "@jsiovn/shared": { singleton: true, eager: true },
      },
    }),
  ],
});

exposes map một tên public (./analytics-dashboard) sang file thật (./src/analytics-dashboard). Khi build, plugin sinh ra mf-manifest.json:3001 — một file JSON mô tả module nào được expose và cần những shared dependency gì. Đây là hợp đồng runtime giữa remote và host.

3.2. Host khai báo remote

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pluginModuleFederation({
  name: "host",
  dts: false,
  remotes: {
    remoteAnalytics: "remoteAnalytics@http://localhost:3001/mf-manifest.json",
  },
  shared: {
    react: { singleton: true, eager: true },
    "react-dom": { singleton: true, eager: true },
    nanostores: { singleton: true, eager: true },
    "@nanostores/react": { singleton: true, eager: true },
    "@jsiovn/shared": { singleton: true, eager: true },
  },
});

remotes trỏ tới đúng file manifest của remote, gắn với alias remoteAnalytics. Từ giờ trong host, bất kỳ chỗ nào import("remoteAnalytics/analytics-dashboard") cũng sẽ được Module Federation dịch thành: fetch manifest, tải chunk tương ứng từ :3001, rồi trả về module.

3.3. Lazy-load remote bằng React.lazy

1
2
3
4
5
6
7
8
9
10
11
12
13
import React, { Suspense } from "react";

const AnalyticsDashboard = React.lazy(
  () => import("remoteAnalytics/analytics-dashboard"),
);

export function App(): React.ReactElement {
  return (
    <Suspense fallback={<div>Loading analytics...</div>}>
      <AnalyticsDashboard />
    </Suspense>
  );
}

import("remoteAnalytics/analytics-dashboard") không phải import một file trong repo — nó là một HTTP request tới remote lúc runtime. Vì là async, ta bọc bằng React.lazy + Suspense: React hiển thị fallback trong lúc chunk của remote đang tải, rồi render dashboard khi có.

Mặc định dts: true: Module Federation tự sinh type cho module được expose ở phía remote, host tải về và giải nén thành thư mục @mf-types/. Repo demo tắt tính năng này (dts: false ở cả hai config phía trên), nên TypeScript không biết remoteAnalytics/... là gì — module đó chỉ tồn tại ở runtime, không có trên đĩa lúc compile. Ta khai báo type thủ công cho nó:

1
2
3
4
5
6
declare module "remoteAnalytics/analytics-dashboard" {
  import type React from "react";

  const AnalyticsDashboard: React.ComponentType;
  export default AnalyticsDashboard;
}

3.4. Async boundary: import("./bootstrap")

Entry point của host chỉ có đúng một dòng, và đây là chi tiết dễ bị bỏ sót nhất:

1
import("./bootstrap");

Toàn bộ code khởi động thật — createRoot, render, start MSW — nằm trong bootstrap.tsx. File entry chỉ dynamic-import nó. Dòng này tạo ra một async boundary — một trong hai cách đáp ứng yêu cầu async của Module Federation; cách còn lại là bật eager: true cho shared dependency.

Lưu ý

Module Federation cần một khoảnh khắc async ngay đầu vòng đời app để negotiate version của các shared module trước khi có code nào import chúng một cách đồng bộ. Nếu bạn import React thẳng ở entry (không qua async boundary) mà cũng không bật eager, Federation runtime báo lỗi:

1
2
3
4
5
loadShareSync failed! The function should not be called unless you set "eager:true". If you do not set it, and encounter this issue, you can check whether an async boundary is implemented.
The original error message is as follows:
Uncaught Error: [ Federation Runtime ]: Invalid loadShareSync function call from runtime #RUNTIME-006
args: {"hostName":"host","sharedPkgName":"react"}
View the docs to see how to solve: https://module-federation.io/guide/troubleshooting/runtime#runtime-006

Tài liệu Module Federation xếp tình huống này vào mã RUNTIME-005, nhưng runtime 2.8.0 in ra RUNTIME-006: bundler gọi loadShareSync mà không truyền cờ from: "build", và nhánh xử lý lỗi chọn mã còn lại. Nguyên nhân thì như nhau — một shared dependency là async module nhưng entry chưa async và eager chưa được bật. Có hai cách xử lý: hoặc dùng async boundary như trên, hoặc đặt eager: true cho shared dependency. Repo này làm cả hai — eager: true cho toàn bộ shared entry và vẫn giữ async boundary — để entry luôn an toàn dù bạn có lỡ import shared module sớm.

4. Shared dependencies: một React duy nhất

Nếu host tải bản React của nó và remote tải bản React của nó, bạn có hai instance React trong cùng một trang. Mỗi bản React giữ hooks dispatcher trong một biến module-level của riêng nó, và react-dom chỉ set dispatcher đó lên đúng bản React mà nó import. Nên khi host render một component của remote được build bằng bản React thứ hai, useState trong component đó đọc phải dispatcher null — React báo lỗi “Invalid hook call” và app crash ngay tại đó. Đó là lý do khối shared tồn tại.

singleton: true nói với Module Federation: trên toàn trang chỉ được có đúng một instance của module này. Khi hai app khai báo version khác nhau, shareStrategy mặc định (version-first) chọn version cao nhất trong số các bản đã đăng ký làm instance duy nhất; app còn lại dùng chung bản đó thay vì tải bản thứ hai. Đổi sang shareStrategy: "loaded-first" thì bản được load trước mới thắng. eager: true gói module thẳng vào bundle khởi động thay vì tách ra một chunk async riêng, nhờ vậy React sẵn sàng ngay khi app boot — đánh đổi lại là entry file lớn hơn một chút.

4.1. Version mismatch: khi singleton từ chối

Chỉ share thôi chưa đủ; hai bên còn phải đồng ý về version. Thử một thí nghiệm nhỏ: bắt remote yêu cầu React 18 trong khi cả hệ đang chạy React 19, và bật strictVersion:

1
2
3
4
5
6
7
8
9
// remote-analytics/rsbuild.config.ts — chỉ để thử nghiệm
shared: {
  react: {
    singleton: true,
    eager: true,
    requiredVersion: "^18.0.0",
    strictVersion: true,
  },
},

Mở lại :3001, trang trắng, và Federation runtime ném lỗi ngay khi khởi động:

1
Uncaught Error: [ Federation Runtime ]: Version 19.2.7 from remoteAnalytics of shared singleton module react does not satisfy the requirement of remoteAnalytics which needs ^18.0.0)

Dấu ) thừa ở cuối không phải lỗi gõ — đó là một quirk có thật trong source của Module Federation runtime, và bài này giữ nguyên đúng những gì browser console in ra.

strictVersion: true biến version mismatch từ warning thành error chặn hẳn app. Bỏ strictVersion đi thì Federation chỉ log một warning và vẫn chạy với một bản React duy nhất — nhưng nếu remote thật sự cần một API chỉ có ở React 18, bạn chỉ dời lỗi sang chỗ khác chứ không xóa được nó. Cách đúng không phải là nới lỏng version check, mà là giữ mọi app trong hệ ở cùng một version React. Xong thí nghiệm, xóa requiredVersionstrictVersion khỏi remote-analytics/rsbuild.config.ts trước khi đi tiếp — giữ lại thì remote crash ngay lúc boot và phần còn lại của bài không chạy được.

5. Bug kinh điển: React Context không vượt qua federation boundary

Đây là bug mà gần như ai mới làm micro frontend cũng dính. Host có một AuthProvider bọc toàn bộ app và cung cấp thông tin user đăng nhập qua React Context. Remote render bên trong cây component đó. Theo phản xạ React thông thường, remote chỉ cần useContext(AuthContext) là đọc được user — nhưng nó luôn thấy null.

Trên nhánh main của repo, host đã đăng nhập thành công với user “Grace Hopper”, nhưng dashboard của remote vẫn hiện badge vàng “Not authenticated”.

5.1. Host có auth context đầy đủ

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
import React, { createContext, useContext, useEffect, useState } from "react";
import type { User, AuthContext as AuthContextType } from "@jsiovn/shared";

const AuthContext = createContext<AuthContextType>({
  user: null,
  isAuthenticated: false,
  token: null,
});

export function useAuth(): AuthContextType {
  return useContext(AuthContext);
}

export function AuthProvider({
  children,
}: {
  children: React.ReactNode;
}): React.ReactElement {
  const [user, setUser] = useState<User | null>(null);
  const [token, setToken] = useState<string | null>(null);

  useEffect(() => {
    async function fetchCurrentUser(): Promise<void> {
      try {
        const response = await fetch("/api/users/me");
        const data: User = await response.json();
        setUser(data);
        setToken("mock-jwt-token-" + data.id);
      } catch {
        // lược phần xử lý lỗi fetch cho gọn
      }
    }

    fetchCurrentUser();
  }, []);

  const value: AuthContextType = {
    user,
    isAuthenticated: user !== null,
    token,
  };

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

Trong host, mọi thứ hoạt động đúng: useAuth() ở bất kỳ component nào của host đều đọc được user. Vấn đề chỉ lộ ra khi qua federation boundary sang remote.

5.2. Remote không đọc được, và tại sao

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
export function AnalyticsDashboard(): React.ReactElement {
  // THE BUG: component này không truy cập được auth context của host.
  // Nó không đọc được user hiện tại hay token.
  // Trên nhánh main, đây là lỗi cố ý để làm bài tập.
  const isAuthenticated = false;
  const userName: string | null = null;

  return (
    <div className="flex items-center gap-3">
      {isAuthenticated ? (
        <span>Viewing as: {userName}</span>
      ) : (
        <span>Not authenticated</span>
      )}
    </div>
  );
}

export default AnalyticsDashboard;

Tại sao remote không thể useContext(AuthContext) của host? Vì AuthContext được tạo bởi createContext() bên trong bundle của host, và nó không nằm trong danh sách shared cũng không được host expose. Remote là một bundle được build hoàn toàn độc lập; nó không có tham chiếu nào tới object context đó.

Và kể cả khi remote tự import { AuthContext } từ một chỗ nào đó chung, nếu module định nghĩa context không phải singleton, remote sẽ nhận về một object context khác — cùng tên biến, nhưng khác identity. React Context phân biệt provider và consumer bằng cách so sánh identity của object context (theo tham chiếu ===), chứ không so tên. Provider của host và useContext của remote đang nói về hai object khác nhau, nên remote không bao giờ nhận được value host set.

View Mermaid diagram code
flowchart TD
    Provider["Host: AuthContext.Provider<br/>set user = Grace"] --> HostCtx["Context object A<br/>(bundle host)"]
    Consumer["Remote: useContext(AuthContext)"] --> RemoteCtx["Context object B<br/>(bundle remote)"]
    HostCtx -. "A !== B → không khớp" .-> RemoteCtx

    style Provider fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style HostCtx fill:#0e2233,stroke:#4aa8ff,stroke-width:1.5px,color:#9ecbff
    style Consumer fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3
    style RemoteCtx fill:#2a1517,stroke:#ff6b6b,stroke-width:1.5px,color:#ffb3b3

Nói cách khác: React Context chỉ hoạt động trong phạm vi một module graph. Host và remote được build thành hai module graph riêng, nên object context của host không tồn tại trong module graph của remote.

6. Fix: shared store với nanostores

Vì object AuthContext chỉ tồn tại trong bundle của host, cách sửa là đưa auth state ra một nơi mà cả hai bundle cùng trỏ tới đúng một object. Đó chính xác là công dụng của shared singleton. Ta đặt một store nanostores trong package @jsiovn/shared — package đã được khai báo singleton: true ở cả hai config — nên host và remote thao tác trên đúng một atom.

Nanostores hợp với việc này vì atom của nó là một object thuần với .get() / .set(), không phụ thuộc React Context, nên nó không dính vào bài toán identity của context ở trên.

6.1. Một atom trong shared package

1
2
3
4
5
6
7
8
import { atom } from "nanostores";
import type { AuthContext } from "./types";

export const authStore = atom<AuthContext>({
  user: null,
  isAuthenticated: false,
  token: null,
});

Rồi export nó ra khỏi barrel của package:

1
2
3
export * from "./types";
export * from "./auth";
export * from "./auth-store";

6.2. Host ghi vào store

Trong auth-provider.tsx, sau khi fetch user xong, host set thẳng vào store dùng chung:

1
2
3
4
5
6
7
8
9
10
import { authStore } from "@jsiovn/shared";

// ... bên trong fetchCurrentUser, ngay sau khi set state của host:
setUser(data);
setToken("mock-jwt-token-" + data.id);
authStore.set({
  user: data,
  isAuthenticated: true,
  token: "mock-jwt-token-" + data.id,
});

6.3. Remote đọc từ store

Remote thay khối // THE BUG bằng một dòng subscribe vào atom:

1
2
3
4
5
6
7
8
9
10
import { useStore } from "@nanostores/react";
import { authStore } from "@jsiovn/shared";

export function AnalyticsDashboard(): React.ReactElement {
  const auth = useStore(authStore);
  const isAuthenticated = auth.isAuthenticated;
  const userName = auth.user?.name ?? null;

  // ... phần render giữ nguyên: badge giờ hiện "Viewing as: {userName}"
}

useStore subscribe component vào atom; mỗi khi host gọi authStore.set(...), remote re-render với value mới. Badge chuyển từ vàng “Not authenticated” sang xanh “Viewing as: Grace Hopper”.

Vì sao lần này state đi qua được federation boundary? Vì @jsiovn/shared là singleton, module auth-store.ts chỉ được evaluate đúng một lần trên toàn trang. Cả host và remote khi import { authStore } đều nhận về đúng một atom object — cùng identity, cùng vùng nhớ. Host .set(...) và remote useStore(...) thao tác trên chung một store, nên đây không còn là hai bản dữ liệu cần đồng bộ, mà thật sự là một.

Lưu ý

Singleton ở đây là bắt buộc, không phải tùy chọn. Năm entry trong sharedreact, react-dom, nanostores, @nanostores/react, @jsiovn/shared — phải giống hệt nhau ở cả hai file rsbuild.config.ts. Nếu một bên quên @jsiovn/shared hoặc để singleton: false, mỗi app sẽ có bản authStore riêng, host ghi một nơi và remote đọc một nơi, và bug quay lại y hệt — nhưng lần này khó debug hơn nhiều vì mọi thứ trông như đã đúng.

7. Khi nào KHÔNG nên dùng micro frontend

Nếu bạn không có nhiều team giẫm chân nhau trên cùng một codebase, cái giá vận hành của micro frontend thường lớn hơn lợi ích:

  • Nhiều bundle cùng chạy: dù share singleton, vẫn có overhead của việc negotiate version và fetch manifest ở runtime.
  • Version lock-step: shared singleton buộc mọi app đồng ý về version React và react-dom. Nâng React nghĩa là nâng cả hệ cùng lúc — đúng thứ mà micro frontend hứa cho bạn tránh.
  • Debug xuyên boundary khó hơn: bug context ở trên là ví dụ điển hình — lỗi không nằm trong một app, mà ở federation boundary giữa hai app, nơi không stack trace nào chỉ thẳng vào.
  • Hạ tầng nặng hơn: mỗi remote cần deploy riêng, có URL riêng, và một chiến lược versioning cho manifest.

Cân nhắc micro frontend khi:

  1. Có từ ba, bốn team trở lên cần deploy độc lập trên cùng một sản phẩm.
  2. App đủ lớn để ranh giới giữa các team ánh xạ rõ ràng sang ranh giới UI.
  3. Bạn chấp nhận trả chi phí hạ tầng và version lock-step để đổi lấy quyền deploy độc lập.

Ngược lại, nếu chỉ có một team hoặc app còn nhỏ, một SPA với code-splitting bằng React.lazy cho bạn phần lớn lợi ích chia nhỏ bundle mà không kèm chi phí runtime negotiation.

Bạn vừa build một hệ micro frontend hoàn chỉnh: host lazy-load remote qua Module Federation, share React như một singleton, và fix được bug context kinh điển bằng một shared store. Điểm cốt lõi để nhớ: state không tự vượt qua federation boundary — bất cứ thứ gì cần dùng chung phải nằm trong một shared singleton mà cả hai bên cùng trỏ tới đúng một object.

Bước tiếp theo: clone repo, chạy git checkout solution để xem bản fix đầy đủ hoạt động thật, rồi thử một stretch goal — thay nanostores bằng BroadcastChannel để đồng bộ auth state giữa nhiều tab của cùng một app. Chúc bạn tách app thành công nhé!

Table of Contents
  1. 1. Micro frontend là gì và khi nào cần
  2. 2. Setup: Bun workspaces và hai app Rsbuild
  3. 3. Cấu hình Module Federation cho host và remote
    1. 3.1. Remote expose một component
    2. 3.2. Host khai báo remote
    3. 3.3. Lazy-load remote bằng React.lazy
    4. 3.4. Async boundary: import("./bootstrap")
  4. 4. Shared dependencies: một React duy nhất
    1. 4.1. Version mismatch: khi singleton từ chối
  5. 5. Bug kinh điển: React Context không vượt qua federation boundary
    1. 5.1. Host có auth context đầy đủ
    2. 5.2. Remote không đọc được, và tại sao
  6. 6. Fix: shared store với nanostores
    1. 6.1. Một atom trong shared package
    2. 6.2. Host ghi vào store
    3. 6.3. Remote đọc từ store
  7. 7. Khi nào KHÔNG nên dùng micro frontend