watch-selector
v2.3.0
Published
Runs a function when a selector is added to dom
Maintainers
Readme
Watch 🕶️
A type-safe DOM observation library that keeps your JavaScript working when the HTML changes.
Ever tried adding interactivity to a server-rendered site? You write event listeners, but then the DOM updates and your JavaScript stops working. Or you need different behavior for each instance of an element, but managing that state gets messy fast.
Watch solves this by letting you attach persistent behaviors to CSS selectors. When new elements match your selector, they automatically get the behavior. When they're removed, everything cleans up automatically.
Perfect for: Server-rendered sites, Chrome extensions, e-commerce templates, htmx apps, and anywhere you don't control the markup.
The Problem Watch Solves
Traditional DOM manipulation breaks when content changes:
// ❌ This stops working when buttons are re-rendered
document.querySelectorAll('button').forEach(btn => {
let clicks = 0;
btn.addEventListener('click', () => {
clicks++; // State is lost if button is removed/added
btn.textContent = `Clicked ${clicks} times`;
});
});Server-rendered sites, Chrome extensions, and dynamic content make this worse. You need:
- Persistent behavior that survives DOM changes
- Instance-specific state for each element
- Automatic cleanup to prevent memory leaks
- Type safety so you know what elements you're working with
Watch handles all of this automatically.
Table of Contents
- Quick Start
- Why Choose Watch?
- Installation
- Documentation
- Core Concepts
- Real-World Examples
- Advanced Features
- Complete API Reference
- Performance & Browser Support
- Frequently Asked Questions
- License
Quick Start
import { watch, click, text } from 'watch-selector';
// Main API - Make all buttons interactive
watch('button', function* () {
yield* click(() => {
yield* text('Button clicked!');
});
});
// Enhanced API - Direct function calls, cleaner syntax
watch('.counter-btn', function* (ctx) {
let count = 0;
yield* ctx.click(function* () {
count++;
ctx.text(`Clicked ${count} times`);
ctx.addClass('clicked');
});
});That's it! Watch handles all the DOM observation, state management, and cleanup automatically.
Why Choose Watch?
🔍 Persistent Element Behavior
Your code keeps working even when the DOM changes:
// Traditional approach breaks when elements are added/removed
document.querySelectorAll('button').forEach(btn => {
btn.addEventListener('click', handler); // Lost if button is re-rendered
});
// Watch approach persists automatically
watch('button', function* () {
yield* click(handler); // Works for all buttons, present and future
});🎯 Type-Safe by Design
TypeScript knows what element you're working with:
watch('input[type="email"]', function* () {
// TypeScript knows this is HTMLInputElement
yield on('blur', () => {
if (!self().value.includes('@')) { // .value is typed
yield addClass('error');
}
});
});🧠 Element-Scoped State
Each element gets its own isolated state:
watch('.counter', function* () {
let count = 0; // This variable is unique to each counter element
yield click(() => {
count++; // Each counter maintains its own count
yield text(`Count: ${count}`);
});
});🔄 Works Both Ways
Functions work directly on elements and in generators:
// Direct usage
const button = document.querySelector('button');
text(button, 'Hello');
// Generator usage
watch('button', function* () {
yield text('Hello');
});⚡ High Performance
- Single global observer for the entire application
- Efficient batch processing of DOM changes
- Automatic cleanup prevents memory leaks
- Minimal memory footprint with WeakMap storage
Installation
npm/pnpm/yarn
npm install watch-selectorESM CDN (no build required)
import { watch } from 'https://esm.sh/watch-selector';
// Start using immediately
watch('button', function* () {
yield click(() => console.log('Hello from CDN!'));
});Documentation
📚 Comprehensive Guides
- API Reference - Complete documentation of all functions, types, and patterns
- Quick Reference - Concise guide to commonly used functions
- Type Definitions - Full TypeScript type reference
- Explicit API Spec - Specification for non-overloaded functions
- Examples - Real-world usage examples and patterns
🚀 Getting Started
The library provides multiple ways to interact with the DOM:
- Direct manipulation - Call functions directly on elements
- CSS selectors - Target elements by selector strings
- Generator functions - Compose behaviors with
yield - Async generators - Use
yield*for type-safe async flows - Pure workflows - Import from
/generatorfor clean syntax
// All these patterns are supported:
import { text, addClass } from 'watch-selector';
// Direct element manipulation
const button = document.querySelector('button');
text(button, 'Click me!');
// CSS selector manipulation
text('#my-button', 'Click me!');
// Generator pattern with yield*
watch('button', function* () {
yield* text('Click me!');
yield* addClass('interactive');
});
// Direct yield* - no $ helper needed
watch('button', function* () {
yield* text('Click me!');
yield* addClass('interactive');
});
// Enhanced context with direct method calls
watch('button', function* (ctx) {
ctx.text('Click me!');
ctx.addClass('interactive');
// Event handlers still use yield*
yield* ctx.click(function* () {
ctx.toggleClass('clicked');
});
});🎯 Three API Styles + Enhanced Context
Watch Selector offers three distinct API styles to match your preferences, plus an enhanced context option for maximum ergonomics:
1. Overloaded API (Default)
The flexible, context-aware API with intelligent overloading:
import { text, addClass, click } from 'watch-selector';
// Functions adapt to context
text(element, 'Hello'); // Direct element
text('#button', 'Hello'); // Selector
yield* text('Hello'); // Generator with type safety2. Explicit API
Clear, unambiguous function names that specify exactly what they do:
import * as explicit from 'watch-selector/explicit';
explicit.setTextElement(element, 'Hello'); // Set text on element
explicit.setTextFirst('#button', 'Hello'); // Set text on first match
explicit.setTextAll('.items', 'Updated'); // Set text on all matches
explicit.getTextElement(element); // Get text from element3. Fluent API
jQuery-like chainable interface for elegant DOM manipulation:
import { selector, element, $ } from 'watch-selector/fluent';
selector('#button')
.text('Click me!')
.addClass('primary', 'large')
.style('backgroundColor', 'blue')
.click(() => console.log('Clicked!'));
// Or use $ for jQuery familiarity
$('.items')
.addClass('found')
.each((el, i) => console.log(`Item ${i}:`, el));4. Enhanced Context API (Recommended)
The most ergonomic option with direct function calls:
import { watch } from 'watch-selector';
watch('button', function* (ctx) {
ctx.text('Click me!'); // Direct calls - no yield* needed
ctx.addClass('interactive'); // Clean, readable syntax
yield* ctx.click(function* () { // Event handlers still use yield*
ctx.toggleClass('clicked');
});
});Choose the style that best fits your needs:
- Enhanced Context: Maximum ergonomics with direct calls (recommended)
- Overloaded: Maximum flexibility and conciseness
- Explicit: Crystal clear intent, no ambiguity
- Fluent: Elegant chaining for multiple operations
See examples/api-comparison.ts for detailed comparisons.
Core Concepts
Watchers
Observe DOM elements and run generators when they appear:
const controller = watch('selector', function* () {
// This runs for each matching element
yield elementFunction;
});Generators & Yield
Generators create persistent contexts that survive DOM changes:
watch('.component', function* () {
let state = 0; // Persists for each element's lifetime
yield* click(() => {
state++; // State is maintained across events
yield* text(`State: ${state}`);
});
// Cleanup happens automatically when element is removed
});Why generators? They provide:
- Persistent execution context that lives with the element
- Declarative behavior through yield statements
- Automatic cleanup when elements are removed
- Composable patterns for building complex behaviors
Element Context
Access the current element and its state:
watch('.counter', function* () {
const counter = createState('count', 0);
const element = self(); // Get current element
yield* click(() => {
counter.update(c => c + 1);
yield* text(`Count: ${counter.get()}`);
});
});Context Parameter
Generators can optionally receive a context parameter for enhanced ergonomics:
// Traditional approach (still works)
watch('button', function* () {
const element = self();
yield* click(() => console.log('Clicked!'));
});
// New context parameter approach
watch('button', function* (ctx) {
const element = ctx.self(); // Direct access via context
yield* click(() => console.log('Clicked!'));
});Enhanced Context API
For the most ergonomic experience, use watch with direct function calls:
import { watch } from 'watch-selector';
watch('button', function* (ctx) {
// DOM functions work as direct synchronous calls
ctx.text('Click me!');
ctx.addClass('interactive');
const parent = ctx.parent(); // Direct return value
const children = ctx.children('.child'); // Direct return value
// State management with direct calls
ctx.setState('count', 0);
const count = ctx.getState('count', 0);
// Event handlers still use yield* for generator composition
yield* ctx.click(function* () {
const newCount = ctx.getState('count', 0) + 1;
ctx.setState('count', newCount);
ctx.text(`Clicked ${newCount} times`);
ctx.toggleClass('active');
});
});Enhanced Context Benefits:
- Direct synchronous calls - No
yield*needed for DOM/state functions - Better discoverability - TypeScript autocomplete shows all available methods
- Cleaner code - More readable without yield* everywhere
- Type safety - Full TypeScript inference for all return values
- Mixed patterns - Event handlers still use generators for composition
API Comparison:
// Main API - uses yield*
watch('button', function* () {
yield* text('Hello');
const content = yield* text();
yield* addClass('active');
});
// Enhanced API - direct calls
watch('button', function* (ctx) {
ctx.text('Hello');
const content = ctx.text();
ctx.addClass('active');
});State Management
Type-safe, element-scoped reactive state:
const counter = createState('count', 0);
const doubled = createComputed(() => counter.get() * 2, ['count']);
watchState('count', (newVal, oldVal) => {
console.log(`${oldVal} → ${newVal}`);
});Real-World Examples
E-commerce Product Cards
watch('.product-card', function* () {
const inCart = createState('inCart', false);
yield click('.add-to-cart', () => {
inCart.set(true);
yield text('.add-to-cart', 'Added to Cart!');
yield addClass('in-cart');
});
yield click('.remove-from-cart', () => {
inCart.set(false);
yield text('.add-to-cart', 'Add to Cart');
yield removeClass('in-cart');
});
});Form Validation
watch('input[required]', function* () {
yield on('blur', () => {
if (!self().value.trim()) {
yield addClass('error');
yield text('.error-message', 'This field is required');
} else {
yield removeClass('error');
yield text('.error-message', '');
}
});
});Dynamic Content Loading
watch('.lazy-content', async function* () {
yield text('Loading...');
yield onVisible(async () => {
const response = await fetch(self().dataset.url);
const html = await response.text();
yield html(html);
});
});Advanced Features
Advanced Composition: Controllers & Behavior Layering
Watch introduces WatchController objects that transform the traditional fire-and-forget watch operations into managed, extensible systems. Controllers enable Behavior Layering - the ability to add multiple independent behaviors to the same set of elements.
WatchController Fundamentals
Every watch() call returns a WatchController instead of a simple cleanup function:
import { watch, layer, getInstances, destroy } from 'watch-selector';
// Basic controller usage
const cardController = watch('.product-card', function* () {
// Core business logic
const inCart = createState('inCart', false);
yield on('click', '.add-btn', () => inCart.set(true));
yield text('Add to Cart');
});
// The controller provides a handle to the watch operation
console.log(`Watching ${cardController.getInstances().size} product cards`);Behavior Layering
Add multiple independent behaviors to the same elements:
// Layer 1: Core functionality
const cardController = watch('.product-card', function* () {
const inCart = createState('inCart', false);
yield on('click', '.add-btn', () => inCart.set(true));
});
// Layer 2: Analytics (added later, different module)
cardController.layer(function* () {
yield onVisible(() => analytics.track('product-view', {
id: self().dataset.productId
}));
});
// Layer 3: Animations (added conditionally)
if (enableAnimations) {
cardController.layer(function* () {
yield watchState('inCart', (inCart) => {
if (inCart) {
yield addClass('animate-add-to-cart');
}
});
});
}Dual API: Methods vs Functions
Controllers support both object-oriented and functional patterns:
// Method-based (OOP style)
const controller = watch('.component', baseGenerator);
controller.layer(enhancementGenerator);
controller.layer(analyticsGenerator);
const instances = controller.getInstances();
controller.destroy();
// Function-based (FP style)
const controller = watch('.component', baseGenerator);
layer(controller, enhancementGenerator);
layer(controller, analyticsGenerator);
const instances = getInstances(controller);
destroy(controller);Instance Introspection
Controllers provide read-only access to managed instances:
const controller = watch('button', function* () {
const clickCount = createState('clicks', 0);
yield click(() => clickCount.update(n => n + 1));
});
// Inspect all managed instances
const instances = controller.getInstances();
instances.forEach((instance, element) => {
console.log(`Button ${element.id}:`, instance.getState());
});
// State is read-only from the outside
const buttonState = instances.get(someButton)?.getState();
// { clicks: 5 } - snapshot of current stateReal-World Example: Composable Product Cards
This example demonstrates how behavior layering enables clean separation of concerns:
// --- Core product card functionality ---
// File: components/product-card.ts
export const productController = watch('.product-card', function* () {
const inCart = createState('inCart', false);
const quantity = createState('quantity', 1);
yield on('click', '.add-btn', () => {
inCart.set(true);
// Update cart through global state or API
});
yield on('click', '.quantity-btn', (e) => {
const delta = e.target.dataset.delta;
quantity.update(q => Math.max(1, q + parseInt(delta)));
});
});
// --- Analytics layer ---
// File: analytics/product-tracking.ts
import { productController } from '../components/product-card';
productController.layer(function* () {
// Track product views
yield onVisible(() => {
analytics.track('product_viewed', {
product_id: self().dataset.productId,
category: self().dataset.category
});
});
// Track cart additions
yield watchState('inCart', (inCart, wasInCart) => {
if (inCart && !wasInCart) {
analytics.track('product_added_to_cart', {
product_id: self().dataset.productId,
quantity: getState('quantity')
});
}
});
});
// --- Animation layer ---
// File: animations/product-animations.ts
import { productController } from '../components/product-card';
productController.layer(function* () {
// Animate cart additions
yield watchState('inCart', (inCart) => {
if (inCart) {
yield addClass('animate-add-to-cart');
yield delay(300);
yield removeClass('animate-add-to-cart');
}
});
// Hover effects
yield on('mouseenter', () => yield addClass('hover-highlight'));
yield on('mouseleave', () => yield removeClass('hover-highlight'));
});
// --- Usage in main application ---
// File: main.ts
import './components/product-card';
import './analytics/product-tracking';
import './animations/product-animations';
// All layers are automatically active
// Analytics and animations are completely independent
// Each can be enabled/disabled or modified without affecting othersState Communication Between Layers
Layers communicate through shared element state:
// Layer 1: Set up shared state
const formController = watch('form', function* () {
const isValid = createState('isValid', false);
const errors = createState('errors', []);
yield on('input', () => {
const validation = validateForm(self());
isValid.set(validation.isValid);
errors.set(validation.errors);
});
});
// Layer 2: React to validation state
formController.layer(function* () {
yield watchState('isValid', (valid) => {
yield toggleClass('form-invalid', !valid);
});
yield watchState('errors', (errors) => {
yield text('.error-display', errors.join(', '));
});
});
// Layer 3: Conditional behavior based on state
formController.layer(function* () {
yield on('submit', (e) => {
if (!getState('isValid')) {
e.preventDefault();
yield addClass('shake-animation');
}
});
});Controller Lifecycle Management
Controllers are singleton instances per target - calling watch() multiple times with the same selector returns the same controller:
// These all return the same controller instance
const controller1 = watch('.my-component', generator1);
const controller2 = watch('.my-component', generator2); // Same controller!
const controller3 = watch('.my-component', generator3); // Same controller!
// All generators are layered onto the same controller
console.log(controller1 === controller2); // true
console.log(controller1 === controller3); // true
// Clean up destroys all layers
controller1.destroy(); // Removes all behaviors for '.my-component'Integration with Scoped Utilities
Controllers work seamlessly with scoped watchers:
// Create a scoped controller
const container = document.querySelector('#dashboard');
const scopedController = scopedWatchWithController(container, '.widget', function* () {
yield addClass('widget-base');
});
// Layer additional behaviors on the scoped controller
scopedController.controller.layer(function* () {
yield addClass('widget-enhanced');
yield on('click', () => console.log('Scoped widget clicked'));
});
// Inspect scoped instances
const scopedInstances = scopedController.controller.getInstances();
console.log(`Managing ${scopedInstances.size} widgets in container`);Component Composition: Building Hierarchies
Watch supports full parent-child component communication, allowing you to build complex, nested, and encapsulated UIs with reactive relationships.
Child-to-Parent: Exposing APIs with createChildWatcher
A child component can return an API from its generator. The parent can then use createChildWatcher to get a live collection of these APIs.
Child Component
// Counter button that exposes an API
function* counterButton() {
let count = 0;
// Set initial text and handle clicks
yield text(`Count: ${count}`);
yield click(() => {
count++;
yield text(`Count: ${count}`);
});
// Define and return a public API
return {
getCount: () => count,
reset: () => {
count = 0;
yield text(`Count: ${count}`);
console.log(`Button ${self().id} was reset.`);
},
increment: () => {
count++;
yield text(`Count: ${count}`);
}
};
}Parent Component
import { watch, child, click } from 'watch-selector';
watch('.button-container', function*() {
// `childApis` is a reactive Map: Map<HTMLButtonElement, { getCount, reset, increment }>
const childApis = child('button.counter', counterButton);
// Parent can interact with children's APIs
yield click('.reset-all-btn', () => {
console.log('Resetting all child buttons...');
for (const api of childApis.values()) {
api.reset();
}
});
yield click('.sum-btn', () => {
const total = Array.from(childApis.values()).reduce((sum, api) => sum + api.getCount(), 0);
console.log(`Total count across all buttons: ${total}`);
});
});Parent-to-Child: Accessing the Parent with getParentContext
A child can access its parent's context and API, creating a top-down communication channel.
Parent Component
watch('form#main-form', function*() {
const isValid = createState('valid', false);
// Form validation logic...
// The parent's API
return {
submitForm: () => {
if (isValid.get()) {
self().submit();
}
},
isValid: () => isValid.get()
};
});Child Component (inside the form)
import { getParentContext, on, self } from 'watch-selector';
watch('input.submit-on-enter', function*() {
// Get the parent form's context and API with full type safety
const parentForm = getParentContext<HTMLFormElement, {
submitForm: () => void;
isValid: () => boolean
}>();
yield on('keydown', (e) => {
if (e.key === 'Enter' && parentForm) {
e.preventDefault();
if (parentForm.api.isValid()) {
parentForm.api.submitForm(); // Call the parent's API method
}
}
});
});Real-World Example: Interactive Counter Dashboard
// Child counter component
function* counterWidget() {
let count = 0;
const startTime = Date.now();
yield addClass('counter-widget');
yield text(`Count: ${count}`);
yield click(() => {
count++;
yield text(`Count: ${count}`);
yield addClass('updated');
setTimeout(() => yield removeClass('updated'), 200);
});
// Public API for parent interaction
return {
getCount: () => count,
getRate: () => count / ((Date.now() - startTime) / 1000),
reset: () => {
count = 0;
yield text(`Count: ${count}`);
},
setCount: (newCount: number) => {
count = newCount;
yield text(`Count: ${count}`);
}
};
}
// Parent dashboard component
function* counterDashboard() {
const counters = child('.counter', counterWidget);
// Dashboard controls
yield click('.reset-all', () => {
counters.forEach(api => api.reset());
});
yield click('.show-stats', () => {
const stats = Array.from(counters.values()).map(api => ({
count: api.getCount(),
rate: api.getRate()
}));
console.log('Dashboard stats:', stats);
});
yield click('.distribute-evenly', () => {
const total = Array.from(counters.values()).reduce((sum, api) => sum + api.getCount(), 0);
const perCounter = Math.floor(total / counters.size);
counters.forEach(api => api.setCount(perCounter));
});
// Parent API
return {
getTotalCount: () => Array.from(counters.values()).reduce((sum, api) => sum + api.getCount(), 0),
getCounterCount: () => counters.size,
resetAll: () => counters.forEach(api => api.reset())
};
}
// Usage
watch('.dashboard', counterDashboard);Building Higher-Level Abstractions
Watch's primitive functions are designed to be composable building blocks for more sophisticated abstractions. You can integrate templating engines, routing libraries, state management solutions, and domain-specific tools while maintaining Watch's ergonomic patterns.
Writing Custom Abstractions
The key to building great abstractions with Watch is following the established patterns:
1. Dual API Pattern
Make your functions work both directly and in generators:
// Custom templating integration
export function template(templateOrElement: string | HTMLElement, data?: any): any {
// Direct usage
if (arguments.length === 2 && (typeof templateOrElement === 'string' || templateOrElement instanceof HTMLElement)) {
const element = resolveElement(templateOrElement);
if (element) {
element.innerHTML = renderTemplate(templateOrElement as string, data);
}
return;
}
// Generator usage
if (arguments.length === 1) {
const templateStr = templateOrElement as string;
return ((element: HTMLElement) => {
element.innerHTML = renderTemplate(templateStr, data || {});
}) as ElementFn<HTMLElement>;
}
// Selector + data usage
const [templateStr, templateData] = arguments;
return ((element: HTMLElement) => {
element.innerHTML = renderTemplate(templateStr, templateData);
}) as ElementFn<HTMLElement>;
}
// Usage examples
const element = document.querySelector('.content');
template(element, '<h1>{{title}}</h1>', { title: 'Hello' });
// Or in generators
watch('.dynamic-content', function* () {
yield template('<div>{{message}}</div>', { message: 'Dynamic!' });
});2. Context-Aware Functions
Create functions that understand the current element context:
// Custom router integration
export function route(pattern: string, handler: () => void): ElementFn<HTMLElement> {
return (element: HTMLElement) => {
const currentPath = window.location.pathname;
const matches = matchRoute(pattern, currentPath);
if (matches) {
// Store route params in element context
if (!element.dataset.routeParams) {
element.dataset.routeParams = JSON.stringify(matches.params);
}
handler();
}
};
}
// Route parameters helper
export function routeParams<T = Record<string, string>>(): T {
const element = self();
const params = element.dataset.routeParams;
return params ? JSON.parse(params) : {};
}
// Usage
watch('[data-route]', function* () {
yield route('/users/:id', () => {
const { id } = routeParams<{ id: string }>();
yield template('<div>User ID: {{id}}</div>', { id });
});
});3. State Integration
Build abstractions that work with Watch's state system:
// Custom form validation abstraction
export function validateForm(schema: ValidationSchema): ElementFn<HTMLFormElement> {
return (form: HTMLFormElement) => {
const errors = createState('validation-errors', {});
const isValid = createComputed(() => Object.keys(errors.get()).length === 0, ['validation-errors']);
// Validate on input changes
const inputs = form.querySelectorAll('input, select, textarea');
inputs.forEach(input => {
input.addEventListener('blur', () => {
const fieldErrors = validateField(input.name, input.value, schema);
errors.update(current => ({
...current,
[input.name]: fieldErrors
}));
});
});
// Expose validation state
form.dataset.valid = isValid().toString();
};
}
// Usage
watch('form.needs-validation', function* () {
yield validateForm({
email: { required: true, email: true },
password: { required: true, minLength: 8 }
});
yield submit((e) => {
const isValid = getState('validation-errors');
if (Object.keys(isValid).length > 0) {
e.preventDefault();
}
});
});Templating Engine Integration
Here's how to integrate popular templating engines:
Handlebars Integration
import Handlebars from 'handlebars';
// Create a templating abstraction
export function handlebars(templateSource: string, data?: any): ElementFn<HTMLElement>;
export function handlebars(element: HTMLElement, templateSource: string, data: any): void;
export function handlebars(...args: any[]): any {
if (args.length === 3) {
// Direct usage: handlebars(element, template, data)
const [element, templateSource, data] = args;
const template = Handlebars.compile(templateSource);
element.innerHTML = template(data);
return;
}
if (args.length === 2) {
// Generator usage: yield handlebars(template, data)
const [templateSource, data] = args;
return (element: HTMLElement) => {
const template = Handlebars.compile(templateSource);
element.innerHTML = template(data);
};
}
// Template only - data from state
const [templateSource] = args;
return (element: HTMLElement) => {
const template = Handlebars.compile(templateSource);
const data = getAllState(); // Get all state as template context
element.innerHTML = template(data);
};
}
// Helper for reactive templates
export function reactiveTemplate(templateSource: string, dependencies: string[]): ElementFn<HTMLElement> {
return (element: HTMLElement) => {
const template = Handlebars.compile(templateSource);
const render = () => {
const data = getAllState();
element.innerHTML = template(data);
};
// Re-render when dependencies change
dependencies.forEach(dep => {
watchState(dep, render);
});
// Initial render
render();
};
}
// Usage
watch('.user-profile', function* () {
const user = createState('user', { name: 'John', email: '[email protected]' });
// Template updates automatically when user state changes
yield reactiveTemplate(`
<h2>{{user.name}}</h2>
<p>{{user.email}}</p>
`, ['user']);
yield click('.edit-btn', () => {
user.update(u => ({ ...u, name: 'Jane' }));
});
});Lit-html Integration
import { html, render } from 'lit-html';
export function litTemplate(template: TemplateResult): ElementFn<HTMLElement>;
export function litTemplate(element: HTMLElement, template: TemplateResult): void;
export function litTemplate(...args: any[]): any {
if (args.length === 2) {
const [element, template] = args;
render(template, element);
return;
}
const [template] = args;
return (element: HTMLElement) => {
render(template, element);
};
}
// Usage with reactive updates
watch('.todo-list', function* () {
const todos = createState('todos', [
{ id: 1, text: 'Learn Watch', done: false },
{ id: 2, text: 'Build something awesome', done: false }
]);
// Template function that uses current state
const todoTemplate = () => html`
<ul>
${todos.get().map(todo => html`
<li class="${todo.done ? 'done' : ''}">
<input type="checkbox" .checked=${todo.done}
@change=${() => toggleTodo(todo.id)}>
${todo.text}
</li>
`)}
</ul>
`;
// Re-render when todos change
watchState('todos', () => {
yield litTemplate(todoTemplate());
});
// Initial render
yield litTemplate(todoTemplate());
});Router Integration
Create routing abstractions that work seamlessly with Watch:
// Simple router abstraction
class WatchRouter {
private routes = new Map<string, RouteHandler>();
route(pattern: string, handler: RouteHandler): ElementFn<HTMLElement> {
this.routes.set(pattern, handler);
return (element: HTMLElement) => {
const checkRoute = () => {
const path = window.location.pathname;
const match = this.matchRoute(pattern, path);
if (match) {
// Store route context
element.dataset.routeParams = JSON.stringify(match.params);
element.dataset.routeQuery = JSON.stringify(match.query);
// Execute handler with route context
handler(match);
}
};
// Check on load and route changes
checkRoute();
window.addEventListener('popstate', checkRoute);
// Cleanup
cleanup(() => {
window.removeEventListener('popstate', checkRoute);
});
};
}
private matchRoute(pattern: string, path: string) {
// Route matching logic...
return { params: {}, query: {} };
}
}
const router = new WatchRouter();
// Route-aware helper functions
export function routeParams<T = Record<string, any>>(): T {
const element = self();
const params = element.dataset.routeParams;
return params ? JSON.parse(params) : {};
}
export function routeQuery<T = Record<string, any>>(): T {
const element = self();
const query = element.dataset.routeQuery;
return query ? JSON.parse(query) : {};
}
export const route = router.route.bind(router);
// Usage
watch('[data-route="/users/:id"]', function* () {
yield route('/users/:id', ({ params }) => {
const userId = params.id;
// Load user data
const user = createState('user', null);
fetch(`/api/users/${userId}`)
.then(r => r.json())
.then(userData => user.set(userData));
// Reactive template
watchState('user', (userData) => {
if (userData) {
yield handlebars(`
<div class="user-profile">
<h1>{{name}}</h1>
<p>{{email}}</p>
</div>
`, userData);
}
});
});
});State Management Integration
Integrate with external state management libraries:
// Redux integration
import { Store } from 'redux';
export function connectRedux<T>(
store: Store<T>,
selector: (state: T) => any,
mapDispatchToProps?: any
): ElementFn<HTMLElement> {
return (element: HTMLElement) => {
let currentValue = selector(store.getState());
const handleChange = () => {
const newValue = selector(store.getState());
if (newValue !== currentValue) {
currentValue = newValue;
// Update element state
setState('redux-state', newValue);
}
};
const unsubscribe = store.subscribe(handleChange);
// Initial state
setState('redux-state', currentValue);
// Provide dispatch function
if (mapDispatchToProps) {
const dispatchers = mapDispatchToProps(store.dispatch);
setState('redux-dispatch', dispatchers);
}
cleanup(() => unsubscribe());
};
}
// Usage
watch('.connected-component', function* () {
yield connectRedux(
store,
state => state.user,
dispatch => ({
updateUser: (user) => dispatch({ type: 'UPDATE_USER', user })
})
);
// Use Redux state in templates
watchState('redux-state', (user) => {
yield template('<div>Hello {{name}}</div>', user);
});
yield click('.update-btn', () => {
const { updateUser } = getState('redux-dispatch');
updateUser({ name: 'New Name' });
});
});Domain-Specific Abstractions
Create specialized tools for specific use cases:
// E-commerce specific abstractions
export function cart(): ElementFn<HTMLElement> {
return (element: HTMLElement) => {
const items = createState('cart-items', []);
const total = createComputed(
() => items.get().reduce((sum, item) => sum + item.price * item.quantity, 0),
['cart-items']
);
// Expose cart API globally
window.cart = {
add: (item) => items.update(current => [...current, item]),
remove: (id) => items.update(current => current.filter(i => i.id !== id)),
getTotal: () => total()
};
};
}
export function addToCart(productId: string, price: number): ElementFn<HTMLButtonElement> {
return (button: HTMLButtonElement) => {
button.addEventListener('click', () => {
window.cart.add({ id: productId, price, quantity: 1 });
// Visual feedback
addClass(button, 'added');
setTimeout(() => removeClass(button, 'added'), 1000);
});
};
}
// Data fetching abstraction
export function fetchData<T>(
url: string,
options?: RequestInit
): ElementFn<HTMLElement> {
return (element: HTMLElement) => {
const data = createState<T | null>('fetch-data', null);
const loading = createState('fetch-loading', true);
const error = createState<Error | null>('fetch-error', null);
fetch(url, options)
.then(response => response.json())
.then(result => {
data.set(result);
loading.set(false);
})
.catch(err => {
error.set(err);
loading.set(false);
});
};
}
// Usage of domain abstractions
watch('.product-page', function* () {
// Initialize cart
yield cart();
// Fetch product data
yield fetchData('/api/products/123');
// Reactive content based on loading state
watchState('fetch-loading', (isLoading) => {
if (isLoading) {
yield template('<div class="loading">Loading...</div>');
}
});
// Reactive content based on data
watchState('fetch-data', (product) => {
if (product) {
yield template(`
<div class="product">
<h1>{{name}}</h1>
<p>{{description}}</p>
<span class="price">${{price}}</span>
<button class="add-to-cart">Add to Cart</button>
</div>
`, product);
}
});
// Add to cart functionality
yield click('.add-to-cart', () => {
const product = getState('fetch-data');
yield addToCart(product.id, product.price);
});
});Creating Reusable Component Libraries
Build component libraries that follow Watch's patterns:
// UI Component library built on Watch
export const UI = {
// Modal component
modal(options: { title?: string, closable?: boolean } = {}): ElementFn<HTMLElement> {
return (element: HTMLElement) => {
const isOpen = createState('modal-open', false);
// Setup modal structure
yield template(`
<div class="modal-backdrop" style="display: none;">
<div class="modal-content">
${options.title ? `<h2>${options.title}</h2>` : ''}
<div class="modal-body"></div>
${options.closable ? '<button class="modal-close">×</button>' : ''}
</div>
</div>
`);
// Show/hide logic
watchState('modal-open', (open) => {
const backdrop = el('.modal-backdrop');
if (backdrop) {
backdrop.style.display = open ? 'flex' : 'none';
}
});
if (options.closable) {
yield click('.modal-close', () => {
isOpen.set(false);
});
}
// Expose modal API
return {
open: () => isOpen.set(true),
close: () => isOpen.set(false),
toggle: () => isOpen.update(current => !current)
};
};
},
// Tabs component
tabs(): ElementFn<HTMLElement> {
return (element: HTMLElement) => {
const activeTab = createState('active-tab', 0);
// Setup tab navigation
const tabButtons = all('.tab-button');
const tabPanels = all('.tab-panel');
tabButtons.forEach((button, index) => {
button.addEventListener('click', () => {
activeTab.set(index);
});
});
// Show/hide panels based on active tab
watchState('active-tab', (active) => {
tabPanels.forEach((panel, index) => {
panel.style.display = index === active ? 'block' : 'none';
});
tabButtons.forEach((button, index) => {
button.classList.toggle('active', index === active);
});
});
};
}
};
// Usage
watch('.my-modal', function* () {
const modalApi = yield UI.modal({ title: 'Settings', closable: true });
yield click('.open-modal', () => {
modalApi.open();
});
});
watch('.tab-container', function* () {
yield UI.tabs();
});Best Practices for Abstractions
- Follow the Dual API Pattern: Make functions work both directly and in generators
- Use Element-Scoped State: Keep component state isolated per element instance
- Provide Type Safety: Use TypeScript generics and proper typing
- Compose with Existing Functions: Build on Watch's primitive functions
- Handle Cleanup: Always clean up external resources
- Maintain Context: Use
self(),el(), and context functions appropriately - Return APIs: Let components expose public interfaces through return values
This approach lets you build powerful, domain-specific libraries while maintaining Watch's ergonomic patterns and type safety guarantees.
Generator Abstractions: When to Wrap the Generator Itself
Sometimes you need to wrap or transform the generator pattern itself, not just individual functions. This is useful for cross-cutting concerns, meta-functionality, and standardizing behaviors across components.
When to Use Generator Abstractions vs Function Abstractions
Use Function Abstractions When:
- Adding specific functionality (templating, validation, etc.)
- Building domain-specific operations
- Creating reusable behaviors
- Extending the dual API pattern
Use Generator Abstractions When:
- Adding cross-cutting concerns (logging, performance, error handling)
- Standardizing component patterns across teams
- Injecting behavior into ALL components
- Creating meta-frameworks or higher-level patterns
- Managing component lifecycles uniformly
Performance Monitoring Generator
// Wraps any generator to add performance monitoring
export function withPerformanceMonitoring<T extends HTMLElement>(
name: string,
generator: () => Generator<ElementFn<T>, any, unknown>
): () => Generator<ElementFn<T>, any, unknown> {
return function* () {
const startTime = performance.now();
console.log(`🚀 Component "${name}" starting...`);
try {
// Execute the original generator
const originalGen = generator();
let result = originalGen.next();
while (!result.done) {
// Time each yielded operation
const opStart = performance.now();
yield result.value;
const opEnd = performance.now();
// Log slow operations
if (opEnd - opStart > 10) {
console.warn(`⚠️ Slow operation in "${name}": ${opEnd - opStart}ms`);
}
result = originalGen.next();
}
const endTime = performance.now();
console.log(`✅ Component "${name}" initialized in ${endTime - startTime}ms`);
return result.value; // Return the original generator's return value
} catch (error) {
const endTime = performance.now();
console.error(`❌ Component "${name}" failed after ${endTime - startTime}ms:`, error);
throw error;
}
};
}
// Usage
const monitoredButton = withPerformanceMonitoring('InteractiveButton', function* () {
yield addClass('interactive');
yield click(() => console.log('Clicked!'));
return {
disable: () => yield addClass('disabled')
};
});
watch('button', monitoredButton);Error Boundary Generator
// Wraps generators with error handling and fallback UI
export function withErrorBoundary<T extends HTMLElement>(
generator: () => Generator<ElementFn<T>, any, unknown>,
fallbackContent?: string,
onError?: (error: Error, element: T) => void
): () => Generator<ElementFn<T>, any, unknown> {
return function* () {
try {
yield* generator();
} catch (error) {
console.error('Component error:', error);
// Show fallback UI
if (fallbackContent) {
yield text(fallbackContent);
yield addClass('error-state');
}
// Call custom error handler
if (onError) {
const element = self() as T;
onError(error as Error, element);
}
// Return safe fallback API
return {
hasError: true,
retry: () => {
// Could implement retry logic here
window.location.reload();
}
};
}
};
}
// Usage
const safeComponent = withErrorBoundary(
function* () {
// This might throw an error
const data = JSON.parse(self().dataset.config || '');
yield template('<div>{{message}}</div>', data);
throw new Error('Something went wrong!'); // Simulated error
},
'Something went wrong. Please try again.',
(error, element) => {
// Send error to logging service
console.error('Logging error for element:', element.id, error);
}
);
watch('.risky-component', safeComponent);Feature Flag Generator
// Wraps generators with feature flag checks
export function withFeatureFlag<T extends HTMLElement>(
flagName: string,
generator: () => Generator<ElementFn<T>, any, unknown>,
fallbackGenerator?: () => Generator<ElementFn<T>, any, unknown>
): () => Generator<ElementFn<T>, any, unknown> {
return function* () {
const isEnabled = await checkFeatureFlag(flagName);
if (isEnabled) {
console.log(`🎯 Feature "${flagName}" enabled`);
yield* generator();
} else if (fallbackGenerator) {
console.log(`🚫 Feature "${flagName}" disabled, using fallback`);
yield* fallbackGenerator();
} else {
console.log(`🚫 Feature "${flagName}" disabled, no fallback`);
// Component does nothing
}
};
}
// Usage
const newButtonBehavior = withFeatureFlag(
'enhanced-buttons',
function* () {
// New enhanced behavior
yield addClass('enhanced');
yield style({
background: 'linear-gradient(45deg, #007bff, #0056b3)',
transition: 'all 0.3s ease'
});
yield click(() => {
yield addClass('clicked');
setTimeout(() => yield removeClass('clicked'), 300);
});
},
function* () {
// Fallback to old behavior
yield addClass('basic');
yield click(() => console.log('Basic click'));
}
);
watch('button.enhanced', newButtonBehavior);Lifecycle Management Generator
// Adds standardized lifecycle hooks to any generator
export function withLifecycle<T extends HTMLElement, R = any>(
generator: () => Generator<ElementFn<T>, R, unknown>,
options: {
onMount?: (element: T) => void;
onUnmount?: (element: T) => void;
onUpdate?: (element: T) => void;
enableDebug?: boolean;
} = {}
): () => Generator<ElementFn<T>, R, unknown> {
return function* () {
const element = self() as T;
const componentName = element.className || element.tagName.toLowerCase();
// Mount lifecycle
if (options.onMount) {
options.onMount(element);
}
if (options.enableDebug) {
console.log(`🔧 Mounting component: ${componentName}`);
}
// Setup unmount cleanup
if (options.onUnmount) {
cleanup(() => {
if (options.enableDebug) {
console.log(`🗑️ Unmounting component: ${componentName}`);
}
options.onUnmount!(element);
});
}
// Track updates if enabled
if (options.onUpdate) {
const observer = new MutationObserver(() => {
options.onUpdate!(element);
});
observer.observe(element, {
attributes: true,
childList: true,
subtree: true
});
cleanup(() => observer.disconnect());
}
// Execute the wrapped generator
const result = yield* generator();
if (options.enableDebug) {
console.log(`✅ Component initialized: ${componentName}`);
}
return result;
};
}
// Usage
const lifecycleComponent = withLifecycle(
function* () {
const clickCount = createState('clicks', 0);
yield click(() => {
clickCount.update(c => c + 1);
yield text(`Clicked ${clickCount.get()} times`);
});
return {
getClicks: () => clickCount.get()
};
},
{
onMount: (el) => console.log(`Component mounted on:`, el),
onUnmount: (el) => console.log(`Component unmounted from:`, el),
onUpdate: (el) => console.log(`Component updated:`, el),
enableDebug: true
}
);
watch('.lifecycle-component', lifecycleComponent);A/B Testing Generator
// Enables A/B testing at the component level
export function withABTest<T extends HTMLElement>(
testName: string,
variants: Record<string, () => Generator<ElementFn<T>, any, unknown>>,
options: {
userIdGetter?: () => string;
onVariantShown?: (variant: string, userId: string) => void;
} = {}
): () => Generator<ElementFn<T>, any, unknown> {
return function* () {
const userId = options.userIdGetter?.() || 'anonymous';
const variant = selectVariant(testName, userId, Object.keys(variants));
// Track which variant was shown
if (options.onVariantShown) {
options.onVariantShown(variant, userId);
}
// Store variant info on element for debugging
const element = self() as T;
element.dataset.abTest = testName;
element.dataset.abVariant = variant;
console.log(`🧪 A/B Test "${testName}": showing variant "${variant}" to user ${userId}`);
// Execute the selected variant
const selectedGenerator = variants[variant];
if (selectedGenerator) {
yield* selectedGenerator();
} else {
console.warn(`⚠️ A/B Test "${testName}": variant "${variant}" not found`);
}
};
}
// Usage
const abTestButton = withABTest(
'button-style-test',
{
control: function* () {
yield addClass('btn-primary');
yield text('Click Me');
yield click(() => console.log('Control clicked'));
},
variant_a: function* () {
yield addClass('btn-success');
yield text('Take Action!');
yield style({ fontSize: '18px', fontWeight: 'bold' });
yield click(() => console.log('Variant A clicked'));
},
variant_b: function* () {
yield addClass('btn-warning');
yield text('Get Started');
yield style({ borderRadius: '25px' });
yield click(() => console.log('Variant B clicked'));
}
},
{
userIdGetter: () => getCurrentUserId(),
onVariantShown: (variant, userId) => {
analytics.track('ab_test_variant_shown', {
test: 'button-style-test',
variant,
userId
});
}
}
);
watch('.ab-test-button', abTestButton);Permission-Based Generator
// Wraps generators with permission checks
export function withPermissions<T extends HTMLElement>(
requiredPermissions: string[],
generator: () => Generator<ElementFn<T>, any, unknown>,
unauthorizedGenerator?: () => Generator<ElementFn<T>, any, unknown>
): () => Generator<ElementFn<T>, any, unknown> {
return function* () {
const hasPermission = await checkPermissions(requiredPermissions);
if (hasPermission) {
yield* generator();
} else {
console.log(`🔒 Access denied. Required permissions: ${requiredPermissions.join(', ')}`);
if (unauthorizedGenerator) {
yield* unauthorizedGenerator();
} else {
// Default unauthorized behavior
yield addClass('unauthorized');
yield text('Access Denied');
yield click(() => {
alert('You do not have permission to use this feature.');
});
}
}
};
}
// Usage
const adminButton = withPermissions(
['admin', 'user_management'],
function* () {
yield text('Delete User');
yield addClass('btn-danger');
yield click(() => {
if (confirm('Are you sure?')) {
deleteUser();
}
});
},
function* () {
yield text('Contact Admin');
yield addClass('btn-secondary');
yield click(() => {
window.location.href = 'mailto:[email protected]';
});
}
);
watch('.admin-action', adminButton);Higher-Order Generator Composition
// Combine multiple generator wrappers
export function compose<T extends HTMLElement>(
...wrappers: Array<(gen: () => Generator<ElementFn<T>, any, unknown>) => () => Generator<ElementFn<T>, any, unknown>>
) {
return (generator: () => Generator<ElementFn<T>, any, unknown>) => {
return wrappers.reduceRight((acc, wrapper) => wrapper(acc), generator);
};
}
// Usage - apply multiple concerns to a component
const enhancedComponent = compose(
// Applied in reverse order (inside-out)
gen => withPerformanceMonitoring('MyComponent', gen),
gen => withErrorBoundary(gen, 'Component failed to load'),
gen => withLifecycle(gen, { enableDebug: true }),
gen => withFeatureFlag('new-ui', gen, () => function* () {
yield text('Feature disabled');
})
)(function* () {
// The actual component logic
const count = createState('count', 0);
yield click(() => {
count.update(c => c + 1);
yield text(`Count: ${count.get()}`);
});
return {
getCount: () => count.get()
};
});
watch('.enhanced-component', enhancedComponent);Component Factory Generator
// Creates standardized component patterns
export function createComponent<T extends HTMLElement>(
name: string,
config: {
template?: string;
styles?: Record<string, string>;
state?: Record<string, any>;
methods?: Record<string, (...args: any[]) => any>;
lifecycle?: {
onMount?: (element: T) => void;
onUnmount?: (element: T) => void;
};
}
): () => Generator<ElementFn<T>, any, unknown> {
return function* () {
const element = self() as T;
// Apply template
if (config.template) {
yield html(config.template);
}
// Apply styles
if (config.styles) {
yield style(config.styles);
}
// Initialize state
const componentState: Record<string, any> = {};
if (config.state) {
Object.entries(config.state).forEach(([key, initialValue]) => {
componentState[key] = createState(key, initialValue);
});
}
// Lifecycle hooks
if (config.lifecycle?.onMount) {
config.lifecycle.onMount(element);
}
if (config.lifecycle?.onUnmount) {
cleanup(() => config.lifecycle!.onUnmount!(element));
}
// Create public API
const api: Record<string, any> = {};
if (config.methods) {
Object.entries(config.methods).forEach(([methodName, method]) => {
api[methodName] = (...args: any[]) => {
return method.call({ element, state: componentState }, ...args);
};
});
}
// Add state getters
Object.keys(componentState).forEach(key => {
api[`get${key.charAt(0).toUpperCase() + key.slice(1)}`] = () => {
return componentState[key].get();
};
});
console.log(`🏗️ Component "${name}" created with API:`, Object.keys(api));
return api;
};
}
// Usage - declarative component creation
const counterComponent = createComponent('Counter', {
template: '<div class="counter">Count: 0</div>',
styles: {
padding: '10px',
border: '1px solid #ccc',
borderRadius: '4px'
},
state: {
count: 0
},
methods: {
increment() {
this.state.count.update(c => c + 1);
this.element.textContent = `Count: ${this.state.count.get()}`;
},
reset() {
this.state.count.set(0);
this.element.textContent = 'Count: 0';
}
},
lifecycle: {
onMount: (el) => {
el.addEventListener('click', () => {
// Access the component API through return value
});
}
}
});
watch('.auto-counter', counterComponent);When NOT to Use Generator Abstractions
Avoid generator wrapping when:
- Simple functionality - Use function abstractions instead
- One-off behaviors - Don't abstract what you won't reuse
- Performance critical - Each wrapper adds overhead
- Team confusion - If it makes code harder to understand
- Over-engineering - Start simple, abstract when patterns emerge
Rule of thumb: If you find yourself copying the same generator patterns across multiple components, consider a generator abstraction. If you're just adding functionality to elements, use function abstractions.
Scoped Watch: Isolated DOM Observation
When you need precise control over DOM observation scope, scoped watch creates isolated observers for specific parent elements without event delegation.
Key Benefits
- 🔒 Isolated Observation: Each watcher has its own MutationObserver scoped to a specific parent
- 🚫 No Event Delegation: Direct DOM observation without event bubbling overhead
- ⚡ Better Performance: Only watches the specific subtree you care about
- 🧹 Automatic Cleanup: Automatically disconnects when parent is removed from DOM
- 🎛️ Granular Control: Fine-tune what changes to observe (attributes, character data, etc.)
Basic Usage
import { scopedWatch, addClass, text } from 'watch-selector';
// Watch for buttons only within a specific container
const container = document.querySelector('#my-container');
const watcher = scopedWatch(container, 'button', function* () {
yield addClass('scoped-button');
yield text('I was found by scoped watch!');
});
// Later cleanup
watcher.disconnect();Advanced Options
// Watch attributes and character data within a form
const form = document.querySelector('form');
const formWatcher = scopedWatch(form, 'input', function* () {
yield addClass('monitored-input');
yield* setValue(''); // Clear on detection
}, {
attributes: true,
attributeFilter: ['value', 'disabled'],
characterData: true,
subtree: true // Watch descendants (default: true)
});Batch Scoped Watching
// Watch multiple selectors within the same parent
const dashboard = document.querySelector('#dashboard');
const watchers = scopedWatchBatch(dashboard, [
{
selector: '.chart',
generator: function* () {
yield addClass('chart-initialized');
yield* setupChart();
}
},
{
selector: '.widget',
generator: function* () {
yield addClass('widget-ready');
yield* setupWidget();
},
options: { attributes: true }
}
]);
// Later cleanup all watchers
watchers.forEach(watcher => watcher.disconnect());One-Time and Timeout Watchers
// Process only the first 3 matching elements
const firstThreeWatcher = scopedWatchOnce(list, '.item', function* () {
yield addClass('first-batch');
yield* setupSpecialBehavior();
}, 3);
// Auto-disconnect after 5 seconds
const tempWatcher = scopedWatchTimeout(container, '.temp-element', function* () {
yield addClass('temporary-highlight');
yield* animateIn();
}, 5000);Matcher Functions
// Use custom logic instead of CSS selectors
const submitButtonMatcher = (el: HTMLElement): el is HTMLButtonElement => {
return el.tagName === 'BUTTON' &&
el.getAttribute('type') === 'submit' &&
el.dataset.important === 'true';
};
const watcher = scopedWatch(container, submitButtonMatcher, function* () {
yield addClass('important-submit');
yield style({ backgroundColor: 'red', color: 'white' });
});Full Context Integration
Scoped watchers work seamlessly with all Watch primitives:
const watcher = scopedWatch(container, 'li', function* () {
// Context primitives work perfectly
const element = yield* self();
const siblings = yield* all('li');
const parentContext = yield* ctx();
// State management
yield* createState('itemIndex', siblings.indexOf(element));
// Event handling
yield onClick(function* () {
const index = yield* getState('itemIndex');
yield text(`Item ${index} clicked`);
});
// Execution helpers
yield onClick(debounce(function* () {
yield addClass('debounced-click');
}, 300));
});When to Use Scoped Watch
Use scoped watch when:
- You need to observe a specific container or component
- Performance is critical (avoid global observer overhead)
- You want isolated behavior that doesn't affect other parts of the page
- You need fine-grained control over what changes to observe
Use regular watch when:
- You want to observe elements across the entire document
- You need event delegation for dynamic content
- You want the simplest possible setup
Utility Functions
// Get all active watchers for a parent
const activeWatchers = getScopedWatchers(parent);
// Disconnect all watchers for a parent
disconnectScopedWatchers(parent);
// Check watcher status
console.log('Active:', watcher.isActive());
console.log('Parent:', watcher.getParent());
console.log('Selector:', watcher.getSelector());Frequently Asked Questions
Why doesn't Watch include templating?
Short answer: We believe in "bring your own templating" for maximum flexibility.
Long answer: Watch is designed to integrate into existing pages where you don't control the DOM structure. This is common in:
- Server-driven websites (Rails, Django, PHP applications)
- E-commerce platforms with fixed templates
- CMS systems like WordPress or Drupal
- Legacy applications being modernized incrementally
- Browser extensions working with arbitrary websites
By not including templating, Watch can focus on what it does best: reactive DOM observation and element lifecycle management. You can use any templating solution you prefer - Handlebars, Mustache, lit-html, or even just string concatenation.
That said, we may add an opinionated templating module in the future that integrates seamlessly with watch-selector's patterns, but it would be optional and composable with existing solutions.
Isn't this just jQuery .live() but more confusing?
Yes! But with significant improvements:
// jQuery .live() (deprecated)
$(document).on('click', '.button', function() {
var clickCount = 0; // This doesn't work - shared across all buttons!
clickCount++;
$(this).text('Clicked ' + clickCount + ' times');
});
// Watch equivalent
watch('button', function* () {
let clickCount = 0; // This works - scoped per button instance
yield click(() => {
clickCount++; // Each button has its own counter
yield text(`Clicked ${clickCount} times`);
});
});Key improvements over jQuery:
- Type Safety: Full TypeScript support with element type inference
- Element-Scoped State: Each element gets its own isolated state
- Composable Behavior: Generators can be mixed, matched, and reused
- Automatic Cleanup: No memory leaks from forgotten event handlers
- Modern JavaScript: Uses generators, async/await, and ES modules
- Performance: Single global observer vs multiple event delegations
Why not React/Vue/Svelte/Alpine/htmx/Mithril?
I respect all those libraries! They're excellent for their intended use cases. But they have different assumptions:
React/Vue/Svelte:
- Want complete control of rendering and state
- Assume you're building a Single Page Application
- Require build tools and complex toolchains
- Don't play well with server-rendered markup you can't control
Alpine.js:
- Great library! Very similar philosophy to Watch
- Less type-safe, more limited state management
- Watch provides more sophisticated component composition
htmx:
- Excellent for server-driven interactions
- Requires server-side coordination
- Watch works purely client-side with any backend
Mithril:
- Lightweight and fast
- Still assumes control over rendering
- Not designed for enhancing existing markup
Watch is designed for different scenarios:
- Enhancing existing server-rendered pages
- Adding interactivity without controlling the entire page
- Working with legacy systems or third-party markup
- Building browser extensions or user scripts
- Gradual modernization of existing applications
Why not just use Web Components?
Web Components are great,
