Workspaces, react and vite. A real-world case study for managing duplicate libraries.
Source: Dev.to
Introduction
When organizing a project using npm workspaces, the typical structure consists of a single root package that orchestrates multiple local packages and applications:
my-workspace
│ package.json
├─ apps
│ ├─ client
│ └─ server
└─ packages
├─ package-a
│ ├─ src
│ └─ package.json
└─ package-b
├─ src
└─ package.json
This architecture promotes modularity and code reuse, but managing shared dependencies can introduce complexity in module resolution, leading to unexpected runtime errors that are difficult to diagnose.
A common symptom is the error:
Cannot destructure property 'basename' of 'w.useContext(...)' as it is null
This indicates that a component cannot access the router context even though a Router provider is present. The root cause is often dependency duplication.
In this article we analyze:
- How npm handles version resolution
- How Vite handles dependency resolution
- How to implement a targeted deduplication strategy using Vite
Dependency Configuration
package-a
{
"dependencies": {
"react-router-dom": "^6.21.1"
}
}
package-b
{
"dependencies": {
"react-router": "6.30.1",
"react-router-dom": "6.30.1"
}
}
react-router-dom depends on react-router:
{
"name": "react-router-dom",
"version": "6.30.1",
"dependencies": {
"react-router": "6.30.1"
}
}
Installation Result
When npm installs the workspace, it tries to hoist dependencies to the workspace root while respecting semantic version constraints:
-
package-ais processed first. The caret range^6.21.1resolves to the latest compatible version, e.g.,6.30.2. This version is installed at the workspace root. Consequently,react-router-dom@6.30.2(which depends onreact-router@6.30.2) is also hoisted to the root. -
package-bis processed next. It pinsreact-routerandreact-router-domto6.30.1. Because these versions differ from the hoisted ones, npm installs a separate copy insidepackages/package-b/node_modules, along with its ownreact-router@6.30.1.
The resulting on‑disk structure contains two distinct versions of both libraries:
my-workspace/
├─ node_modules/
│ ├─ react-router@6.30.2
│ └─ react-router-dom@6.30.2
└─ packages/
└─ package-b/
└─ node_modules/
├─ react-router@6.30.1
└─ react-router-dom@6.30.1
Role of the Bundler
In modern frontend development, the bundler (Webpack, Rollup, Vite, etc.) traverses the application’s dependency graph, resolves each import, and bundles the code for the browser.
If two different physical copies of a library are present, the bundler will include both versions in the final bundle. This leads to:
- Increased bundle size
- Inconsistent behavior when the same library is used at different versions across the codebase
For react-router, using mismatched versions breaks the context system, producing the runtime error shown earlier.
Deduplication Strategies
1. npm overrides
You can force a single version across the workspace by adding an overrides field in the root package.json:
{
"overrides": {
"react-router": "6.30.1",
"react-router-dom": "6.30.1"
}
}
This approach works but may be too coarse for some projects.
2. Vite resolve.dedupe
Vite provides a native deduplication option:
// vite.config.ts
export default {
resolve: {
dedupe: ['react-router', 'react-router-dom']
}
}
resolve.dedupe forces Vite to resolve the listed packages to the copy found at the project root. In the scenario above, the root contains version 6.30.2, so Vite would still bundle the newer version, ignoring the pinned 6.30.1 inside package-b.
3. Custom Vite Plugin for Granular Control
When you need fine‑grained control—e.g., always resolve to the version inside a specific package—you can implement a Vite plugin that intercepts module resolution and redirects it to a chosen location.
import fs from 'fs';
import path from 'path';
import { PluginOption } from 'vite';
/**
* Plugin to force deduplication of specific packages toward
* a version contained in a defined relative path.
*
* @param dedupe - Array of package names to deduplicate (e.g. ['react-router'])
* @param packagePath - Relative path to the package to use as "source of truth"
*/
const enhancedDedupePlugin = ({
dedupe,
packagePath,
}: {
dedupe: string[];
packagePath: string;
}): PluginOption => ({
name: 'vite-plugin-enhanced-dedupe',
enforce: 'pre', // Run before Vite's default resolver
resolveId(source, _importer) {
// Only process whitelisted packages
if (!dedupe.includes(source)) return null;
// Build absolute path to the target node_modules
const targetBase = path.resolve(__dirname, packagePath, 'node_modules', source);
if (!fs.existsSync(targetBase)) return null;
// Locate the package's entry point
const pkgJsonPath = path.join(targetBase, 'package.json');
if (!fs.existsSync(pkgJsonPath)) return null;
try {
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
const entry = pkg.module || pkg.main || 'index.js';
return path.join(targetBase, entry);
} catch {
return null; // Fallback to default resolution on error
}
},
});
export default {
plugins: [
enhancedDedupePlugin({
dedupe: ['react-router', 'react-router-dom'],
packagePath: '../package-a' // adjust to the package you trust
})
]
};
This plugin:
- Checks if the imported module is in the
dedupewhitelist. - Resolves it to the
node_modulesfolder of the specifiedpackagePath. - Falls back to Vite’s default resolution if the target package cannot be found.
By pointing packagePath to the package that contains the desired version (e.g., ../package-a), you can ensure that all imports of react-router and react-router-dom resolve to the same physical copy, eliminating duplication and the associated runtime errors.
Conclusion
- npm workspaces can hoist multiple versions of the same library when version constraints differ.
- Vite, by default, bundles each physical copy it encounters, which can cause both size bloat and functional bugs (e.g., broken React context).
- Simple solutions like
overridesorresolve.dedupemay not provide the granularity required for complex monorepos. - A custom Vite plugin that redirects resolution to a chosen package offers precise control over deduplication, ensuring a single instance of critical libraries such as
react-routeracross the entire workspace.