A lightweight, hierarchical resource management system for JavaScript. Handles cleanup of subscriptions, event listeners, timers, and any other disposable resources in a structured, scope-based manner.
- 🎯 Universal cleanup: Works with any cleanup pattern (functions, Symbol.dispose, etc.)
- 🌲 Hierarchical scopes: Organize resources in a tree structure
- 🔄 Correct disposal order: Children dispose first, local resources in reverse
- 🪶 Lightweight: No dependencies, minimal API surface
- 🎨 Flexible: Use nested scopes or path-based lookup
npm i dirgeimport { ManagedResourceScope } from 'dirge';
class Component {
#disposal = new ManagedResourceScope('component');
constructor() {
// Add any cleanup function
this.#disposal.add(() => console.log('cleanup!'));
// Subscriptions
this.#disposal.add(() => signal.unsubscribe());
// Event listeners
const handler = () => {};
this.#disposal.add(() => {
button.removeEventListener('click', handler);
});
// AbortController
const controller = new AbortController();
this.#disposal.add(() => controller.abort());
// Timer
const timer = setInterval(() => {}, 1000);
this.#disposal.add(() => clearInterval(timer));
}
destroy() {
this.#disposal.dispose(); // Cleans everything
}
}Organize related resources into nested scopes:
import { ManagedResourceScope } from 'dirge';
class App {
#disposal = new ManagedResourceScope('app');
constructor() {
// Main app resources
this.#disposal.add(() => console.log('app cleanup'));
// UI scope
const ui = this.#disposal.scope('ui');
ui.add(() => console.log('ui cleanup'));
// Modal within UI
const modal = ui.scope('modal');
modal.add(() => console.log('modal cleanup'));
// Keep reference for later
this.modalScope = modal;
}
closeModal() {
// Dispose only the modal
this.modalScope.dispose();
}
destroy() {
// Disposes all: modal → ui → app (depth-first)
this.#disposal.dispose();
}
}const scope = new ManagedResourceScope(name);Add a resource to be disposed. Resource must be either:
- A function:
() => cleanup() - An object with
Symbol.disposemethod
scope.add(() => subscription.unsubscribe());
scope.add(() => controller.abort());Create or get a child scope. Returns a ManagedResourceScope.
const childScope = scope.scope('child');
childScope.add(() => console.log('child cleanup'));Get a nested scope by path. Less type-safe than keeping references.
const modal = scope.lookup('ui/modal');
// Equivalent to: scope.scope('ui').scope('modal')Dispose all resources in this scope and all children. Disposal order:
- All child scopes (recursively)
- Local resources (in reverse order of addition)
scope.dispose();Get the full path of this scope from root.
const modal = root.scope('ui').scope('modal');
console.log(modal.path); // "/ui/modal"Reference to parent scope (or undefined for root).
Map of child scopes: Map<string, ManagedResourceScope>
import { ManagedResourceScope } from 'dirge';
class Component {
#disposal = new ManagedResourceScope('component');
mount() {
this.#disposal.add(() => element.remove());
this.#disposal.add(() => signal.unsubscribe());
}
unmount() {
this.#disposal.dispose();
}
}showDialog() {
const dialogScope = this.#disposal.scope('dialog');
dialogScope.add(() => overlay.remove());
dialogScope.add(() => controller.abort());
// Later...
dialogScope.dispose(); // Just the dialog
}async fetchData() {
const fetchScope = this.#disposal.scope('fetch');
const controller = new AbortController();
fetchScope.add(() => controller.abort());
try {
const response = await fetch(url, {
signal: controller.signal
});
return await response.json();
} finally {
fetchScope.dispose();
}
}Visualize your disposal tree:
// DOM visualization
export function visualizeDisposalTree(scope, rootName = "root") {
const ul = document.createElement("ul");
ul.style.fontFamily = "monospace";
ul.style.lineHeight = "1.6";
function buildTree(scope, parentUl) {
const li = document.createElement("li");
li.innerHTML = `<strong>${scope.name || rootName}</strong> ` + `<span style="color: #666;">(${scope.size} resources)</span>`;
parentUl.appendChild(li);
if (scope.children.size > 0) {
const childUl = document.createElement("ul");
li.appendChild(childUl);
for (const child of scope.children.values()) {
buildTree(child, childUl);
}
}
}
buildTree(scope, ul);
return ul;
}
// Usage
document.body.appendChild(visualizeDisposalTree(myScope));export function logDisposalTree(scope, rootName = "root", indent = "") {
const name = scope.name || rootName;
console.log(`${indent}├─ ${name} (${scope.size} resources)`);
const children = Array.from(scope.children.values());
children.forEach((child, i) => {
const isLast = i === children.length - 1;
const newIndent = indent + (isLast ? " " : "│ ");
logDisposalTree(child, child.name, newIndent);
});
}
// Usage
logDisposalTree(myScope);
// Output:
// ├─ app (2 resources)
// │ ├─ ui (1 resources)
// │ ├─ modal (3 resources)The real problem emerges when components have different lifecycle scopes - persistent resources that live for the component's lifetime, and temporary resources that change as the user navigates through states.
Consider a login component with multiple stages:
class AuthComponent {
#componentScope; // Lives until user logs in
#userFormScope; // Recreated when switching login/forgot password
initialize() {
// Component-level: survives form switches
this.#componentScope = app.disposer.scope('auth-component');
// Subscribe to user profile signal - lives entire component lifetime
this.#componentScope.add(() => userProfile.unsubscribe());
// Setup form validation - component-level
this.#componentScope.add(() => validator.destroy());
}
start() {
// Show login form
this.showLoginForm();
}
showLoginForm() {
// Clear old form scope if exists
this.#userFormScope?.dispose();
// Create fresh scope for this form
this.#userFormScope = this.#componentScope.scope('loginForm');
// Form-specific subscriptions
this.#userFormScope.add(() => {
loginButton.removeEventListener('click', this.handleLogin);
});
this.#userFormScope.add(() => loginFormSignal.unsubscribe());
// Nested scope for dynamic validation messages
const validationScope = this.#userFormScope.scope('validation');
validationScope.add(() => clearValidationUI());
}
showForgotPasswordForm() {
// Automatically cleans loginForm + all its nested scopes
this.#userFormScope?.dispose();
// Fresh scope for forgot password
this.#userFormScope = this.#componentScope.scope('forgotForm');
this.#userFormScope.add(() => {
resetButton.removeEventListener('click', this.handleReset);
});
this.#userFormScope.add(() => forgotFormSignal.unsubscribe());
}
terminate() {
// Cleanup everything: component scope + any active form scope
// All nested scopes cleaned automatically
this.#componentScope.dispose();
}
}// ❌ Array approach - error prone and messy
class AuthComponent {
#componentCleanups = [];
#formCleanups = [];
initialize() {
this.#componentCleanups.push(() => userProfile.unsubscribe());
this.#componentCleanups.push(() => validator.destroy());
}
showLoginForm() {
// Manual cleanup - easy to forget
this.#formCleanups.forEach(fn => fn());
this.#formCleanups = [];
// Add new cleanups
this.#formCleanups.push(() => loginButton.removeEventListener(...));
this.#formCleanups.push(() => loginFormSignal.unsubscribe());
// Wait, where do validation cleanups go?
// Need another array? #validationCleanups?
// What about nested wizard steps? #wizardStepCleanups?
// This gets messy fast...
}
showForgotPasswordForm() {
// Remember to cleanup, every time, manually
this.#formCleanups.forEach(fn => fn());
this.#formCleanups = [];
this.#formCleanups.push(() => resetButton.removeEventListener(...));
// Easy to forget cleanups or mix up arrays
}
terminate() {
// Cleanup in correct order? Hope you remembered everything
this.#formCleanups.forEach(fn => fn());
this.#componentCleanups.forEach(fn => fn());
// Did we cleanup validation? Nested scopes? Who knows!
}
}// ✅ ManagedResourceScope - organized and automatic
class AuthComponent {
#componentScope;
#userFormScope;
initialize() {
this.#componentScope = app.disposer.scope('auth-component');
this.#componentScope.add(() => userProfile.unsubscribe());
this.#componentScope.add(() => validator.destroy());
}
showLoginForm() {
// One line - cleans everything nested inside
this.#userFormScope?.dispose();
this.#userFormScope = this.#componentScope.scope('loginForm');
this.#userFormScope.add(() => loginButton.removeEventListener(...));
// Nest as deep as needed - cleanup is automatic
const validation = this.#userFormScope.scope('validation');
validation.add(() => clearValidationUI());
const wizard = validation.scope('wizard-step-2');
wizard.add(() => wizardCleanup());
// All cleaned when userFormScope.dispose() is called
}
terminate() {
// One line - guarantees everything is cleaned
// componentScope → userFormScope → validation → wizard-step-2
// All disposed in correct order (depth-first)
this.#componentScope.dispose();
}
}Arrays force you to manually track and cleanup parallel hierarchies. Scopes give you a tree structure that matches your component's actual lifecycle.
When you call componentScope.dispose():
loginFormscope is disposed first (and all its children)- Then
componentScope's own resources are disposed - You can trust every nested scope is cleaned up - no manual tracking needed
This is especially critical in:
- Multi-step wizards - each step creates/destroys scopes
- Tabbed interfaces - tabs have different resource lifetimes
- Dynamic forms - fields appear/disappear based on user input
- Modal/dialog chains - modals can spawn other modals
With arrays, you'd need separate arrays for each level and manual coordination. With scopes, the tree structure does the work for you.
MIT