โ† Back to DevBytes

State Management in Webpack: Patterns and Libraries

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:

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:

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:

Best Practices

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.

๐Ÿ›  Tools from DevBytes

Inventory Tracker Pro โ€” Excel inventory system, low-stock alerts ยท $19
AI Dev Kit for Mac โ€” local AI dev environment templates ยท $9.99
KeyMapper for Mac โ€” custom keyboard shortcut toolkit ยท $7.99

โ† Back to all articles