| description |
|---|
Technical specification for HMR support and tree shaking in React 19 / Rolldown architectures. |
Technical Specification: Industry Standard Practices for HMR Support and Tree Shaking in React 19 / Rolldown Architectures
The migration to React 19, coupled with the Vite 6+ ecosystem utilizing Rolldown (the Rust-based successor to Rollup), fundamentally alters the optimization landscape for frontend applications. This document serves as a comprehensive technical specification for Senior Frontend Architects and Lead Engineers. It details the precise coding standards, configuration patterns, and architectural constraints required to achieve two specific critical objectives: Deterministic Hot Module Replacement (HMR) stability and Aggressive Tree Shaking (Dead Code Elimination).
In the Rolldown era, the discrepancy between development (previously esbuild) and production (previously Rollup) is eliminated, allowing for a unified compilation strategy. However, leveraging this unified toolchain requires strict adherence to specific practices that align with Rolldown’s static analysis capabilities and React 19’s Compiler mechanics. The following standards are not suggestions; they are architectural requirements for performant, stable applications in 2026.
Hot Module Replacement (HMR) in React 19 is governed by the "Fast Refresh" implementation. Unlike legacy hot reloading, Fast Refresh attempts to preserve component state (hooks and refs) during updates. This process relies on the bundler’s ability to identify "Safe HMR Boundaries." A violation of these boundaries forces a full page reload, degrading developer experience (DX) and resetting application state.
The most prevalent cause of HMR instability in Vite/Rolldown environments is the violation of the "Single Export Authority" rule.
Vite’s HMR client (@vitejs/plugin-react) performs static analysis on every file. To determine if a file can be "hot swapped," the analyzer checks if the file strictly exports React components. If a file exports a React component alongside non-component values (constants, utility functions, Zod schemas, or Context objects), the HMR boundary becomes ambiguous.
When a mixed export is detected, the bundler cannot guarantee that the non-component exports are side-effect-free or that they can be safely re-evaluated without breaking the application state. For example, if a file exports a const theme = {... } object that is used to initialize a singleton style engine, re-executing that file might re-initialize the engine, causing a memory leak or style glitch. Consequently, Vite falls back to a full page reload to ensure correctness.
Files ending in .tsx or .jsx must strictly export only React components. All other transfers must be segregated into dedicated files.
**Violation Pattern (Breaks HMR):**typescript // ❌ UserProfile.tsx // This constant breaks the HMR boundary because it is not a component. export const MAX_BIO_LENGTH = 500;
// This Context definition breaks the HMR boundary. export const UserContext = createContext(null);
export const UserProfile = () => { return
**Compliant Pattern (Preserves HMR):**
```typescript
// ✅ constants.ts
export const MAX_BIO_LENGTH = 500;
// ✅ UserContext.ts
import { createContext } from 'react';
export const UserContext = createContext(null);
// ✅ UserProfile.tsx
import { MAX_BIO_LENGTH } from './constants';
import { UserContext } from './UserContext';
export const UserProfile = () => {
return <div>...</div>;
};
Manual enforcement is unreliable. The industry standard is to utilize ESLint with the react-refresh/only-export-components rule configured to error severity. This ensures that CI/CD pipelines reject code that compromises HMR stability.
| Rule ID | Severity | Configuration | Reasoning |
|---|---|---|---|
react-refresh/only-export-components |
error |
{ allowConstantExport: false } |
While true allows constants, strict separation is safer for large teams to prevent regression. |
React 19’s ecosystem, particularly when paired with file-system based routing (e.g., TanStack Router, React Router v7) or code-splitting, heavily favors named exports over default exports.
Anonymous default exports (e.g., export default () =>...) lack a stable identifier in the Abstract Syntax Tree (AST). Fast Refresh relies on component names to map state between the old version of the module and the new version.
If a component is anonymous, the HMR engine generates a synthetic ID. During a complex edit (e.g., changing the order of hooks or wrapping the return statement), this synthetic ID may change or fail to map correctly to the Fiber node, causing the state (useState, useRef) to reset unexpectedly.
Default exports are treated as "opaque" by some interop patterns in Rolldown. When importing a default export, the bundler often has to include the entire module evaluation result. Named exports allow for precise import scanning (import { Dashboard } from './Dashboard'), enabling the bundler to verify exactly which export is used and shake out the rest if applicable.
Implementation Requirement: Refactor all page-level and component-level files to use named exports.
// ❌ Avoid
export default function() {
return <h1>Dashboard</h1>;
}
// ✅ Adopt
export function DashboardPage() {
return <h1>Dashboard</h1>;
}React 19 introduces the React Compiler (formerly React Forget), which automates memoization. While primarily a runtime performance tool, it significantly impacts the HMR lifecycle.
The React Compiler memoizes components based on the flow of data. If the input data has not changed, the compiler returns the cached JSX. In development, this can sometimes lead to "HMR Locking," where a code change (e.g., changing a style object defined outside the component) does not trigger a visual update because the compiler determines the component's data dependencies haven't changed.
To diagnose and resolve HMR locking, or to fix components that break under automatic memoization, React 19 provides the "use no memo" directive. This directive acts as a localized "eject" button, instructing the compiler to skip optimization for a specific component or file.
Usage Pattern: Use this directive only when debugging HMR issues or refactoring legacy components that violate the Rules of React (e.g., mutation during render).
// ✅ SuspiciousComponent.tsx
function SuspiciousComponent({ data }) {
"use no memo"; // Opt-out of React Compiler optimization
// Legacy code performing mutation
data.sort();
return <div>{data.join(',')}</div>;
}To ensure the React Compiler supports HMR without manual overrides, strict adherence to immutability is required. The compiler assumes that if a reference (Object.is) is unchanged, the value is unchanged. Mutating arrays (.push) or objects in place confuses the dependency tracking, leading to stale UI states that persist even after HMR updates.
Rolldown operates on a graph-based analysis of ECMAScript Modules (ESM). "Tree shaking" is the process of eliminating dead nodes from this graph. However, Rolldown is conservative by default; it will not remove code if there is a possibility that executing that code produces a side effect (e.g., modifying the global window object). To achieve minimal bundle sizes, developers must explicitly signal purity to the bundler.
"Barrel files" (index.ts files that re-export multiple modules) are the single largest contributor to bloated bundles and slow development startup times in large React applications.
When a developer writes import { Button } from './components';, the bundler must resolve ./components/index.ts. If that barrel file exports 100 other components (export * from './Table', export * from './Modal', etc.), the bundler must parse and resolve the AST for all 100 files to ensure that Button is indeed exported and to check for side effects in the other files.
This process:
- Increases Cold Start Time: Vite/Rolldown must perform disk I/O and parsing for hundreds of unused files.
- Defeats Lazy Loading: If the barrel file is not perfectly tree-shakeable (see Standard V), importing one small utility can pull the entire library into the main bundle.
- Slows Testing: Test runners like Vitest (even with their optimized resolution) often load the entire module graph for the barrel, slowing down unit tests significantly.
For application code (internal source), strictly forbid barrel files. Import directly from the source.
Prohibited Pattern:
// src/features/index.ts
export * from "./Auth";
export * from "./Dashboard";
export * from "./Settings";
// src/App.tsx
import { Auth } from "./features"; // Triggers parsing of Dashboard and SettingsMandated Pattern:
// src/App.tsx
import { Auth } from "./features/Auth/Auth";If barrel files are unavoidable (e.g., in a shared internal library), or for any third-party dependency, the sideEffects property in package.json is the primary mechanism to enable aggressive pruning.
According to the ECMAScript module specification, importing a module—even if no exports are used—requires evaluating its top-level code. This is because the module might perform a global side effect (e.g., console.log, window.polyfill =...).
When sideEffects: false is set in package.json, the developer explicitly authorizes Rolldown to violate the spec and skip the evaluation of the module entirely if none of its exports are used. This allows the bundler to "prune" the branch from the graph instantly.
For a standard React 19 application, the majority of code is pure. However, global CSS files are side effects. If you set sideEffects: false globally without exceptions, Rolldown will remove your CSS imports (e.g., import './styles.css') because they appear to be unused imports.
Correct package.json Configuration:
{
"name": "my-react-app",
"version": "1.0.0",
"sideEffects": ["**/*.css", "**/*.scss", "**/*.less", "./src/init-observability.ts", "./src/polyfills.ts"]
}This configuration tells Rolldown: "Assume everything is pure and safe to remove, except CSS files and specific initialization scripts."
TypeScript 5.0+ introduced the --verbatimModuleSyntax flag, replacing the confusing importsNotUsedAsValues and preserveValueImports flags. This setting is critical for Rolldown to correctly distinguish between runtime code and type definitions.
Without this flag, TypeScript attempts to infer whether an import is used as a value or a type. If it infers incorrectly, it might emit a require/import statement for a file that only contains types (and thus doesn't exist in the build output), causing runtime errors. Conversely, it might elide an import that was needed for a side effect.
Rolldown (and esbuild) processes files individually (isolatedModules). They cannot "see" into other files to know if an export is a class (value) or an interface (type). Explicit syntax removes this ambiguity.
Enable verbatimModuleSyntax in tsconfig.json. This forces developers to use the type modifier for type-only imports.
tsconfig.json:
{
"compilerOptions": {
"verbatimModuleSyntax": true,
"isolatedModules": true,
"moduleResolution": "bundler",
"target": "ESNext"
}
}Code Impact:
// ❌ Ambiguous (Rolldown must guess)
import { User, UserRole } from "./types";
// ✅ Explicit (Rolldown removes UserRole line entirely)
import { User } from "./types";
import type { UserRole } from "./types";TypeScript Enums are a non-standard feature that emits runtime code—typically an Immediately Invoked Function Expression (IIFE) attached to an object. This generated code is notoriously difficult for bundlers to tree-shake because it looks like an imperative side effect.
Even if you only use one value from an Enum, the entire Enum definition object is included in the bundle. const enum can sometimes inline values, but tooling support is inconsistent (especially with isolatedModules: true), often leading to the retention of the runtime object regardless.
Replace all Enums with "Plain Old JavaScript Objects" (POJO) using the as const assertion. This creates a structure that TypeScript treats as literals. Rolldown can easily inline the string/number literals and remove the object definition entirely if it's unused.
Comparison Table:
| Feature | TypeScript Enum | as const Object |
Rolldown Optimization |
|---|---|---|---|
| Runtime Output | IIFE + Object assignment | Object literal | High |
| Tree Shaking | Poor (Often retained) | Excellent (Removed if unused) | Max |
| Code Size | Heavy | Lightweight | Minimal |
| Type Safety | Nominal | Structural | Equivalent |
Implementation:
// ❌ Avoid
enum Status {
Active = "active",
Inactive = "inactive",
}
// ✅ Adopt
const Status = {
Active: "active",
Inactive: "inactive",
} as const;
// Derived Type
export type Status = typeof Status;Legacy CommonJS libraries are the enemy of tree shaking. CommonJS modules are dynamic; their exports are defined at runtime (module.exports =...). Static analyzers cannot reliably determine which exports are unused, forcing them to include the entire library.
Importing from lodash (import { map } from 'lodash') typically pulls in the entire 70KB+ library because the main entry point is a CommonJS monolith.
Solution:
- Use ESM Builds: Prefer
lodash-es. It is the same library pre-compiled to ES Modules. Rolldown can tree-shakelodash-esperfectly. - Path Imports: If ESM is unavailable, import specific files:
import map from 'lodash/map'.
Ensure your UI component library (MUI, AntD, Shadcn) supports ESM. Check your import statements. If you see import { Button } from '@mui/material', verify in the bundle analyzer that it is not pulling in the entire library. Modern libraries use sideEffects flags to allow this, but older versions may require path imports.
Proper configuration of vite.config.ts is the enforcement mechanism for these practices. The transition from Vite 5 (esbuild/Rollup) to Vite 6+ (Rolldown) introduces new configuration objects.
The rollupOptions in Vite now map to Rolldown options. Crucially, Rolldown introduces rolldownOptions for dependency optimization, replacing esbuildOptions.
In the build.rollupOptions.treeshake object, we can enforce stricter rules than the defaults.
Setting this to "no-external" is a high-reward optimization. It tells Rolldown: "Assume all external dependencies (node_modules) have no side effects unless they explicitly say so in their package.json." This prevents the inclusion of massive amounts of dead code from dependencies that forgot to add the sideEffects: false flag.
Warning: This can break libraries that rely on global polyfills. Test thoroughly.
By default, bundlers assume that reading a property from an object (e.g., obj.value) might trigger a getter with side effects. Setting this to false tells Rolldown to assume getters are pure. This allows for deeper elimination of unused object properties.
React’s forwardRef, memo, and createContext are higher-order functions. To a bundler, React.memo(...) is a function call that executes at runtime. Rolldown must preserve this call unless it knows it's pure. Listing these in manualPureFunctions authorizes Rolldown to remove the entire component definition if the variable holding it is unused.
Configuration Template:
// vite.config.ts
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { resolve } from 'path';
export default defineConfig({
plugins: [
react({
babel: {
plugins: ['babel-plugin-react-compiler'],
},
}),
],
resolve: {
alias: {
'@': resolve(__dirname, './src'),
},
},
build: {
target: 'esnext', // Rolldown targets modern environments
minify: 'esbuild', // Fast minification
rollupOptions: {
treeshake: {
// Aggressive Strategy: Assume externals are pure
moduleSideEffects: 'no-external',
// Optimization: Assume getters don't have side effects
propertyReadSideEffects: false,
// Optimization: Remove unused React HOCs
manualPureFunctions:,
},
output: {
// Manual Code Splitting Strategy
manualChunks: (id) => {
if (id.includes('node_modules')) {
// Isolate React Core to cache it aggressively
if (id.includes('react') |
|
id.includes('react-dom') |
|
id.includes('scheduler')) {
return 'vendor-react';
}
// General Vendor Chunk
return 'vendor';
}
},
},
},
},
// Rolldown specific optimization options (Vite 8+)
optimizeDeps: {
// Force Rolldown to analyze these dependencies
include: ['react', 'react-dom'],
}
});Tree shaking removes unused code within files. Code splitting breaks the application into chunks to be loaded on demand. Rolldown offers advanced manual code splitting capabilities.
While Vite creates chunks automatically based on dynamic imports (import()), the default strategy often bundles all node_modules into a single massive vendor.js. This is inefficient for caching. If you update one tiny library, the user must re-download React, Lodash, and everything else.
Split stable dependencies (React, ReactDOM) from volatile dependencies (UI libraries, utility scripts). React updates infrequently; your UI library might update weekly.
Implementation:
Refer to the manualChunks function in the configuration template above. This creates a vendor-react.js (long-term cache) and vendor.js (medium-term cache).
Use React.lazy and Suspense at the route level. This creates natural split points in the graph. Rolldown treats import() as a signal to start a new chunk.
// router.tsx
import { lazy } from "react";
// This automatically creates a separate chunk for the Dashboard
const Dashboard = lazy(() => import("./pages/Dashboard"));
export const routes = [
//...
];Blindly applying optimizations is dangerous. You must verify their impact.
Scenario: A component is not updating during development (HMR failure). Action:
- Apply
"use no memo"to the component. - Save.
- Result A: It updates. Diagnosis: React Compiler was overly aggressive. Check for mutation in your code or leave the directive.
- Result B: It still doesn't update. Diagnosis: HMR Boundary violation. Check for mixed exports or anonymous functions in the file.
Do not trust your code; trust the artifact. Use rollup-plugin-visualizer (compatible with Vite/Rolldown) to generate a treemap of the final build.
Procedure:
npm install -D rollup-plugin-visualizer- Add to
vite.config.tsplugins. - Run
npm run build. - Open
stats.html.
Analysis Checklist:
- Duplicate Libraries: Do you see multiple versions of
lodashordate-fns? (Fix: Deduplicate inpackage-lock.json). - Tree Shaking Failures: Do you see the full
@mui/icons-materiallibrary (5MB+) instead of just the 3 icons you use? (Fix: Check import syntax andsideEffectsconfig). - Unexpected Large Files: Is a random 500KB JSON file included in the main bundle? (Fix: Use dynamic import
await import('./data.json')to split it out).
Circular dependencies (Module A -> Module B -> Module A) create "Strongly Connected Components" in the graph. Bundlers cannot separate them, so they must be bundled together. They also frequently break HMR cycles.
Tooling:
Use madge to detect cycles.
- Command:
npx madge --circular --extensions ts,tsx./src - Standard: Zero circular dependencies allowed in the
srcfolder. Refactor shared logic into a third, independent module.
To achieve industry-standard performance and stability with React 19 and Rolldown, the following rules must be treated as CI/CD failure conditions:
- Strict File Separation:
.tsxfiles must export only components. No mixed exports. - Explicit Naming: No anonymous default exports. Use named exports for all components.
- No Barrel Files: Ban barrel files in application source code. Import from leaves.
- Side Effects Whitelist: Configure
sideEffects: [...]inpackage.jsonto allow aggressive pruning of non-CSS files. - Verbatim Module Syntax: Enable
verbatimModuleSyntax: truein TSConfig to clarify type imports. - No Enums: Use
as constobjects instead of TypeScript Enums. - ESM Only: Reject CommonJS libraries; use ESM alternatives (e.g.,
lodash-es). - HOC Optimization: Whitelist
React.memoandforwardRefinmanualPureFunctions. - Vendor Splitting: Configure
manualChunksto isolate React core from other dependencies.
Adherence to these nine standards transforms the build pipeline from a "black box" into a precision-engineered delivery mechanism, fully capitalizing on the speed of Rolldown and the runtime efficiency of React 19.