Sharing Singletons Across Webpack and Vite Remotes #

When a webpack host loads a Vite-built remote, React (and any store library) must resolve to exactly one runtime instance — otherwise hooks, context, and the scheduler silently break with Invalid hook call and duplicate-React errors.

This guide walks through aligning the shared configuration on both sides of a mixed-toolchain setup so the two bundlers negotiate a single shared scope and consume one React.

The Problem in One Sentence #

A webpack host and a Vite remote each ship their own React unless their shared declarations agree on the package name, share scope, and version range — and the moment two Reacts load, every hook in the remote throws.

Prerequisites #

This guide assumes a mixed federation topology. Pin these versions before you start:

The two plugins implement the same share-scope protocol but spell it differently. The diagram below shows the shape you are building toward: one shared scope named default, one React entry, two consumers.

One React singleton in the default share scope A central shared scope box holds a single React instance; a webpack host and a Vite remote both consume it instead of loading their own copies. Share scope: 'default' react (singleton) v18.3.1 Webpack host provides + consumes Vite remote consumes only Both apps register the same name + version into one scope, so the runtime hands back a single React for everyone.
One shared scope, one React: the host provides it, the Vite remote consumes it, hooks stay valid.

Step 1: Declare React as a Singleton on the Webpack Host #

On the host, mark react and react-dom as singletons with a requiredVersion that matches your installed version. The host both provides and consumes from the scope.

// host/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        // Vite remote's exposed entry; see step 2 for the filename.
        catalog: 'catalog@https://catalog.example.com/assets/remoteEntry.js',
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: deps.react,   // e.g. "18.3.1"
          eager: false,
        },
        'react-dom': {
          singleton: true,
          requiredVersion: deps['react-dom'],
          eager: false,
        },
      },
    }),
  ],
};

singleton: true is what makes the runtime hand back one instance instead of loading a second copy. Keep eager: false unless the host renders React before any async chunk loads (see Step 4).

Step 2: Mirror the Shared Config in the Vite Remote #

The Vite plugin accepts the same intent through its shared field. With @originjs/vite-plugin-federation, pass an object so you can set singleton and requiredVersion per package.

// remote/vite.config.js
import { defineConfig } from 'vite';
import federation from '@originjs/vite-plugin-federation';
import pkg from './package.json' assert { type: 'json' };

export default defineConfig({
  plugins: [
    federation({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        './ProductList': './src/ProductList.tsx',
      },
      shared: {
        react: { singleton: true, requiredVersion: pkg.dependencies.react },
        'react-dom': { singleton: true, requiredVersion: pkg.dependencies['react-dom'] },
      },
    }),
  ],
  build: {
    target: 'esnext',     // top-level await is required by the federation runtime
    minify: false,        // keep readable while debugging singleton issues
    cssCodeSplit: false,
  },
});

If you use @module-federation/vite instead, the field is identical — it implements the upstream Module Federation runtime, so the share-scope semantics match webpack exactly. For a side-by-side of how the two plugins map onto webpack’s API, see Vite Federation Plugin Parity with Webpack.

Step 3: Confirm the Share Scope Name Matches #

Both plugins default the share scope to 'default', and they must use the same name or the host and remote register into separate scopes and never see each other’s React.

You rarely need to change it, but if the host customizes the scope, the remote must follow.

// host: only set this if you have a reason to; 'default' is the default
new ModuleFederationPlugin({
  name: 'host',
  shareScope: 'default',
  shared: { /* ... */ },
});
// remote (@module-federation/vite): match it exactly
federation({
  name: 'catalog',
  shareScope: 'default',
  shared: { /* ... */ },
});

A mismatched scope name is the most common silent cause of two Reacts: every other setting looks correct, but the negotiation never happens.

Step 4: Set Eager vs Lazy Correctly #

eager: true inlines the dependency into the initial chunk so it is available synchronously. Use it only where it is required, and only consistently across the topology:

// host/src/index.js — async boundary lets eager shared deps resolve first
import('./bootstrap');
// host/src/bootstrap.js
import { createRoot } from 'react-dom/client';
import App from './App';

createRoot(document.getElementById('root')).render(<App />);

Step 5: Verify a Single React Instance at Runtime #

Add a one-time check that proves the host and the remote share the same React object — not just the same version string.

// shared/assertSingleton.ts
import * as React from 'react';

declare global {
  interface Window { __MFE_REACT__?: typeof React }
}

export function assertSingleReact(source: string): void {
  if (window.__MFE_REACT__ && window.__MFE_REACT__ !== React) {
    console.error(
      `[federation] duplicate React in ${source}: ` +
      `${window.__MFE_REACT__.version} vs ${React.version}`
    );
  } else {
    window.__MFE_REACT__ = React;
    console.info(`[federation] React ${React.version} from ${source}`);
  }
}

Call it from both the host entry and the remote’s exposed component. Identity (!==), not version equality, is the real test — two builds of 18.3.1 are still two Reacts and will still break hooks.

Verification #

Confirm the singleton actually took hold before shipping:

  1. One instance via console. Run the page, then in DevTools evaluate that the remote’s React identity equals the host’s. The assertSingleReact log should fire exactly twice with the same version and no duplicate error.
  2. One copy in the network tab. Filter the Network panel for react. You should see a single shared React chunk requested once, not one bundled into the host and another into the remote’s remoteEntry.
  3. Hooks work in the remote. Render the Vite remote’s useState/useEffect component inside the host. No Invalid hook call, and state updates re-render normally.
  4. Context crosses the boundary. Wrap the remote in a host-provided context provider and read it from inside the remote — it resolves only when both share one React. For deduplicating React across builds more broadly, see Avoiding Bundle Duplication.

Troubleshooting #

Two React instances / “Invalid hook call” #

Symptom: the remote’s hooks throw Invalid hook call or you see Warning: Invalid hook call only for remote components.

Diagnosis: the Vite remote bundled its own React. Often Vite’s dependency pre-bundling created a separate optimized copy, or the share scope names did not match (Step 3).

Fix: ensure singleton: true is set on both sides for react and react-dom, confirm the scope name, and exclude React from Vite’s optimizer so it does not pre-bundle a private copy:

// remote/vite.config.js
export default defineConfig({
  optimizeDeps: { exclude: ['react', 'react-dom'] },
});

requiredVersion mismatch warnings #

Symptom: console warns Unsatisfied version 18.2.0 from host of shared singleton module react (required ^18.3.0) and the runtime falls back to a second copy.

Diagnosis: the host and remote resolved different React versions, and the range is too narrow to accept both.

Fix: align the installed versions first. If a temporary gap is unavoidable, widen requiredVersion to the shared major (^18.0.0) so the singleton negotiation succeeds, and keep singleton: true so the runtime still collapses to one instance rather than loading both. The full version-conflict playbook lives in Resolving Version Conflicts in Shared React Libraries.

Vite externalizes React differently than expected #

Symptom: the remote builds fine standalone but ships a full React inside remoteEntry.js, or fails to start because React is undefined at runtime.

Diagnosis: with @originjs/vite-plugin-federation, a package listed in shared is treated as external; if the host never provides it, the remote has nothing to fall back to. Conversely, a package omitted from shared gets fully bundled.

Fix: declare every framework/store library that must be a singleton in both configs’ shared. Verify with rollup-plugin-visualizer that React is not duplicated inside the remote chunk, and ensure the host actually provides the dependency it expects the remote to consume.

Eager consumption error #

Symptom: Shared module react doesn't exist in shared scope default or Eager consumption errors at boot.

Diagnosis: an eager shared module was requested before the scope was initialized — usually a host that renders React synchronously in its entry chunk without an async boundary.

Fix: move host rendering behind a dynamic import('./bootstrap') (Step 4) so the federation runtime initializes the shared scope before any eager module is consumed. Keep Vite remotes lazy.