Introduction to State Management in Webpack
When developers hear "state management," they usually think of Redux, Zustand, or React Context. But Webpack itself is a deeply stateful system. Every build maintains an in-memory representation of modules, chunks, assets, dependencies, and compilation artifacts. Understanding how Webpack models, mutates, and exposes this state is essential if you want to write plugins, optimize builds, preserve state during Hot Module Replacement (HMR), or wire application-level state libraries into your build pipeline.
This tutorial covers two complementary perspectives: managing Webpack's own internal state (compilation state, plugin state, HMR state) and integrating application state management libraries into a Webpack-powered workflow. By the end, you'll have practical patterns for both.
What "State" Means in Webpack
Webpack's state can be broken into several layers:
- Configuration state โ the immutable
webpack.config.jsoptions resolved at startup. - Compilation state โ the
Compilationobject holding modules, chunks, assets, errors, and warnings for a single build. - Plugin state โ closures and shared objects plugins use to communicate across hooks.
- Module instance state โ runtime values held by modules between HMR updates.
- Application state โ the runtime state of your bundled app (Redux store, Zustand store, etc.).
Each layer has different lifecycle rules. Configuration state is effectively frozen. Compilation state is recreated on every rebuild. Plugin state persists across rebuilds but not across compiler instances. Module and application state live in the browser (or Node runtime) and must be explicitly preserved during HMR.
Why State Management Matters in Webpack
Ignoring Webpack's state model leads to subtle bugs: plugins that leak data between builds, HMR updates that wipe form input, caches that serve stale modules, and memory bloat in watch mode. Conversely, mastering it unlocks:
- Faster incremental rebuilds via correct cache invalidation.
- Plugins that share data without global variables.
- HMR that preserves UI state across code edits.
- Seamless integration of Redux/Zustand stores with module boundaries.
- Predictable, testable build pipelines.
Webpack's Internal State Model
The Compiler and Compilation Objects
The Compiler is the long-lived parent object โ one per Webpack configuration. It survives across rebuilds in watch mode. The Compilation object is created fresh for each build and holds the mutable state of that build.
// minimal-plugin.js
class MinimalPlugin {
apply(compiler) {
// compiler-level state: persists across rebuilds
this.buildCount = 0;
compiler.hooks.compilation.tap('MinimalPlugin', (compilation) => {
this.buildCount += 1;
console.log(`Build #${this.buildCount} started`);
// compilation-level state: fresh each build
compilation.hooks.finishModules.tap('MinimalPlugin', (modules) => {
console.log(`Finished with ${modules.size} modules`);
});
});
}
}
module.exports = MinimalPlugin;
Notice the distinction: this.buildCount lives on the plugin instance and survives across compilations, while anything attached to compilation is discarded when the build finishes.
Accessing Module and Chunk State
Modules and chunks expose their own state through properties like dependencies, blocks, assets, and hash. You can read and mutate these inside hooks.
class ChunkStatsPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('ChunkStatsPlugin', (compilation) => {
compilation.hooks.afterChunks.tap('ChunkStatsPlugin', (chunks) => {
for (const chunk of chunks) {
console.log(`Chunk ${chunk.id}: ${chunk.getNumberOfModules()} modules`);
}
});
});
}
}
Plugin State Management Patterns
Pattern 1: Closure-Based Private State
The simplest pattern keeps state in the plugin's constructor or apply closure. This is private by default and easy to reason about.
class CachePlugin {
constructor() {
this.cache = new Map();
}
apply(compiler) {
compiler.hooks.compilation.tap('CachePlugin', (compilation) => {
compilation.hooks.succeedModule.tap('CachePlugin', (module) => {
this.cache.set(module.identifier(), module);
});
});
compiler.hooks.done.tap('CachePlugin', () => {
console.log(`Cache size: ${this.cache.size}`);
});
}
}
Pattern 2: Shared State via Compilation Object
When multiple plugins need to share state within a single build, attach it to the compilation object. This avoids global singletons and resets cleanly each build.
// Plugin A produces data
class ProducerPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('ProducerPlugin', (compilation) => {
compilation.sharedData = [];
compilation.hooks.succeedModule.tap('ProducerPlugin', (module) => {
compilation.sharedData.push(module.userRequest);
});
});
}
}
// Plugin B consumes data
class ConsumerPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('ConsumerPlugin', (compilation) => {
compilation.hooks.afterChunks.tap('ConsumerPlugin', () => {
console.log('Shared data:', compilation.sharedData);
});
});
}
}
This pattern is used internally by Webpack itself โ for example, compilation.fileDependencies, compilation.contextDependencies, and compilation.assets are all shared-state collections.
Pattern 3: WeakMap for External Metadata
If you need to associate metadata with modules or chunks without mutating them, use a WeakMap. This avoids memory leaks because entries are garbage-collected when the key object is destroyed.
const moduleMeta = new WeakMap();
class MetadataPlugin {
apply(compiler) {
compiler.hooks.compilation.tap('MetadataPlugin', (compilation) => {
compilation.hooks.succeedModule.tap('MetadataPlugin', (module) => {
moduleMeta.set(module, {
processedAt: Date.now(),
sourceSize: module.originalSource().length,
});
});
compilation.hooks.afterChunks.tap('MetadataPlugin', (chunks) => {
for (const chunk of chunks) {
for (const module of chunk.getModules()) {
const meta = moduleMeta.get(module);
if (meta) console.log(module.identifier(), meta);
}
}
});
});
}
}
HMR and Module State Preservation
Hot Module Replacement replaces modules in place without a full page reload. But by default, any state held in those modules โ counters, form values, cached API responses โ is lost. Preserving it requires explicit handling.
The module.hot.accept Pattern
Each module that wants to survive HMR must accept updates and rehydrate its state.
// counter.js
let state = { count: 0 };
export function increment() {
state.count += 1;
render();
}
function render() {
document.getElementById('app').textContent = `Count: ${state.count}`;
}
if (module.hot) {
// Preserve state across updates
if (module.hot.data && module.hot.data.state) {
state = module.hot.data.state;
}
module.hot.accept();
module.hot.dispose((data) => {
data.state = state;
});
}
render();
module.hot.dispose runs before the old module is replaced and lets you stash data. module.hot.data is available in the new module and contains whatever you disposed.
Persisting Redux Store State Across HMR
For application-level state, the same pattern applies โ capture the store state on dispose and rehydrate on accept.
// store.js
import { createStore } from 'redux';
import rootReducer from './reducers';
let store;
if (module.hot && module.hot.data && module.hot.data.storeState) {
store = createStore(rootReducer, module.hot.data.storeState);
} else {
store = createStore(rootReducer);
}
if (module.hot) {
module.hot.accept('./reducers', () => {
const nextRootReducer = require('./reducers').default;
store.replaceReducer(nextRootReducer);
});
module.hot.dispose((data) => {
data.storeState = store.getState();
});
}
export default store;
This lets you edit reducers and see changes instantly without losing the current store contents โ invaluable during development.
Integrating Application State Libraries
Redux with Webpack
Redux stores are framework-agnostic and bundle cleanly with Webpack. The main integration points are HMR (shown above) and code-splitting reducers.
// webpack.config.js โ code-split reducers with dynamic imports
module.exports = {
mode: 'development',
entry: './src/index.js',
output: {
filename: '[name].bundle.js',
chunkFilename: '[name].chunk.js',
path: __dirname + '/dist',
},
optimization: {
splitChunks: {
chunks: 'all',
},
},
devServer: {
hot: true,
},
};
With code-splitting, you can lazy-load reducers for route-based chunks:
// routes/admin.js
import { injectReducer } from '../store/reducerInjector';
export default {
onEnter: async (nextState, replace, callback) => {
const { default: adminReducer } = await import('./adminReducer');
injectReducer('admin', adminReducer);
callback();
},
getComponent: (nextState, cb) => {
import('./AdminPage').then((mod) => cb(null, mod.default));
},
};
Zustand with Webpack and HMR
Zustand is lighter than Redux and pairs well with Webpack's HMR. The bind pattern is similar.
// store.js
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
if (module.hot) {
const currentStore = useStore.getState();
module.hot.dispose((data) => {
data.storeState = currentStore;
});
module.hot.accept();
if (module.hot.data && module.hot.data.storeState) {
useStore.setState(module.hot.data.storeState);
}
}
export default useStore;
Using Webpack DefinePlugin for Initial State
You can inject build-time state into your application via DefinePlugin. This is useful for feature flags, API URLs, and environment-specific defaults.
// webpack.config.js
const webpack = require('webpack');
const { version } = require('./package.json');
module.exports = {
plugins: [
new webpack.DefinePlugin({
'process.env.API_URL': JSON.stringify(process.env.API_URL || 'http://localhost:3000'),
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
__APP_VERSION__: JSON.stringify(version),
}),
],
};
Inside your app, these become compile-time constants:
const initialState = {
apiBaseUrl: process.env.API_URL,
appVersion: __APP_VERSION__,
features: {
beta: process.env.NODE_ENV !== 'production',
},
};
Persistent Caching and State
Webpack 5's persistent filesystem cache serializes module and dependency state to disk. This is a form of state management โ the cache must be invalidated correctly when source or config changes.
// webpack.config.js
module.exports = {
cache: {
type: 'filesystem',
buildDependencies: {
// Invalidate cache when these files change
config: [__filename],
},
version: '1.0.0', // bump to force full rebuild
},
};
Key rules for cache state:
- Include any file that influences build output in
buildDependencies. - Bump the
versionstring when you change loaders, plugins, or Babel config in ways Webpack can't detect. - Never cache side-effectful plugins that depend on external mutable state (database reads, network calls).
Best Practices
- Keep plugin state on the plugin instance, not on global variables. This makes plugins reusable across multiple compiler instances.
- Use the compilation object for per-build shared state so data resets cleanly between rebuilds and avoids stale leaks.
- Prefer WeakMap over mutation when attaching metadata to modules or chunks โ it prevents memory leaks in watch mode.
- Always guard HMR code with
if (module.hot)so production builds tree-shake it away. - Dispose before accept โ capture state in
dispose, rehydrate frommodule.hot.datain the new module. - Version your persistent cache whenever you change build tooling that Webpack cannot automatically track.
- Separate build-time state from runtime state.
DefinePluginvalues are compile-time constants; they do not update at runtime. - Test plugins with watch mode to catch state-leak bugs that only appear across multiple builds.
Conclusion
State management in Webpack spans two worlds: the build-time state of the bundler itself and the runtime state of the application it produces. By understanding the lifecycle of the Compiler and Compilation objects, choosing the right pattern for plugin state โ closures, shared compilation properties, or WeakMaps โ and wiring HMR dispose/accept hooks into your Redux or Zustand stores, you can build faster, more reliable tooling and a smoother developer experience. The key insight is that every layer of state has its own lifecycle, and respecting those lifecycles is what separates fragile Webpack setups from robust, production-grade pipelines.