A powerful, type-safe reactive event system for TypeScript/JavaScript applications. Inspired by solid-events and remix events, this library provides event primitives, framework integration, DOM utilities, and Remix Events integration for building reactive user interfaces and event-driven architectures.
- π Documentation
- β¨ Features
- π¦ Installation
- π Quick Start
- π Core API
- π Remix Events Integration
- Declarative APIs Inspired by Solid-Events
- π― Framework Integrations
- π Type Safety
- π DOM Utilities
- π Advanced DOM Event Handling
- π Advanced Examples
- ποΈ Complete API Reference
- π EventEmitter Integration
- π§ Handler Operators
- π Actor System
Explore comprehensive guides to master @doeixd/events
:
- Primitives Guide - When to use operators, interactions, reducers, and actors
- Async Handling - Cancellation, control flow, disposal, and batching
- DOM Utilities - Reactive event handling, observers, and focus management
- Framework Integration - React, Vue, Svelte, and SolidJS integrations
- Positioning Guide - Compare with RxJS, SolidJS, XState, and Redux
- π Reactive Subjects: Observable values with automatic dependency tracking
- π Event Chaining: Powerful handler composition with conditional logic
- π DOM Integration: Type-safe DOM event handling and reactive bindings
- β‘ Remix Compatible: Full integration with Remix's event system
- π― SolidJS-Style APIs: Familiar patterns for SolidJS developers
- π¦ Tree-Shakable: Modular design with optimal bundling
- π Type-Safe: Full TypeScript support with excellent inference
- π οΈ Framework Agnostic: Works with React, Vue, Svelte, or vanilla JS
npm install @doeixd/events
# or
yarn add @doeixd/events
# or
pnpm add @doeixd/events
import { createEvent, createSubject, halt } from '@doeixd/events';
// Basic event system
const [onEvent, emitEvent] = createEvent<string>();
onEvent(payload => console.log(`Event emitted:`, payload));
emitEvent('Hello World!');
// logs "Event emitted: Hello World!"
// Event transformation and chaining
const [onIncrement, emitIncrement] = createEvent<number>();
const onMessage = onIncrement((delta) => `Increment by ${delta}`);
onMessage(message => console.log(`Message emitted:`, message));
emitIncrement(2);
// logs "Message emitted: Increment by 2"
// Conditional halting
const onValidIncrement = onIncrement(delta => delta > 0 ? delta : halt());
const onFinalMessage = onValidIncrement((delta) => `Valid increment: ${delta}`);
onFinalMessage(message => console.log(message));
emitIncrement(-1); // No output (halted)
emitIncrement(5); // logs "Valid increment: 5"
// Reactive subjects
const count = createSubject(0);
count.subscribe((value) => console.log('Count is now:', value));
count(1); // logs "Count is now: 1"
console.log(count()); // 1
import { createEvent, halt } from '@doeixd/events';
// Create typed events
const [onMessage, emitMessage] = createEvent<string>();
const [onCount, emitCount] = createEvent<number>();
// Conditional halting
onCount((n) => {
if (n < 0) halt(); // Stop processing
console.log('Processing:', n);
});
emitCount(5); // Logs: Processing: 5
emitCount(-1); // No log (halted)
import { createSubject } from 'events';
const user = createSubject({
name: 'John',
age: 30
});
// Get current value
console.log(user().name); // 'John'
// Update and notify subscribers
user({ ...user(), age: 31 });
// Subscribe to changes
user.subscribe((newUser) => {
console.log('User updated:', newUser);
});
@doeixd/events
is designed as a powerful companion to the low-level @remix-run/events
system. The two libraries work together to provide a complete, composable, and reactive event handling solution.
You can think of their roles as complementary:
@remix-run/events
is the engine. It provides the core mechanism for attaching event listeners (events()
) and encapsulating stateful logic into reusable, higher-level Interactions (likepress
orouterPress
). It manages the lifecycle of event handling.@doeixd/events
is the logic toolkit. It provides a powerful, declarative API for creating reactive pipelines that process the data flowing through those events. It excels at event transformation, conditional logic (halt()
), and deriving state.
By combining them, you can build sophisticated, encapsulated, and highly readable event logic.
Additional Features: @doeixd/events
also provides Handler Operators for RxJS-style event transformations (debounce, throttle, double-click detection) and createEvent signals with automatic async operation abortion for safe concurrent event handling. See the Async Handling Guide for deep dives on cancellation and control flow.
The integration is made possible through a set of bridge functions. The most important one is toEventDescriptor
, which converts any @doeixd/events
Handler
chain into a Remix-compatible EventDescriptor
.
This allows you to build complex logic with @doeixd/events
and then seamlessly "plug it in" to any element using Remix's events()
function or on
prop.
While Remix provides dom.submit
, @doeixd/events
allows you to build a declarative pipeline on top of it. The final business logic will only run if all preceding steps in the chain succeed.
import { events } from '@remix-run/events';
import { dom, halt, toEventDescriptor } from '@doeixd/events';
// Assume we have a form element in the DOM
const formElement = document.querySelector('form')!;
const emailInput = formElement.querySelector('input[name="email"]')!;
// 1. Create a reactive event chain from the DOM event.
const onSubmit = dom.submit(formElement);
// 2. Chain 1: Prevent default browser submission.
const onSafeSubmit = onSubmit(event => {
event.preventDefault();
return event; // Pass the event down the chain
});
// 3. Chain 2: Validate the email. If invalid, halt the chain.
const onValidatedSubmit = onSafeSubmit(() => {
if (emailInput.value.includes('@')) {
return { email: emailInput.value }; // Pass validated data
}
console.log('Validation failed!');
return halt(); // Stop processing
});
// 4. Create the final Remix EventDescriptor from our powerful chain.
const submitDescriptor = toEventDescriptor(onValidatedSubmit, 'submit');
// 5. Attach the descriptor using Remix's events() function.
const cleanup = events(formElement, [submitDescriptor]);
// Later, when the component unmounts...
// cleanup();
Remix's custom Interactions are perfect for encapsulating stateful logic. @doeixd/events
provides an ideal way to write the internal logic for these interactions in a clean, declarative style.
Let's build a doubleClick
interaction that fires only when a user clicks twice within 300ms.
import { createInteraction, events } from '@remix-run/events';
import { createEvent, dom, halt, toEventDescriptor } from '@doeixd/events';
// The Interaction factory
export const doubleClick = createInteraction('doubleClick', ({ target, dispatch }) => {
let timer: number;
const [onClick, emitClick] = createEvent<MouseEvent>();
// 1. Core Logic using @doeixd/events:
// - Start with the raw click events.
// - If a timer is running, it's a double click. Clear the timer and pass the event through.
// - If no timer, start one and halt the chain.
const onDoubleClick = onClick(event => {
if (timer) {
clearTimeout(timer);
timer = 0;
return event; // This is a double click!
} else {
timer = setTimeout(() => (timer = 0), 300);
return halt(); // This is the first click.
}
});
// 2. Dispatch the custom Remix event when a double click occurs.
onDoubleClick(() => dispatch());
// 3. Bridge the raw DOM click to our internal event emitter.
const clickDescriptor = toEventDescriptor(
dom.click(target)(e => emitClick(e)),
'click'
);
// 4. Attach the listener using Remix's events() and return the cleanup.
return events(target, [clickDescriptor]);
});
// --- How to use it ---
// Now you have a clean, reusable `doubleClick` interaction.
const button = document.querySelector('button')!;
events(button, [
doubleClick(() => {
console.log('Double click detected!');
}),
]);
This example shows the power of the combination:
- Remix's
createInteraction
encapsulates the logic and provides thedispatch
mechanism. @doeixd/events
provides the declarative tools (createEvent
,halt
, chaining) to implement the complex timing logic cleanly.
The events()
function creates a managed container for attaching listeners to any EventTarget
. You describe your listeners as an array of EventDescriptor
objects.
import { events, dom, press } from '@doeixd/events';
const button = document.getElementById('my-button');
// 1. Create an event container for the element
const buttonEvents = events(button);
// 2. Declaratively attach a set of listeners
buttonEvents.on([
// Attach a standard DOM event listener
dom.click(event => {
console.log('Button was clicked!');
}),
// Attach a high-level, built-in interaction
press(event => {
// This fires for mouse clicks, touch taps, and Enter/Space key presses
console.log(`Button was "pressed" via a ${event.detail.originalEvent.type} event.`);
})
]);
// 3. To remove all listeners managed by the container:
// buttonEvents.cleanup();
Interactions are reusable, stateful event handlers that compose multiple low-level events into a single, semantic user behavior.
press
: Normalizes clicks, taps, and key presses into a single event.
When you attach multiple handlers for the same event type, they form a middleware chain. Any handler in the chain can call event.preventDefault()
to stop subsequent handlers from executing. This is perfect for building composable components.
function SmartLink({ href, onClick }) {
const linkEvents = events(linkElement);
linkEvents.on([
// 1. The consumer's onClick runs first.
dom.click(event => {
if (shouldCancelNavigation()) {
// This will stop our navigation logic below.
event.preventDefault();
}
onClick(event);
}),
// 2. Our component's default behavior runs second.
dom.click(event => {
// This code will not run if the consumer called preventDefault().
console.log('Navigating to', href);
navigateTo(href);
})
]);
}
You can encapsulate any complex, stateful event logic into your own reusable interaction with createInteraction
.
Hereβs a simple longPress
interaction that fires an event after the mouse has been held down for 500ms.
import { createInteraction, dom } from '@doeixd/events';
// 1. Define the interaction
const longPress = createInteraction<Element, { duration: number }>(
'longpress', // The name of the custom event it will dispatch
({ target, dispatch }) => {
let timer;
// Use our core `dom` helpers and subscription stack for robust logic
const onMouseDown = dom.mousedown(target);
const onMouseUp = dom.mouseup(window); // Listen on window to catch mouseup anywhere
const downSub = onMouseDown(e => {
const startTime = Date.now();
timer = setTimeout(() => {
dispatch({ detail: { duration: Date.now() - startTime } });
}, 500);
});
const upSub = onMouseUp(() => clearTimeout(timer));
// The factory must return its cleanup functions
return [downSub, upSub];
}
);
// 2. Use the new interaction
events(myElement, [
longPress(e => {
console.log(`Element was long-pressed for ${e.detail.duration}ms!`);
})
]);
Function | Purpose |
---|---|
toEventDescriptor |
Converts a Handler chain into a Remix EventDescriptor . (Most common) |
subjectToEventDescriptor |
Creates a descriptor that updates a Subject when a custom event is dispatched. |
emitterToEventDescriptor |
Creates a descriptor that calls an Emitter when a custom event is dispatched. |
bindSubjectToDom |
Provides two-way binding between a Subject and a DOM property for use within Remix. |
bridgeInteractionFactory |
Converts a Handler into a factory for creating advanced custom Remix Interactions. |
The core architecture and API of @doeixd/events
are heavily inspired by the excellent solid-events
library. The goal is to take the powerful, declarative patterns for event composition and state derivation and make them available in a framework-agnostic package that can be used in any JavaScript environment.
If you are familiar with solid-events
, you will find the API to be almost identical, enabling you to build complex, predictable logic by defining how state reacts to events.
π Primitives Guide - Architectural patterns and when to use each primitive (operators, interactions, reducers, actors).
Create a reactive subject whose value is managed exclusively by event handlers. This makes state changes traceable and predictable.
const [onIncrement, emitIncrement] = createEvent<number>();
const [onReset, emitReset] = createEvent();
const count = createSubject(0,
onIncrement(delta => currentCount => currentCount + delta),
onReset(() => 0)
);
Create a subject that loads its initial state from an async source and can be updated or re-fetched via events.
const [onRefresh, emitRefresh] = createEvent();
const user = createAsyncSubject(
() => fetch('/api/user').then(res => res.json()), // Initial load
onRefresh(() => fetch('/api/user').then(res => res.json())) // Re-fetch
);
Create a mutable store (like Immer or Solid stores) where event handlers can safely and directly mutate the state object.
const [onAddItem, emitAddItem] = createEvent<string>();
const cart = createSubjectStore({ items: [] },
onAddItem(item => state => {
state.items.push(item); // Directly mutate state
})
);
Combine multiple, distinct event streams into a single, unified handler.
const [onLogin, emitLogin] = createEvent<string>();
const [onLogout, emitLogout] = createEvent();
const onAuthChange = createTopic(
onLogin(user => `User logged in: ${user}`),
onLogout(() => 'User logged out')
);
Split a single event stream into two separate streams based on a predicate function.
const [onNumber, emitNumber] = createEvent<number>();
const [onPositive, onNegativeOrZero] = createPartition(
onNumber,
(num) => num > 0
);
While the core API is parallel, @doeixd/events
differs in a few crucial ways to achieve its framework-agnostic goals:
-
Framework Agnostic vs. SolidJS-Specific:
solid-events
is designed specifically for SolidJS and integrates deeply with its reactive graph.@doeixd/events
is built with no framework dependencies, allowing it to be used anywhere.
-
Memory Management:
- In
solid-events
, handlers are automatically cleaned up by Solid's component lifecycle. @doeixd/events
requires manual cleanup. You must call theunsubscribe
function returned by a handler or use anAbortSignal
to prevent memory leaks, especially within component lifecycles (e.g., in React'suseEffect
cleanup).
- In
-
Expanded Focus on DOM and Integrations:
@doeixd/events
includes a rich set of DOM Utilities and a dedicated Remix Integration Bridge to provide a first-class experience in a variety of frontend environments.
π Positioning Guide - Compare @doeixd/events
with RxJS, SolidJS Signals, XState, and Redux to understand trade-offs and choose the right tool.
@doeixd/events
provides first-class integrations with popular JavaScript frameworks. You can either use the core library directly or leverage framework-specific integration packages for enhanced developer experience.
For seamless integration with automatic lifecycle management and idiomatic APIs:
- @doeixd/react - React Hooks with automatic subscription cleanup
- @doeixd/vue - Vue 3 Composables for the Composition API
- @doeixd/svelte - Svelte Stores and Runes (Svelte 4 & 5)
- @doeixd/solid - SolidJS utilities with bidirectional reactivity
π Framework Integration Guide - Detailed documentation with examples for each framework.
npm install @doeixd/react
import { useEvent, useSubject } from '@doeixd/react';
import { createEvent, createSubject } from '@doeixd/events';
function Counter() {
const [onIncrement, emitIncrement] = createEvent<number>();
D722
const count = createSubject(0);
useEvent(onIncrement, (delta) => count(count() + delta));
const currentCount = useSubject(count);
return <button onClick={() => emitIncrement(1)}>Count: {currentCount}</button>;
}
The core @doeixd/events
library works seamlessly with any framework or vanilla JavaScript:
function MyComponent() {
const [count, setCount] = useState(0);
const [onIncrement, emitIncrement] = createEvent<number>();
useEffect(() => {
const unsub = onIncrement((delta) => setCount(c => c + delta));
return unsub;
}, []);
return <button onClick={() => emitIncrement(1)}>Count: {count}</button>;
}
<template>
<button @click="emitIncrement(1)">Count: {{ count }}</button>
</template>
<script setup>
import { createEvent, createSubject } from '@doeixd/events';
const [onIncrement, emitIncrement] = createEvent<number>();
const count = createSubject(0);
onIncrement((delta) => count(count() + delta));
</script>
<script>
import { createEvent, createSubject } from '@doeixd/events';
const [onIncrement, emitIncrement] = createEvent<number>();
const count = createSubject(0);
onIncrement((delta) => count($count + delta));
</script>
<button on:click={() => emitIncrement(1)}>Count: {$count}</button>
Note: @doeixd/events
subjects are fully compatible with Svelte's store contract, enabling the $
auto-subscription syntax out of the box.
Works seamlessly without any framework - full type safety with JSDoc annotations.
Enable batched updates to prevent redundant computations in complex reactive graphs:
import { createSubject, batch } from '@doeixd/events';
// Per-subject batching (batches individual updates)
const user = createSubject(null, { batch: true });
// Manual batching (batch multiple operations)
batch(() => {
firstName('John');
lastName('Doe'); // All notifications happen once at end
});
This library provides excellent TypeScript support with full type inference and safety guarantees:
- Automatic Type Inference: Event payloads, handler return types, and chaining are fully inferred
- Compile-Time Safety: Catch type mismatches at compile time, not runtime
- Generic Support: Strongly typed generics for events, subjects, and DOM interactions
- IntelliSense: Full IDE support with autocompletion and documentation
// Fully typed event system
const [onUserAction, emitUserAction] = createEvent<{ type: 'click' | 'hover'; target: Element }>();
// Type-safe chaining
const onValidatedAction = onUserAction((action) => {
if (action.type === 'click') return action.target; // Inferred return type: Element
return halt(); // Type-safe halting
});
// Subject with inferred state type
const user = createSubject({ name: 'John', age: 30 });
user.subscribe((u) => console.log(u.name)); // u is fully typed
Type-safe DOM event handling with reactive bindings. See the DOM Utilities Guide for comprehensive documentation on event handling, observers, and focus management.
import { fromDomEvent, dom, subjectProperty, on } from '@doeixd/events';
// Direct DOM event handling
const clickHandler = dom.click(buttonElement);
clickHandler(() => console.log('Clicked!'));
// Reactive DOM properties
const inputValue = subjectProperty(inputElement, 'value');
inputValue.subscribe((value) => {
console.log('Input changed:', value);
});
// Multi-element handling
on([button1, button2, button3], 'click', () => {
console.log('Button clicked!');
});
Beyond the basic examples, here are advanced DOM patterns:
import { subjectProperty, dom } from '@doeixd/events';
function createReactiveForm() {
const form = document.createElement('form');
// Reactive inputs
const nameInput = document.createElement('input');
const emailInput = document.createElement('input');
const name = subjectProperty(nameInput, 'value');
const email = subjectProperty(emailInput, 'value');
// Reactive validation
const isValid = createSubject(false);
combineLatest(name, email).subscribe(([n, e]) => {
isValid(n.length > 0 && e.includes('@'));
});
// Submit handler
const submitHandler = dom.submit(form);
submitHandler((e) => {
e.preventDefault();
if (isValid()) {
console.log('Submit:', { name: name(), email: email() });
}
});
return { form, name, email, isValid };
}
import { on } from '@doeixd/events';
const buttons = document.querySelectorAll('.my-button');
on(buttons, 'click', (event) => {
console.log('Button clicked:', event.target);
});
import { fromDomEvent } from '@doeixd/events';
const container = document.getElementById('container');
const clickHandler = fromDomEvent(container, 'click');
clickHandler((event) => {
if (event.target.matches('.item')) {
console.log('Item clicked:', event.target.dataset.id);
}
});
This library provides full support for native DOM events with advanced features like event phases, delegation, and standard addEventListener
options.
DOM events propagate through three phases: capturing, target, and bubbling. By default, events bubble up from the target element to the root.
import { fromDomEvent } from '@doeixd/events';
// Bubbling phase (default)
const bubblingHandler = fromDomEvent(childElement, 'click');
bubblingHandler((event) => {
console.log('Bubbling phase');
});
// Capturing phase
const capturingHandler = fromDomEvent(parentElement, 'click', { capture: true });
capturingHandler((event) => {
console.log('Capturing phase');
});
Works with all standard DOM events and their native properties:
import { dom } from '@doeixd/events';
const mouseHandler = dom.mousemove(document.body);
mouseHandler((event) => {
console.log('Mouse at:', event.clientX, event.clientY);
console.log('Target:', event.target);
console.log('Current target:', event.currentTarget);
});
// Access all native event properties
const keyHandler = dom.keydown(window);
keyHandler((event) => {
console.log('Key pressed:', event.key);
console.log('Code:', event.code);
console.log('Ctrl pressed:', event.ctrlKey);
});
import { dom } from '@doeixd/events';
const formHandler = dom.submit(formElement);
formHandler((event) => {
event.preventDefault(); // Prevent form submission
event.stopPropagation(); // Stop DOM event bubbling
// Handle form submission
console.log('Form submitted');
});
// Stop immediate propagation (prevents other handlers on same element)
const buttonHandler = dom.click(button, { capture: true });
buttonHandler((event) => {
event.stopImmediatePropagation();
console.log('This handler runs first and prevents others');
});
For reactive event chains, use halt()
to stop propagation:
import { createEvent, halt } from '@doeixd/events';
const [onNumber, emitNumber] = createEvent<number>();
// Chain with conditional halting
const onProcessed = onNumber((n) => {
if (n < 0) halt(); // Stop this chain
return n * 2;
});
onProcessed((result) => console.log('Result:', result));
emitNumber(5); // Logs: Result: 10
emitNumber(-1); // No output (halted)
Important: halt()
only affects the current handler chain. Other handlers attached to the same event continue normally.
When working with composed events (multiple handlers merged together), bubbling behavior depends on the composition method:
import { createEvent, createTopic, halt } from '@doeixd/events';
const [onEvent, emitEvent] = createEvent<number>();
// Multiple independent handlers (each isolated)
const handler1 = onEvent((n) => {
if (n < 0) halt(); // Only stops handler1
console.log('Handler 1:', n);
});
const handler2 = onEvent((n) => {
console.log('Handler 2:', n); // Always runs
});
// Composed handlers (halt affects the composed result)
const composed = createTopic(
onEvent((n) => n < 0 ? halt() : n),
onEvent((n) => `Result: ${n}`)
);
composed((result) => console.log(result));
emitEvent(5); // Handler 1: 5, Handler 2: 5, Result: 5
emitEvent(-1); // Handler 1: (halted), Handler 2: -1, (composed halted)
import { fromDomEvent } from '@doeixd/events';
// Passive listeners (improves scroll performance)
const scrollHandler = fromDomEvent(window, 'scroll', { passive: true });
scrollHandler(() => {
// This won't block scrolling
console.log('Scrolled');
});
// Once-only listeners
const clickOnceHandler = fromDomEvent(button, 'click', { once: true });
clickOnceHandler(() => {
console.log('This will only fire once');
});
// Combined options
const advancedHandler = fromDomEvent(element, 'touchstart', {
capture: true,
passive: false,
signal: abortController.signal
});
import { fromDomEvent } from '@doeixd/events';
// Listen for custom events
const customHandler = fromDomEvent(window, 'my-custom-event' as any);
customHandler((event: CustomEvent) => {
console.log('Custom event data:', event.detail);
});
// Dispatch custom events
const customEvent = new CustomEvent('my-custom-event', {
detail: { message: 'Hello!' }
});
window.dispatchEvent(customEvent);
import { fromDomEvent } from '@doeixd/events';
// Delegate to parent element
const listHandler = fromDomEvent(document.getElementById('list'), 'click');
listHandler((event) => {
const target = event.target as HTMLElement;
// Handle different child elements
if (target.matches('.delete-btn')) {
const itemId = target.dataset.id;
deleteItem(itemId);
} else if (target.matches('.edit-btn')) {
const itemId = target.dataset.id;
editItem(itemId);
}
});
// Multiple event types on same element
const multiHandler = fromDomEvent(container, 'click');
multiHandler((event) => {
if (event.target.matches('button')) {
handleButtonClick(event);
} else if (event.target.matches('a')) {
handleLinkClick(event);
}
});
import { fromDomEvent, dom } from '@doeixd/events';
// Use passive listeners for scroll/touch events
const touchHandler = dom.touchmove(document.body, { passive: true });
// Debounce high-frequency events
let scrollTimeout: number;
const scrollHandler = dom.scroll(window, { passive: true });
scrollHandler(() => {
clearTimeout(scrollTimeout);
scrollTimeout = setTimeout(() => {
console.log('Scroll settled');
}, 100);
});
// Use AbortController for cleanup
const controller = new AbortController();
const handler = fromDomEvent(button, 'click', { signal: controller.signal });
// Later: controller.abort(); // Removes the listener
- β Modern browsers (Chrome, Firefox, Safari, Edge)
- β All standard DOM events supported
- β
Event listener options (
capture
,passive
,once
) - β AbortSignal for cleanup
- β Custom events
- β All event properties and methods
import { createEvent, Handler } from 'events';
// Custom event emitter class
class EventEmitter<T extends Record<string, any>> {
private events = new Map<keyof T, Handler<T[keyof T]>>();
on<K extends keyof T>(event: K, handler: Handler<T[K]>) {
const [h, emit] = createEvent<T[K]>();
h(handler);
this.events.set(event, h);
return emit;
}
emit<K extends keyof T>(event: K, data: T[K]) {
const handler = this.events.get(event);
if (handler) {
// Trigger the handler with data
// Implementation details...
}
}
}
import { createSubject, subjectProperty } from '@doeixd/events';
function useFormValidation() {
const email = subjectProperty(emailInput, 'value');
const password = subjectProperty(passwordInput, 'value');
const errors = createSubject({ email: '', password: '' });
// Reactive validation
email.subscribe((value) => {
const isValid = /^[^@]+@[^@]+\.[^@]+$/.test(value);
errors({ ...errors(), email: isValid ? '' : 'Invalid email' });
});
password.subscribe((value) => {
const isValid = value.length >= 8;
errors({ ...errors(), password: isValid ? '' : 'Password too short' });
});
return { email, password, errors };
}
import { createAsyncSubject, createEvent } from '@doeixd/events';
function usePosts() {
const [onRefresh, emitRefresh] = createEvent();
const [onDelete, emitDelete] = createEvent<number>();
const posts = createAsyncSubject(
() => api.getPosts(), // Initial load
onRefresh(() => api.getPosts()), // Manual refresh
onDelete((id) => (currentPosts) =>
currentPosts.filter(p => p.id !== id) // Optimistic update
)
);
return { posts, refresh: emitRefresh, deletePost: emitDelete };
}
// Function to emit events with data of type T
type Emitter<T> = (data?: T) => void;
// Higher-order function for handling events
type Handler<T> = <R>(
cb: (data: T) => R | Promise<R> | void | Promise<void>
) => R extends void | Promise<void> ? Unsubscribe : Handler<Awaited<R>>;
// Unsubscribe function returned by handlers
type Unsubscribe = () => void;
// Reactive subject interface
interface Subject<S> {
(value?: S): S;
subscribe(cb: (value: S) => void): Unsubscribe;
dispose?: () => void;
}
Creates a typed event system with a handler and emitter. Each emission creates a new AbortController, automatically aborting any previous async operations for safety.
Parameters:
defaultValue?: T
- Optional default value to emit when no data is providedoptions.signal?: AbortSignal
- Optional abort signal for automatic cleanup
Returns: Tuple of [handler, emitter]
Callback Signature:
handler((data: T, meta?: { signal: AbortSignal }) => void)
The meta
parameter is optional and contains an AbortSignal
that is aborted when a new event is emitted, allowing for safe cancellation of async operations.
Examples:
const [onMessage, emitMessage] = createEvent<string>();
// Basic usage (meta parameter optional)
onMessage((msg) => console.log('Received:', msg));
emitMessage('Hello World!'); // Logs: Received: Hello World!
// Async safety with AbortSignal
onMessage(async (msg, meta) => {
if (meta?.signal.aborted) return; // Check if already aborted
try {
await someAsyncOperation(msg, meta.signal); // Pass signal for cancellation
} catch (err) {
if (err.name === 'AbortError') return; // Handle cancellation
throw err;
}
});
// When emitMessage is called again, previous async operations are automatically aborted
emitMessage('New message'); // Aborts previous async operation
Creates a reactive subject that holds a value and notifies subscribers.
Parameters:
initial?: T
- Initial value for the subjectoptions.batch?: boolean
- Whether to batch notifications (default: false)
Returns: Subject instance
Example:
const count = createSubject(0);
count.subscribe((value) => console.log('Count:', value));
count(5); // Logs: Count: 5
console.log(count()); // 5
// Batched subject
const batched = createSubject(0, { batch: true });
Creates a reactive subject derived from event handlers (SolidJS-style).
Parameters:
initial: T | (() => T)
- Initial value or function returning initial valuehandlers: Handler<any>[]
- Event handlers that update the subject
Returns: Subject instance
Example:
const [onIncrement, emitIncrement] = createEvent<number>();
const count = createSubject(0, onIncrement((delta) => (current) => current + delta));
emitIncrement(5);
console.log(count()); // 5
Executes a function with batched updates. All subject notifications are deferred until the end of the microtask, preventing redundant computations.
Parameters:
fn: () => T
- Function to execute with batching
Returns: Result of the function
Example:
batch(() => {
subject1('value1');
subject2('value2'); // Notifications happen once at end
});
Halts the current event chain execution without throwing an error.
Returns: Never (throws internal symbol)
Example:
const [onNumber, emitNumber] = createEvent<number>();
onNumber((n) => {
if (n < 0) halt(); // Stop processing
console.log('Processing:', n);
});
emitNumber(5); // Logs: Processing: 5
emitNu
D722
mber(-1); // No log (halted)
Special value used internally for type checking handlers. You typically don't need to use this directly.
Handler operators are pipeable functions that transform event handlers, enabling composable, reusable event logic similar to RxJS operators.
createOperator<T>(process: (data: T, emit: (result: T) => void, halt: () => never) => void): (source: Handler<T>) => Handler<T>
Helper for creating custom handler operators. Simplifies the boilerplate of operator creation by handling DUMMY values and providing a clean API.
Parameters:
process: (data: T, emit: (result: T) => void, halt: () => never) => void
- Function that processes each event
Process Function Parameters:
data: T
- The event dataemit: (result: T) => void
- Call to pass data through the chainhalt: () => never
- Call to stop event propagation
Returns: Pipeable operator function
Example:
import { createOperator } from '@doeixd/events/operators';
export const take = <T>(count: number) =>
createOperator<T>((data, emit, halt) => {
if (--count >= 0) {
emit(data);
} else {
halt();
}
});
Creates a double-click handler operator that only triggers on double clicks within a timeout.
Parameters:
timeout?: number
- Maximum time in milliseconds between clicks (default: 300)
Returns: Pipeable operator function
Example:
import { dom } from '@doeixd/events';
import { doubleClick } from '@doeixd/events/operators';
const onButtonClick = dom.click(button);
const onButtonDoubleClick = doubleClick(500)(onButtonClick);
onButtonDoubleClick(() => console.log('Double click detected!'));
Creates a debounced handler operator that delays execution until after a timeout.
Parameters:
delay: number
- Delay in milliseconds
Returns: Pipeable operator function
Example:
import { dom } from '@doeixd/events';
import { debounce } from '@doeixd/events/operators';
const onInput = dom.input(textInput);
const onDebouncedInput = debounce(300)(onInput);
onDebouncedInput((event) => {
console.log('User stopped typing:', event.target.value);
});
Creates a throttled handler operator that limits execution to once per interval.
Parameters:
interval: number
- Minimum time in milliseconds between executions
Returns: Pipeable operator function
Example:
import { dom } from '@doeixd/events';
import { throttle } from '@doeixd/events/operators';
const onScroll = dom.scroll(window);
const onThrottledScroll = throttle(100)(onScroll);
onThrottledScroll(() => {
console.log('Scroll event (throttled)');
});
Handler operators are functions that take a Handler<T>
and return a new Handler<T>
, enabling composable event transformations. Use the createOperator
helper to simplify operator creation:
import { createOperator } from '@doeixd/events/operators';
export function filter<T>(predicate: (data: T) => boolean) {
return createOperator<T>((data, emit, halt) => {
if (predicate(data)) {
emit(data); // Pass data through
} else {
halt(); // Stop the event chain
}
});
}
// Usage
import { createEvent } from '@doeixd/events';
import { filter } from './operators';
const [onNumber, emitNumber] = createEvent<number>();
const onEvenNumbers = filter((n) => n % 2 === 0)(onNumber);
onEvenNumbers((num) => console.log('Even number:', num));
emitNumber(1); // No output
emitNumber(2); // Logs: Even number: 2
emitNumber(3); // No output
emitNumber(4); // Logs: Even number: 4
Advanced Example - Custom Debounce with Reset:
import { createOperator } from '@doeixd/events/operators';
export function debounceWithReset<T>(delay: number, resetValue?: T) {
let timeoutId: number | null = null;
return createOperator<T>((data, emit, halt) => {
if (timeoutId) {
clearTimeout(timeoutId);
}
// Special reset value clears the debounce
if (resetValue !== undefined && data === resetValue) {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
return; // Don't emit reset values
}
timeoutId = window.setTimeout(() => {
timeoutId = null;
emit(data);
}, delay);
halt(); // Always halt immediate execution
});
}
Operator Guidelines:
- Use
emit(result)
to pass transformed data through the chain - Use
halt()
to stop event propagation (likepreventDefault()
) createOperator
automatically handlesDUMMY
values for type checking- Use closures to maintain state between events
- Return cleanup functions from operator factories if needed
Handler operators and Remix Events "Interactions" serve different but complementary purposes:
Handler Operators (this library):
- Transform single event streams with functional composition
- Pipeable operators like RxJS:
debounce(300)(throttle(100)(source))
- Stateless transformations of individual events
- Best for: filtering, timing control, data transformation
Remix Events Interactions:
- Stateful compositions of multiple DOM events into behaviors
- Manage complex state across event types (mouse, keyboard, touch)
- Encapsulate interaction patterns like "press", "hoverAim", "outerPress"
- Best for: gesture recognition, multi-event coordination, component interactions
Example Comparison:
Handler Operator (single event transformation):
// Debounce a single input event
const debouncedInput = debounce(300)(dom.input(searchInput));
Remix Interaction (multi-event composition):
// Compose mouse/touch/keyboard into "press" behavior
const pressHandler = press(() => console.log('Pressed'));
Use handler operators for transforming individual event streams, and Remix Interactions for composing multiple events into higher-level user behaviors.
fromDomEvent<E extends Element, K extends keyof HTMLElementEventMap>(el: E, eventName: K, options?: { signal?: AbortSignal; capture?: boolean; passive?: boolean; once?: boolean }): Handler<HTMLElementEventMap[K]>
Creates a type-safe DOM event handler with full addEventListener
options support.
Parameters:
el: E
- DOM element to attach the event toeventName: K
- Event name (e.g., 'click', 'input')options.signal?: AbortSignal
- Optional abort signal for cleanupoptions.capture?: boolean
- Use capture phase instead of bubbling (default: false)options.passive?: boolean
- Passive listener (improves performance for scroll/touch)options.once?: boolean
- Remove listener after first event
Returns: Handler for the DOM event
Examples:
const button = document.querySelector('button')!;
// Basic usage
const clickHandler = fromDomEvent(button, 'click');
clickHandler(() => console.log('Clicked!'));
// With options
const scrollHandler = fromDomEvent(window, 'scroll', {
passive: true, // Improves scroll performance
capture: false // Use bubbling phase (default)
});
// Once-only listener
const submitHandler = fromDomEvent(form, 'submit', { once: true });
submitHandler((e) => {
e.preventDefault();
console.log('Form submitted (listener removed)');
});
Object containing shortcuts for common DOM events. All shortcuts support the same options as fromDomEvent
.
Available shortcuts:
dom.click<E extends Element>(el: E, options?)
dom.dblclick<E extends Element>(el: E, options?)
dom.input<E extends HTMLInputElement | HTMLTextAreaElement>(el: E, options?)
dom.change<E extends HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(el: E, options?)
dom.submit<E extends HTMLFormElement>(el: E, options?)
dom.keydown<E extends Element>(el: E, options?)
dom.keyup<E extends Element>(el: E, options?)
dom.focus<E extends Element>(el: E, options?)
dom.blur<E extends Element>(el: E, options?)
dom.mousemove<E extends Element>(el: E, options?)
dom.mousedown<E extends Element>(el: E, options?)
dom.mouseup<E extends Element>(el: E, options?)
dom.wheel<E extends Element>(el: E, options?)
dom.touchstart<E extends Element>(el: E, options?)
dom.touchend<E extends Element>(el: E, options?)
dom.touchmove<E extends Element>(el: E, options?)
Examples:
const button = document.querySelector('button')!;
// Basic usage
const clickHandler = dom.click(button);
clickHandler(() => console.log('Button clicked!'));
// With event phase control
const capturingClick = dom.click(parentElement, { capture: true });
capturingClick(() => console.log('Captured click'));
// Passive touch for better performance
const touchHandler = dom.touchmove(element, { passive: true });
touchHandler((e) => console.log('Touch moved'));
subjectProperty<T extends Element, K extends keyof T>(el: T, prop: K, eventName?: keyof HTMLElementEventMap): Subject<T[K]>
Creates a reactive Subject bound to a DOM element property.
Parameters:
el: T
- DOM elementprop: K
- Property name to bind toeventName?: keyof HTMLElementEventMap
- Event that triggers updates (default: 'input')
Returns: Subject bound to the property
Example:
const input = document.querySelector('input')!;
const value = subjectProperty(input, 'value');
value.subscribe((val) => console.log('Input value:', val));
subjectFromEvent<E extends Element, K extends keyof HTMLElementEventMap>(el: E, eventName: K): Subject<HTMLElementEventMap[K]>
Converts a DOM event stream into a reactive Subject.
Parameters:
el: E
- DOM elementeventName: K
- Event name
Returns: Subject that emits DOM events
Example:
const button = document.querySelector('button')!;
const clicks = subjectFromEvent(button, 'click');
clicks.subscribe((event) => console.log('Button clicked at:', event.clientX, event.clientY));
on<E extends Element>(elements: E[] | NodeListOf<E>, event: keyof HTMLElementEventMap, handler: (ev: HTMLElementEventMap[typeof event]) => void, options?: { signal?: AbortSignal; capture?: boolean; passive?: boolean; once?: boolean }): Unsubscribe
Attaches an event handler to multiple elements with full event options support.
Parameters:
elements: E[] | NodeListOf<E>
- Elements to attach toevent: keyof HTMLElementEventMap
- Event namehandler: (ev: HTMLElementEventMap[typeof event]) => void
- Event handleroptions.signal?: AbortSignal
- Optional abort signal for cleanupoptions.capture?: boolean
- Use capture phaseoptions.passive?: boolean
- Passive listeneroptions.once?: boolean
- Remove listeners after first event
Returns: Unsubscribe function (removes all listeners)
Examples:
const buttons = document.querySelectorAll('.my-button');
// Basic multi-element handling
const unsub = on(buttons, 'click', (event) => {
console.log('Button clicked:', event.target);
});
// With options
const touchButtons = document.querySelectorAll('.touch-btn');
const touchUnsub = on(touchButtons, 'touchstart', (event) => {
console.log('Touch started');
}, { passive: true });
// Cleanup
unsub(); // Removes click listeners
touchUnsub(); // Removes touch listeners
Converts a Handler to a Remix EventDescriptor.
Parameters:
handler: Handler<T>
- Handler to converttype: string
- Event type stringsignal?: AbortSignal
- Optional abort signal
Returns: Remix EventDescriptor
Example:
const [onEvent, emitEvent] = createEvent<string>();
const descriptor = toEventDescriptor(onEvent, 'custom-event');
// Use in Remix events()
events(button, [descriptor]);
subjectToEventDescriptor<T>(subject: Subject<T>, type: string, signal?: AbortSignal): EventDescriptor
Converts a Subject to a Remix EventDescriptor.
Parameters:
subject: Subject<T>
- Subject to converttype: string
- Event type stringsignal?: AbortSignal
- Optional abort signal
Returns: Remix EventDescriptor
fromDomHandler<E extends Element, K extends keyof HTMLElementEventMap>(el: E, eventName: K, opts?: { signal?: AbortSignal; capture?: boolean; passive?: boolean }): Handler<HTMLElementEventMap[K]>
Creates a DOM handler for Remix integration.
Parameters:
el: E
- DOM elementeventName: K
- Event nameopts?: { signal?: AbortSignal; capture?: boolean; passive?: boolean }
- Options
Returns: Handler for DOM events
bindSubjectToDom<E extends Element, K extends keyof E>(subject: Subject<any>, el: E, propOrEvent: K | keyof HTMLElementEventMap, opts?: { signal?: AbortSignal; fromEvent?: boolean }): EventDescriptor
Bidirectionally binds a Subject to a DOM element property or event.
Parameters:
subject: Subject<any>
- Subject to bindel: E
- DOM elementpropOrEvent: K | keyof HTMLElementEventMap
- Property or event nameopts.signal?: AbortSignal
- Optional abort signalopts.fromEvent?: boolean
- If true, bind from event to subject
Returns: EventDescriptor for Remix
Converts a Handler into a Remix InteractionDescriptor factory.
Parameters:
handler: Handler<T>
- Handler to convert
Returns: Factory function for custom interactions
emitterToEventDescriptor<T>(emitter: Emitter<T>, type: string, signal?: AbortSignal): EventDescriptor
Converts an Emitter to a Remix EventDescriptor.
Parameters:
emitter: Emitter<T>
- Emitter to converttype: string
- Event type stringsignal?: AbortSignal
- Optional abort signal
Returns: EventDescriptor
Creates a managed container for attaching event listeners declaratively to any EventTarget
. Supports middleware-style event handling where handlers can call event.preventDefault()
to stop subsequent handlers.
Parameters:
target: Target
- TheEventTarget
(e.g., an element, window, or document) to attach listeners to
Returns: EventContainer with .on()
and .cleanup()
methods
Example:
import { events, dom, press } from '@doeixd/events';
const button = document.getElementById('my-button')!;
// Create an event container
const buttonEvents = events(button);
// Attach listeners declaratively
buttonEvents.on([
// Standard DOM event
dom.click(event => {
console.log('Button was clicked!');
}),
// Built-in interaction
press(event => {
console.log(`Button was pressed via a ${event.detail.originalEvent.type} event.`);
})
]);
// Clean up all listeners
buttonEvents.cleanup();
createInteraction<Target extends EventTarget, Detail = any, Options = any>(eventName: string, factory: InteractionFactory<Target, Detail, Options>): (handler: EventHandler<CustomEvent<Detail>, Target>, options?: Options) => InteractionDescriptor<Target>
Creates a reusable, stateful interaction that composes multiple low-level DOM events into a single, semantic custom event.
Parameters:
eventName: string
- The name of the custom event this interaction will dispatchfactory: InteractionFactory<Target, Detail, Options>
- Function that implements the interaction logic
Returns: Function that creates InteractionDescriptors when called with a handler and options
Example:
import { createInteraction, dom } from '@doeixd/events';
const longPress = createInteraction<Element, { duration: number }>(
'longpress',
({ target, dispatch }) => {
let timer: number;
const onMouseDown = dom.mousedown(target);
const onMouseUp = dom.mouseup(window);
const downSub = onMouseDown(e => {
const startTime = Date.now();
timer = setTimeout(() => {
dispatch({ detail: { duration: Date.now() - startTime } });
}, 500);
});
const upSub = onMouseUp(() => clearTimeout(timer));
return [downSub, upSub];
}
);
// Use the interaction
events(myElement, [
longPress(e => {
console.log(`Element was long-pressed for ${e.detail.duration}ms!`);
})
]);
Built-in interaction that normalizes user input across mouse, keyboard, and touch into a single "press" event.
Type: InteractionDescriptor<Element>
Dispatches: Custom event with type 'press'
and detail { originalEvent: Event }
Triggers on:
- Left mouse button click
Enter
orSpace
key press- Touch tap
Example:
import { events, press } from '@doeixd/events';
events(button, [
press(event => {
console.log(`Pressed with a ${event.detail.originalEvent.type} event.`);
})
]);
fromHandler<T>(handler: Handler<T>, type: string, callback: (data: T) => void, options?: AddEventListenerOptions): EventDescriptor
Converts a @doeixd/events
Handler into a Remix-compatible EventDescriptor for declarative event attachment.
Parameters:
handler: Handler<T>
- The handler to convert (typically fromdom.*
or other handler chains)type: string
- The DOM event name this descriptor should listen tocallback: (data: T) => void
- Function called with data from the handler chainoptions?: AddEventListenerOptions
- StandardaddEventListener
options
Returns: EventDescriptor ready for use with events()
Example:
import { events, fromHandler, dom, debounce } from '@doeixd/events';
// Create a debounced input handler
const onDebouncedInput = debounce(300)(dom.input(inputElement));
// Convert to EventDescriptor
const descriptor = fromHandler(onDebouncedInput, 'input', (event) => {
console.log('Debounced value:', event.target.value);
});
// Use in declarative events
events(inputElement, [descriptor]);
Creates an async reactive subject that loads from a promise and applies updates.
Parameters:
asyncSource: () => Promise<T>
- Function returning initial promisehandlers: Handler<any>[]
- Handlers that apply updates
Returns: Async subject
Example:
const data = createAsyncSubject(
() => fetch('/api/data').then(r => r.json()),
onRefresh(() => fetch('/api/data').then(r => r.json()))
);
Creates a mutable state store (like SolidJS stores).
Parameters:
initial: T | (() => T)
- Initial state or functionhandlers: Handler<any>[]
- Handlers that mutate state
Returns: Subject store
Example:
const store = createSubjectStore({ count: 0 },
onIncrement((delta) => (state) => {
state.count += delta;
})
);
Merges multiple event handlers into one.
Parameters:
handlers: Handler<T[number]>[]
- Handlers to merge
Returns: Combined handler
Example:
const [onA, emitA] = createEvent<string>();
const [onB, emitB] = createEvent<number>();
const topic = createTopic(
onA((msg) => `A: ${msg}`),
onB((num) => `B: ${num}`)
);
topic((result) => console.log(result));
emitA('hello'); // Logs: A: hello
emitB(42); // Logs: B: 42
Splits a handler based on a predicate into two handlers.
Parameters:
source: Handler<T>
- Source handler to splitpredicate: (value: T) => boolean
- Function to test values
Returns: Tuple of [trueHandler, falseHandler]
Example:
const [onNumber, emitNumber] = createEvent<number>();
const [onPositive, onNegative] = createPartition(onNumber, (n) => n >= 0);
onPositive((n) => console.log('Positive:', n));
onNegative((n) => console.log('Negative:', n));
emitNumber(5); // Logs: Positive: 5
emitNumber(-3); // Logs: Negative: -3
Combines the latest values from multiple handlers.
Parameters:
handlers: Handler<T>[]
- Handlers to combine
Returns: Handler that emits arrays of latest values
Example:
const [onA, emitA] = createEvent<string>();
const [onB, emitB] = createEvent<number>();
const combined = combineLatest(onA, onB);
combined(([msg, num]) => console.log(msg, num));
emitA('hello');
emitB(42);
// Logs: hello 42
subjectFromEvent
assubjectFromDomEvent
subjectProperty
assubjectDomProperty
createSubject
ascreateSubjectSolid
@doeixd/events
includes utilities for bridging with the classic EventEmitter pattern. Use fromEmitterEvent()
, toEmitterEvent()
, and adaptEmitter()
to seamlessly integrate legacy EventEmitter-based systems with reactive patterns.
import { fromEmitterEvent, toEmitterEvent, adaptEmitter } from '@doeixd/events';
import { EventEmitter } from 'events';
const emitter = new EventEmitter();
// Consume events from EventEmitter
const onData = fromEmitterEvent(emitter, 'data');
onData((payload) => console.log('Received:', payload));
// Drive EventEmitter with reactive logic
const [onAction, emitAction] = createEvent<string>();
toEmitterEvent(emitter, 'action', onAction);
// Adapt entire emitter to reactive interface
const reactive = adaptEmitter<{ data: string }>(emitter);
reactive.data((payload) => console.log(payload));
RxJS-style operators for event stream transformation. These operators provide powerful, composable tools for processing event data in a functional programming style.
π Operators Guide - Comprehensive documentation with examples, best practices, and API reference.
The actor system provides a powerful abstraction for managing state and behavior in a reactive, encapsulated way. Actors combine state management with event-driven logic, providing a robust solution for both simple UI state and complex concurrent operations.
The core of the system is the createActor
primitive, which supports two distinct execution modes to fit your specific needs:
'direct'
Mode (Default): For simple, synchronous state management. It's fast, ergonomic, and perfect for self-contained UI components where state logic is straightforward. This is the original behavior ofcreateActor
.'queued'
Mode: For advanced, concurrent state management. It guarantees that all actions are processed sequentially, one at a time, eliminating race conditions entirely. This mode is essential for handling async operations, managing shared resources, or building resilient services.
Creates a new, type-safe actor instance with reactive state and event-driven methods.
Parameters:
initialContext: TContext
- The initial state object for the actor.behavior: TBehavior | SetupFn
- Defines the actor's logic. This can be:- A behavior map (object with methods) for the modern API, required for
'queued'
mode. - A setup function for the original, backward-compatible API (implicitly uses
'direct'
mode).
- A behavior map (object with methods) for the modern API, required for
options?: ActorOptions
- An optional configuration object to specify themode
and anyeffects
.
Returns: A fully typed Actor
instance with a reactive state accessor, a subscribe
method, a dispose
method, and all the methods defined in its behavior.
This is the default mode, optimized for simplicity and performance in synchronous contexts. It uses a mutable state object that you can modify directly.
Best for:
- UI component state (forms, toggles, counters).
- Scenarios where all state updates are synchronous.
Example:
import { createActor, createEvent } from '@doeixd/events';
// In 'direct' mode, we use the original setup function API.
const counterActor = createActor(
{ count: 0 },
(context) => { // context is a mutable proxy
const [onIncrement, increment] = createEvent<number>();
onIncrement((by) => {
// Direct mutation is the pattern here.
context.count += by;
});
const [onReset, reset] = createEvent();
onReset(() => {
context.count = 0;
});
return { increment, reset };
}
);
// Access current state synchronously
console.log(counterActor()); // { count: 0 }
// Trigger behavior - state updates happen immediately
counterActor.increment(5);
console.log(counterActor()); // { count: 5 }
// Subscribe to state changes
counterActor.subscribe((state) => console.log('State changed:', state));
counterActor.reset(); // Logs: "State changed: { count: 0 }"
This mode provides strong guarantees for concurrent applications. It uses an immutable state pattern and processes all actions sequentially from a queue, which eliminates race conditions.
Best for:
- Managing state that involves API calls or other async operations.
- Controlling access to shared resources (e.g., a WebSocket connection).
- Any situation where multiple events could try to update state at the same time.
Example:
import { createActor } from '@doeixd/events';
// In 'queued' mode, we use the new behavior map API.
const userProfileActor = createActor(
{ status: 'idle', user: null, error: null },
{
// A "cast": updates state, returns Promise<void>
fetchUser: async (state, userId: string) => {
// Handlers must return a NEW state object (immutable pattern)
return { ...state, status: 'loading' };
},
// Another "cast"
_fetchSuccess: (state, user: { name: string }) => {
return { ...state, status: 'success', user };
},
// A "call": returns a value, returns Promise<Reply>
getUserName: (state) => {
return state.user?.name ?? 'Not loaded';
},
},
{ mode: 'queued' } // Explicitly enable the safe, queued mode
);
// --- Usage ---
async function main() {
// Methods in 'queued' mode are async, returning Promises.
userProfileActor.fetchUser('123'); // This is non-blocking.
console.log(userProfileActor().status); // 'loading'
// Let's simulate the API call completing
await new Promise(r => setTimeout(r, 100));
const fakeUser = { name: 'Jane Doe' };
userProfileActor._fetchSuccess(fakeUser);
// Get the final state
const userName = await userProfileActor.getUserName();
console.log(`User: ${userName}`); // "User: Jane Doe"
console.log(userProfileActor().status); // 'success'
}
main();
Creates a derived reactive value from one or more actor or subject sources. The derived state automatically updates whenever any of the source actors change.
Parameters:
sources: Subscribable<any>[]
- Array of actors or subjects to derive from.projection: () => T
- A function that computes the derived value from the current states of the sources.
Returns: A read-only reactive Subject
with an added dispose()
method to clean up all underlying subscriptions.
Example:
import { createActor, select, createEvent } from '@doeixd/events';
const authActor = createActor({ isLoggedIn: false }, (ctx) => {
const [onLogin, login] = createEvent();
onLogin(() => (ctx.isLoggedIn = true));
return { login };
});
const cartActor = createActor({ items: [] as string[] }, (ctx) => {
const [onAddItem, addItem] = createEvent<string>();
onAddItem((item) => ctx.items.push(item));
return { addItem };
});
// Derive computed state from multiple actors
const canCheckout = select(
[authActor, cartActor],
() => authActor().isLoggedIn && cartActor().items.length > 0
);
canCheckout.subscribe((canUserCheckout) => {
console.log(`Can checkout: ${canUserCheckout}`);
});
console.log(canCheckout()); // false
authActor.login(); // Triggers re-computation
cartActor.addItem('item1'); // Triggers re-computation
console.log(canCheckout()); // true
// It's important to clean up derived state to prevent memory leaks.
canCheckout.dispose();
The fully-inferred type representing an actor instance.
Type Parameters:
TContext
- The shape of the actor's internal state object.TBehavior
- The shape of the behavior map or emitters.TMode
- The execution mode ('direct'
or'queued'
).
Properties:
(): TContext
- Function to access the current state snapshot.subscribe(...)
- Subscribes to state changes.dispose()
- Shuts down the actor and cleans up resources....methods
- All the methods defined in the actor's behavior, with correctly inferred return types (void
orPromise<T>
).
A type for a side-effect function that runs after an actor's state has changed.
Signature: (newContext: TContext, oldContext: TContext) => void
Example:
const loggingEffect = (newContext, oldContext) => {
console.log('State changed from:', oldContext, 'to:', newContext);
};
const actor = createActor(
{ count: 0 },
(context) => {
const [onIncrement, increment] = createEvent();
onIncrement(() => context.count++);
return { increment };
},
{ effects: [loggingEffect] } // Pass effects in the options object
);
While createActor
is a versatile primitive, many applications need a more structured way to define long-running, shared servicesβthings like an authentication manager, a WebSocket client, or an in-memory cache. For this, the library provides createService
, a high-level abstraction built on top of createActor
.
A Service is a singleton-like actor that is explicitly initialized. It formalizes the pattern of having an init
step that can fail, ensuring that you only get an instance of your service if it can start up correctly.
It is always built on a 'queued'
mode actor, providing the same powerful guarantees of sequential processing and race condition prevention.
- Formal Initialization: Services have an
init()
function that can beasync
and can fail gracefully. This is perfect for initial data fetching or setup validation. - Structured Definition: Logic is organized into a
ServiceModule
, which clearly separates initialization from the runtime behavior. - Singleton-like Pattern: You define the service once with
createService
, which gives you astart()
method to create and launch an instance when needed.
Creates a service factory from a ServiceModule
definition.
Parameters:
module: ServiceModule
- An object containing the service'sinit
logic and itsbehavior
map.
Returns: An object with a start(...args)
method. Calling start
will initialize and launch a new instance of the service, returning a Promise
that resolves with the running service actor.
This example demonstrates how to build a simple, time-aware cache that is safe from concurrent read/write issues.
import { createService, ServiceModule } from '@doeixd/events';
// 1. Define the State and Behavior types for clarity
type CacheState = {
data: Map<string, { value: any; expiresAt: number }>;
};
type CacheBehavior = {
set(state: CacheState, key: string, value: any, ttlMs: number): CacheState;
get(state: CacheState, key: string): any | undefined;
prune(state: CacheState): CacheState;
};
// 2. Implement the ServiceModule
const CacheServiceModule: ServiceModule<CacheState, [], CacheBehavior> = {
// `init` is called by `start()`. It can be async.
init() {
console.log('Cache service initializing...');
return { ok: true, state: { data: new Map() } };
},
// The `behavior` map is identical to the one used in `createActor`.
behavior: {
// A "cast": returns a new state object.
set(state, key, value, ttlMs) {
const newData = new Map(state.data);
newData.set(key, { value, expiresAt: Date.now() + ttlMs });
return { data: newData };
},
// A "call": returns a non-state value.
get(state, key) {
const entry = state.data.get(key);
if (entry && Date.now() < entry.expiresAt) {
return entry.value;
}
return undefined; // Entry is missing or expired.
},
// A "cast": updates state by removing expired items.
prune(state) {
const now = Date.now();
const newData = new Map(state.data);
for (const [key, entry] of newData.entries()) {
if (now > entry.expiresAt) {
newData.delete(key);
}
}
return { data: newData };
}
},
};
// 3. Create the service factory from the module definition
const CacheService = createService(CacheServiceModule);
// 4. In your application's startup logic, start the service
async function main() {
const startResult = await CacheService.start();
if (!startResult.ok) {
console.error('Failed to start cache service:', startResult.reason);
return;
}
const cache = startResult.service;
// Now interact with the running service via its direct, async methods.
await cache.set('user:1', { name: 'Alice' }, 5000);
const user = await cache.get('user:1');
console.log('Fetched user:', user); // Fetched user: { name: 'Alice' }
// Because a service is just an actor, you can subscribe to its state.
cache.subscribe(newState => {
console.log(`Cache size is now ${newState.data.size}`);
});
await cache.set('user:2', { name: 'Bob' }, 10000); // Logs: "Cache size is now 2"
// Clean up when the application shuts down.
cache.dispose();
}
main();
While they are built on the same foundation, they are optimized for different use cases.
Use Case | Recommended Primitive | Why? |
---|---|---|
UI Component State | createActor (direct mode) |
Simple, synchronous, and co-located with the component. Fast and lightweight. |
Complex Async Logic | createActor (queued mode) |
Encapsulates async flows and prevents race conditions without the formality of a service. |
Shared Application Logic | createService |
Provides a clear initialization path (init ) and a singleton-like pattern for cross-cutting concerns like auth or data caching. |
Managing External Resources | createService |
init is perfect for establishing connections (e.g., WebSocket, IndexedDB), and the queued actor ensures safe, ordered interaction. |
The reducer system provides immutable, type-safe state management with fluent chaining and optional side effects.
createReducer<TState, TReducers>(config: ReducerConfig<TState, TReducers>): ReducerStore<TState, TReducers>
Creates a reducer store for general-purpose state management.
Parameters:
config
- Configuration with initial state, actions map, and optional effects
Example:
import { createReducer } from '@doeixd/events';
const store = createReducer({
initialState: { count: 0 },
actions: {
increment: (state, amount: number) => ({ count: state.count + amount }),
decrement: (state, amount: number) => ({ count: state.count - amount })
}
});
const updated = store.dispatch.increment(5).dispatch.decrement(2);
console.log(updated()); // { count: 3 }
createGuardedReducer<TState, TActions>(config: { initialState: TState; actions: TActions }): StateGuardedReducerStore<TState, TActions>
Creates a state-guarded reducer for compile-time state machine safety.
Parameters:
config
- Configuration with initial state and actions map
Example:
import { createGuardedReducer } from '@doeixd/events';
type State = { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: string };
const store = createGuardedReducer<State, any>({
initialState: { status: 'idle' },
actions: {
start: (state) => ({ status: 'loading' }),
finish: (state, data: string) => ({ status: 'success', data })
}
});
// Only valid actions are available based on current status
const loading = (store as any).dispatch.start();
const success = (loading as any).dispatch.finish('Done');
The state machine system provides type-safe, generator-based state management for complex workflows and sequential logic. State machines excel at orchestrating multi-step processes with compile-time guarantees about valid state transitions.
Creates a context builder for defining state machine transitions in a type-safe way.
Type Parameters:
TAllContexts
- Union type of all possible state shapes (e.g.,{ state: 'idle' } | { state: 'running' }
)
Returns: ContextBuilder instance for chaining transition definitions
Example:
import { defineContext } from '@doeixd/events';
type States = { state: 'idle' } | { state: 'running'; count: number };
const machineDef = defineContext<States>()
.transition('start', (ctx) => ({ state: 'running', count: 0 }))
.transition('increment', (ctx, amount: number) => ({
...ctx,
count: ctx.count + amount
}))
.transition('stop', (ctx) => ({ state
8020
: 'idle' }))
.build();
Builder class for defining state machine transitions and context creation.
transition<TKey extends string, TFromContext extends TAllContexts, TPayload, TToContext extends TAllContexts>(key: TKey, transitionFn: (ctx: TFromContext, payload: TPayload) => TToContext): ContextBuilder<TAllContexts>
Adds a transition to the state machine.
Parameters:
key: TKey
- Unique name for the transitiontransitionFn: (ctx: TFromContext, payload: TPayload) => TToContext
- Pure function that transforms state
Returns: Builder instance for chaining
Finalizes the machine definition and returns the internal configuration.
Returns: Object with transitions map and context creation function
createMachine<TAllContexts extends { state: string }>(definition: ReturnType<ContextBuilder<TAllContexts>['build']>, initialStateFn: StateFn<TAllContexts, any>, initialContextData: TAllContexts): Actor<{ context: TAllContexts }, {}>
Creates a running state machine instance.
Parameters:
definition
- Machine definition from ContextBuilder.build()initialStateFn: StateFn<TAllContexts, any>
- Generator function defining the initial state logicinitialContextData: TAllContexts
- Initial state data
Returns: Actor representing the running machine
Example:
import { createMachine, MachineContext } from '@doeixd/events';
function* counterLogic(ctx: MachineContext<States, { state: 'idle' }>) {
while (true) {
const runningCtx = yield* ctx.start();
yield* runningCtx.increment(5);
yield* runningCtx.stop();
}
}
const counter = createMachine(machineDef, counterLogic, { state: 'idle' });
// Subscribe to state changes
counter.subscribe(state => {
console.log('State:', state.context.state, 'Count:', state.context.count);
});
The intelligent, type-safe context object passed to state generator functions. Contains the current state data and dynamically added transition methods.
Type Parameters:
TAllContexts
- Union of all possible state shapesTCurrentContext
- Current specific state shape
Properties:
- All properties from
TCurrentContext
batch(fn: Generator): Generator
- Groups multiple transitions into atomic updates- Dynamically added transition methods based on defined transitions
Type for state generator functions that define machine behavior.
Type Parameters:
TAllContexts
- Union of all possible state shapesTCurrentContext
- Current state shape for this function
Signature: (ctx: MachineContext<TAllContexts, TCurrentContext>, ...args: any[]) => Generator<any, void, any>
import { defineContext, createMachine, MachineContext } from '@doeixd/events';
// Define state types
type TrafficLightStates =
| { readonly state: 'red'; readonly canGo: false }
| { readonly state: 'green'; readonly canGo: true }
| { readonly state: 'yellow'; readonly canGo: false };
// Define transitions
const trafficLightDef = defineContext<TrafficLightStates>()
.transition('timerExpires', (ctx: { state: 'red' }) => ({ state: 'green', canGo: true }))
.transition('timerExpires', (ctx: { state: 'green' }) => ({ state: 'yellow', canGo: false }))
.transition('timerExpires', (ctx: { state: 'yellow' }) => ({ state: 'red', canGo: false }))
.transition('emergency', (ctx: { state: 'green' } | { state: 'yellow' }, payload: { reason: string }) => ({
state: 'red',
canGo: false
}))
.build();
// Define state logic
function* redLight(ctx: MachineContext<TrafficLightStates, { state: 'red' }>) {
console.log('π¦ Red light - Stop!');
const greenCtx = yield* ctx.timerExpires();
yield* greenLight(greenCtx);
}
function* greenLight(ctx: MachineContext<TrafficLightStates, { state: 'green' }>) {
console.log('π¦ Green light - Go!');
const yellowCtx = yield* ctx.timerExpires();
yield* yellowLight(yellowCtx);
}
function* yellowLight(ctx: MachineContext<TrafficLightStates, { state: 'yellow' }>) {
console.log('π¦ Yellow light - Caution!');
const redCtx = yield* ctx.timerExpires();
yield* redLight(redCtx);
}
// Create and run the machine
const trafficLight = createMachine(
trafficLightDef,
redLight,
{ state: 'red', canGo: false }
);
// Subscribe to state changes
trafficLight.subscribe(state => {
console.log(`Light changed to: ${state.context.state}`);
});
// Trigger emergency (only available when green or yellow)
setTimeout(() => {
// This would be a compile error if called from red state
// trafficLight.trigger('emergency', { reason: 'Ambulance approaching' });
}, 1000);
Aspect | State Machines | Reducers |
---|---|---|
Purpose | Orchestrate complex workflows with sequential steps | Manage structured state with predictable transitions |
Execution Model | Generator-based imperative logic | Declarative action-based updates |
State Transitions | Type-safe sequences with compile-time guarantees | Guarded transitions with runtime validation |
Best For | Multi-step processes, authentication flows, game states | Form validation, data fetching, UI state management |
Control Flow | yield* for step-by-step execution |
dispatch.action() for state updates |
Complexity | High - handles complex sequential logic | Medium - manages interrelated state |
The library features a robust internal subscription system for reliable resource management:
- Modern Environments: Automatically uses the native
DisposableStack
API for enhanced error handling and cleanup reliability - Error Suppression: In modern runtimes, errors during unsubscription are properly suppressed using
SuppressedError
, ensuring all cleanup functions execute - Performance: Leverages optimized native implementations where available
- Universal Support: Provides a custom array-based subscription stack for environments without
DisposableStack
- Graceful Degradation: Maintains full functionality with console logging for errors in fallback mode
- Zero Configuration: Runtime detection requires no user setup
- Consistent API: All combinators (
createTopic
,combineLatest
,select
,on
) use the same internal stack abstraction - Type Safety: Full TypeScript support with compile-time guarantees
- Resource Safety: Ensures proper cleanup even when individual unsubscribe functions throw errors
createSubscriptionStack()
: Factory function that returns a subscription manager, usingDisposableStack
in modern environments or an array-based fallback otherwisecreateSubscriptionManager()
: Higher-level manager implementing the Disposable protocol for automatic cleanup with theusing
keyword
- Promises in handlers are automatically flattened
- Use
await
in async handlers for sequential processing - Avoid infinite loops in reactive updates
- Automatic cancellation with
AbortSignal
prevents race conditions
π Async Handling Guide - Deep dive into cancellation, control flow, disposal, and batching.
- Use
halt()
to conditionally stop processing - Halting throws an internal symbol, not an error
- Useful for validation and conditional logic
- Call
subject(newValue)
to update and notify subscribers - Subscribers receive the current value immediately on subscription
- Dispose subjects to clean up resources
- Use
subjectProperty
for reactive DOM bindings - Clean up event listeners with AbortSignal
- DOM utilities work in any environment with DOM API
- Unsubscribe from handlers when no longer needed
- Use
dispose()
on subjects to clear subscribers - Pass AbortSignal to DOM handlers for automatic cleanup
Q: My async handler isn't working as expected A: Ensure you're awaiting promises properly. Async handlers flatten automatically, but timing matters in tests.
Q: TypeScript complains about my handler types
A: Check that your event payloads match the expected types. Use DUMMY
for type testing if needed.
Q: DOM events aren't firing A: Make sure elements are attached to the DOM before setting up handlers. Use AbortSignal for cleanup.
Q: Memory leaks in long-running apps A: Always unsubscribe from handlers and dispose subjects when components unmount.
The DUMMY
export is a special value used internally for type checking handlers:
import { DUMMY } from '@doeixd/events';
// DUMMY helps determine if a handler returns a value
// Used automatically - you typically don't need to use it directly
Inspired by solid-events, remix events, SolidJS, RxJS, and modern reactive programming patterns. Built with TypeScript for maximum type safety and developer experience.