DEV Community

Mattia Pispisa
Mattia Pispisa

Posted on

Workspaces, react and vite. A real-world case study for managing duplicate libraries.

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
  | apps
  |-- client
  |-- server
  | packages
  |-- package-a
     |-- src
     |__ package.json
  |-- package-b
     |-- src
     |__ package.json
  |__ package.json
Enter fullscreen mode Exit fullscreen mode

This architecture promotes modularity and code reuse. However, managing shared dependencies can introduce complexity in module resolution, leading to unexpected behaviors - even at runtime - that can be difficult to diagnose.

To fully understand dependency resolution mechanics and the deduplication strategies we use, we analyze a practical scenario. We use as a case study a frontend application built with react that uses react-router and react-router-dom for routing. These libraries, heavily relying on context, with their specific dependencies will immediately highlight the "structural problems" arising from the presence of multiple versions.

The starting point of our analysis is a blocking runtime error you might have encountered:

"Cannot destructure property 'basename' of 'w.useContext(...)' as it is null"
Enter fullscreen mode Exit fullscreen mode

This error indicates that the component cannot access the router context, despite the presence of a Provider in the component tree (we're not dealing with the issue where we use useLocation before defining the router). The root cause lies in the dependency duplication problem.

In this article we analyze:

  • how npm handles version resolution;
  • how vite handles dependency resolution;
  • how to implement a targeted deduplication strategy (dedupe) using vite.

Analysis: Module resolution in npm

An npm workspace links local packages through symbolic links. The challenge arises when different internal libraries depend on the same external library, but with different version constraints (even compatible ones as we'll see later).

Let's consider the following real-world scenario with two local packages, package-a and package-b that both depend, in different ways, on react-router and react-router-dom.

Dependency configuration:

  1. package-a uses a version range (caret):
// package-a/package.json
{
  "dependencies": {
    "react-router-dom": "^6.21.1"
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. While package-b requires exact versions ("pinned"):
// package-b/package.json
{
  "dependencies": {
    "react-router": "6.30.1",
    "react-router-dom": "6.30.1"
  }
}
Enter fullscreen mode Exit fullscreen mode
  1. In turn, react-router-dom depends on react-router.
// react-router-dom/package.json version 6.30.1
{
  "name": "react-router-dom",
  "version": "6.30.1",
  "dependencies": {
    "react-router": "6.30.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

When npm runs the installation, it attempts in order to resolve dependencies to optimize space (hoisting). However, it must respect semantic constraints:

  • For package-a, analyzed first, the version ^6.21.1 is resolved to the latest compatible version available, in our case 6.30.2 (as of December 2025). This is installed in the workspace root. react-router-dom at version 6.30.2 depends on react-router at version 6.30.2, so it will also be installed in the workspace root.

  • For package-b, analyzed second, version 6.30.1 is specifically required. Since it differs from the version in root, npm installs a local copy in packages/package-b/node_modules. Consequently, react-router will also be installed locally in package-b's node_modules at version 6.30.1.

The resulting on-disk structure will be as follows:

dependencies

The result we get is that, even though we pinned the version of react-router and react-router-dom in package-b's package.json and the versions in package-a are compatible with package-b's versions, we have two different versions of both libraries on disk.

The bundling problem

Before addressing the solution, it's useful to contextualize the role of the bundler. In a modern frontend architecture, the bundler (such as webpack, rollup, or vite) has the task of traversing the application's dependency graph, resolving each import statement, to combine modules and assets into static files optimized for browser execution.

For this analysis, we examine vite, currently a de facto standard for developing react applications, to see how it handles these collisions. By default, if two packages import two different physical sources (lib@1 and lib@2), the bundler will include both versions in the bundle (with increased size).

In our case, the final bundle will include both versions of react-router and react-router-dom with:

  1. increased size;
  2. imports of code at different versions depending on where the code is defined (package-a, package-b).

While this results in an increase in bundle size, the critical problem is functional: react-router cannot function correctly if different versions are used at different points in the code. This will lead to the runtime problem shown in the introduction:

"Cannot destructure property 'basename' of 'w.useContext(...)' as it is null"
Enter fullscreen mode Exit fullscreen mode

Resolution strategies with vite

To solve the problem, it's necessary to force the bundler to use a single instance of the library (deduplication).

One possible solution to resolve the problem is to use overrides in the root project's package.json, specifying the exact versions of dependencies you want to use.

// package.json
{
  "overrides": {
    "react-router": "6.30.1",
    "react-router-dom": "6.30.1"
  }
}
Enter fullscreen mode Exit fullscreen mode

But is this the only way to solve the problem?
No, there are other strategies for deduplication resolution. In our case, we leverage a setting that vite provides for deduplication resolution.

vite resolve.dedupe

vite offers the native resolve.dedupe option to indicate the dependencies you want to deduplicate.

// vite.config.ts
export default {
  resolve: {
    dedupe: ['react-router', 'react-router-dom']
  }
}
Enter fullscreen mode Exit fullscreen mode

However, this option doesn't always produce the desired or expected result.
As indicated in the official documentation, this option forces vite to resolve the listed dependencies always toward the copy present in the project root.

Problem: In our scenario, the root contains version 6.30.2 (resolved from package-a which required version ^6.21.1). While the version we pinned in package-b (which was compatible with package-a's requirement) is present inside package-b's node_modules (packages/package-b/node_modules).
So in our case, the result would be that, even though we've pinned the version of react-router and react-router-dom in package.json to version 6.30.1, the bundler will resolve dependencies toward version 6.30.2 present in the root.

Custom Plugin for Targeted Resolution

To achieve granular control, we can implement a vite plugin that forces resolution toward a specific installation (for example, the local one in package-a), ignoring the standard Node/npm resolution.

Here's an implementation of a plugin that intercepts module resolution and redirects the specified packages:

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 => {
  return {
    name: 'vite-plugin-enhanced-dedupe',
    enforce: 'pre', // Priority execution over the standard resolver
    resolveId(source, _importer) {
      // Process only packages in whitelist
      if (!dedupe.includes(source)) {
        return null;
      }

      // Build the absolute path toward the target node_modules
      const targetBase = path.resolve(__dirname, packagePath, 'node_modules', source);

      // Verify physical existence of the package in the target path
      if (!fs.existsSync(targetBase)) {
        return null; // Fallback to standard resolution if not found
      }

      // Identify the entry point via package.json
      const packageJsonPath = path.join(targetBase, 'package.json');
      if (!fs.existsSync(packageJsonPath)) {
        return null;
      }

      try {
        const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf-8'));

        // Priority to ESM ('module'), fallback to 'main' or 'index.js'
        const entryPoint = packageJson.module || packageJson.main || 'index.js';
        const resolvedPath = path.join(targetBase, entryPoint);

        // Return the absolute path to force the use of this specific instance
        return resolvedPath;
      } catch (error) {
        console.warn(`[enhanced-dedupe] Error reading package.json for ${source}:`, error);
        return null;
      }
    },
  }
}

export default enhancedDedupePlugin;
Enter fullscreen mode Exit fullscreen mode

Integration in vite.config.ts:

We can now configure vite to use the versions present in package-b as the single source of truth for react-router:

import { defineConfig } from 'vite'
import enhancedDedupePlugin from './vite-plugin-enhanced-dedupe'

export default defineConfig({
  plugins: [
    enhancedDedupePlugin({ 
      dedupe: ['react-router', 'react-router-dom'], 
      packagePath: '../../packages/package-b' 
    })
  ],
})
Enter fullscreen mode Exit fullscreen mode

With this configuration, the final bundle will include exclusively version 6.30.1 (the one from package-b), ensuring react context consistency and resolving the runtime error.

This solution proves particularly effective in asymmetric scenarios: cases where a local package imposes strict constraints (dependencies "pinned" to exact versions for legacy or stability reasons), while the rest of the workspace adopts more flexible constraints (e.g. with ^ or ~). In such a context, the plugin acts by normalizing the entire project to the most restrictive version, ensuring the constrained package works without requiring manual refactoring of dependencies in other "free" modules.

Note on the official react plugin

It's interesting to note that the official @vitejs/plugin-react plugin has removed automatic deduplication in recent versions. As reported in the official changelog, the automation often caused side effects that were difficult to track in complex monorepo architectures. This confirms the need for a manual and conscious approach to managing critical dependencies.

Conclusion

Managing dependencies in npm workspaces environments requires attention, especially with libraries that "orchestrate" the entire application. While npm tends to preserve semantic correctness by installing multiple versions, this behavior is incompatible with the runtime operation of some libraries. Through the use of custom plugins in vite, it's possible to achieve the necessary determinism, forcing resolution toward specific versions and ensuring application stability.

Top comments (0)