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:
- Cached file contents or ASTs (Abstract Syntax Trees) parsed by custom plugins.
- Counters or metadata tracking which files have been processed.
- Virtual module definitions generated dynamically during the build.
- References to external assets or configuration objects that need to be accessed in
onResolveoronLoadhooks.
Why Does State Management Matter?
Without a deliberate strategy for managing state, your ESBuild plugins and scripts can suffer from several issues:
- Memory Leaks: In watch mode, builds happen repeatedly. If state is not cleared or managed, caches can grow indefinitely.
- Stale Data: Holding onto outdated file contents can result in builds that do not reflect recent file changes.
- Unpredictable Builds: Race conditions or shared mutable state across parallel plugin executions can lead to non-deterministic outputs.
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:
- Prefer Local State: Always default to the Plugin Context Pattern. Keep variables scoped inside your plugin's
setupfunction to prevent cross-contamination between builds. - Clear State on
onEnd: If you must use global or module-level state, ensure you clean up or reset that state in theonEndhook to prevent memory leaks during watch mode. - Use the Context API for Watch Mode: Avoid writing custom file watchers. ESBuild's
context.watch()is highly optimized and handles dependency graph state much better than custom implementations. - Avoid Mutating
initialOptions: Do not attempt to modify thebuild.initialOptionsobject directly during the build. Instead, use theonResolveandonLoadhooks to alter how files are handled dynamically. - Make State Immutable Where Possible: When passing configuration into your plugins, pass deep copies or use immutable objects to prevent plugins from accidentally altering shared state.
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.