← Back to DevBytes

State Management in ESBuild: Patterns and Libraries

Introduction to State Management in ESBuild

ESBuild is renowned for its blazing-fast build speeds, but when you move beyond simple configurations and start writing custom plugins or complex build pipelines, you inevitably encounter the challenge of state management. Managing state effectively ensures your builds are deterministic, your plugins are efficient, and your watch-mode integrations behave predictably.

What is State Management in ESBuild?

In the context of ESBuild, state management refers to the practice of storing, sharing, and updating data across different phases of the build process. This data can include:

Why Does State Management Matter?

Without a deliberate strategy for managing state, your ESBuild plugins and scripts can suffer from several issues:

Core Patterns for State Management

When writing ESBuild plugins, there are two primary patterns for managing state: the Plugin Context Pattern and the Global Store Pattern.

The Plugin Context Pattern

The most common and safest way to manage state is by encapsulating it within the closure of your plugin function. Because ESBuild instantiates your plugin function when the build starts, variables declared inside this function are scoped to that specific build lifecycle.

function counterPlugin() {
  let filesProcessed = 0;
  const processedFiles = new Set();

  return {
    name: 'counter-plugin',
    setup(build) {
      build.onLoad({ filter: /\.js$/ }, async (args) => {
        filesProcessed++;
        processedFiles.add(args.path);

        console.log(`Processed ${filesProcessed} files so far.`);

        // Return null to let ESBuild handle the actual loading
        return null;
      });

      build.onEnd((result) => {
        console.log(`Build finished. Total JS files processed: ${filesProcessed}`);
        // Reset state if necessary, though ESBuild creates a new plugin 
        // instance for standard builds. Watch mode behavior may vary.
      });
    }
  };
}

module.exports = counterPlugin;

The Global Store Pattern

Sometimes, you need to share state across multiple different plugins or between the main build script and the plugins themselves. In this case, you can use a module-level store. This is particularly useful for virtual modules or sharing configuration data.

// store.js
const state = {
  virtualModules: new Map(),
  config: {}
};

module.exports = state;

// build.js
const esbuild = require('esbuild');
const store = require('./store');

function virtualModulePlugin() {
  return {
    name: 'virtual-module-plugin',
    setup(build) {
      build.onResolve({ filter: /^virtual:/ }, (args) => {
        return { path: args.path, namespace: 'virtual' };
      });

      build.onLoad({ filter: /.*/, namespace: 'virtual' }, (args) => {
        const contents = store.virtualModules.get(args.path);
        if (contents) {
          return { contents, loader: 'js' };
        }
        return undefined;
      });
    }
  };
}

// Populate state before build
store.virtualModules.set('virtual:config', 'export const apiUrl = "https://api.example.com";');

esbuild.build({
  entryPoints: ['app.js'],
  bundle: true,
  plugins: [virtualModulePlugin()],
  outfile: 'out.js',
});

Popular Libraries and Approaches

While vanilla JavaScript patterns are often sufficient, ESBuild's modern API and ecosystem allow for more robust state management solutions when builds become highly complex.

Using the ESBuild Context API

Introduced in ESBuild 0.17.0, the Context API is the built-in solution for managing state across watch and serve modes. The context object holds the state of your build configuration and allows you to trigger rebuilds manually or automatically without re-parsing your options.

const esbuild = require('esbuild');

async function startBuild() {
  // The context manages the state of the build pipeline
  const ctx = await esbuild.context({
    entryPoints: ['src/index.js'],
    bundle: true,
    outfile: 'dist/bundle.js',
    plugins: [/* your plugins here */]
  });

  // Start watch mode. ESBuild manages the state internally,
  // reusing plugin instances and caches efficiently.
  await ctx.watch();

  console.log('Watching for changes...');

  // You can also manually trigger a rebuild using the same state
  // const result = await ctx.rebuild();
}

Integrating External State Libraries

If your build process involves complex interactions—such as reading from a database, polling an API, or coordinating with a separate dev server—you might integrate a lightweight state library like nanostores or a simple EventEmitter. This allows your build script to react to external state changes.

const esbuild = require('esbuild');
const { createStore } = require('nanostores');
const { EventEmitter } = require('events');

// Using a simple EventEmitter for external state
const externalState = new EventEmitter();
let currentEnv = 'development';

externalState.on('envChange', (newEnv) => {
  currentEnv = newEnv;
  console.log(`Environment changed to: ${currentEnv}`);
});

function envInjectorPlugin() {
  return {
    name: 'env-injector',
    setup(build) {
      build.onResolve({ filter: /^env$/ }, (args) => {
        return { path: args.path, namespace: 'env-ns' };
      });

      build.onLoad({ filter: /.*/, namespace: 'env-ns' }, () => {
        // State is read dynamically at build time
        return {
          contents: `export default "${currentEnv}";`,
          loader: 'js'
        };
      });
    }
  };
}

Best Practices for ESBuild State Management

To ensure your ESBuild plugins and build scripts remain maintainable and performant, follow these best practices:

Conclusion

State management in ESBuild is a nuanced topic that bridges the gap between simple bundling and advanced build orchestration. By understanding the lifecycle of ESBuild plugins and leveraging the Context API, developers can create robust, cache-friendly, and leak-free build pipelines. Whether you rely on simple closure-based patterns or integrate external event emitters, the key is to keep your state scoped as tightly as possible and to always account for the repetitive nature of watch mode. With these patterns and best practices in hand, you are well-equipped to tackle even the most complex ESBuild plugin development scenarios.

🛠 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