Package Exports
- @kellanjs/eventcraft
- @kellanjs/eventcraft/package.json
- @kellanjs/eventcraft/react
Readme
Eventcraft
A tiny TypeScript event helper for components that need to emit events and subscribe to exactly the events they care about.
Eventcraft gives you a typed event tree. Emit from leaf events, then subscribe to one event, a whole branch, the root, a named group, or a one-off selection.
import { event, eventcraft, group } from "@kellanjs/eventcraft";
const events = eventcraft()
.events({
notes: {
created: event<{ id: string; title: string }>(),
updated: event<{ id: string; title: string }>(),
deleted: event<{ id: string }>(),
},
saved: event(),
})
.groups((events) => ({
searchInvalidating: group(events.notes),
}))
.build();
events.searchInvalidating.$subscribe((event) => {
console.log("Refresh search because:", event.name);
});
await events.notes.updated({
id: "note-1",
title: "Updated title",
});Install
npm install @kellanjs/eventcraftWhy Use It?
- Typed payloads:
event<T>()controls what can be emitted. - Tree-shaped subscriptions: subscribe to
events.notesinstead of string wildcards like"notes.*". - Small runtime API: events are callable functions with
$emit,$subscribe,$once, and metadata. - Reusable groups: name important event sets once and use them across the app.
- React-friendly: optional
useEventhook handles component cleanup. - No required React dependency: the core package is framework-agnostic.
Define Events
Create a registry with eventcraft().events(...).build().
import { event, eventcraft } from "@kellanjs/eventcraft";
const events = eventcraft()
.events({
notes: {
created: event<{ id: string; title: string }>(),
updated: event<{ id: string; title: string }>(),
deleted: event<{ id: string }>(),
},
saved: event(),
})
.build();Use event<T>() for payloadful events. Use event() for payloadless events.
Emit Events
Events are callable.
await events.notes.created({
id: "note-1",
title: "First note",
});
await events.saved();You can also use $emit.
await events.notes.deleted.$emit({ id: "note-1" });
await events.saved.$emit();Subscribe To One Event
Leaf event listeners receive (payload, event).
const unsubscribe = events.notes.updated.$subscribe((payload, event) => {
console.log(payload.title);
console.log(event.name); // "notes.updated"
});
unsubscribe();Every emitted event includes:
type EventEnvelope = {
id: string;
name: string;
payload: unknown;
timestamp: number;
};By default, ids come from crypto.randomUUID() and timestamps come from Date.now(). You can override both for tests or custom tracing.
const events = eventcraft({
createId: () => "event-1",
now: () => 123,
})
.events({
saved: event(),
})
.build();Subscribe To Event Sources
Branches, the root registry, named groups, and selections are all event sources. They receive the full event object.
Branch
events.notes.$subscribe((event) => {
console.log(event.name); // notes.created | notes.updated | notes.deleted
});Root
events.$subscribe((event) => {
console.log("Something happened:", event.name);
});Named Group
import { group } from "@kellanjs/eventcraft";
const events = eventcraft()
.events({
notes: {
created: event<{ id: string }>(),
updated: event<{ id: string }>(),
deleted: event<{ id: string }>(),
},
tags: {
updated: event<{ id: string }>(),
},
})
.groups((events) => ({
searchInvalidating: group(events.notes, events.tags.updated),
}))
.build();
events.searchInvalidating.$subscribe((event) => {
console.log(event.name);
});One-Off Selection
events
.$select([events.notes, events.tags.updated])
.$subscribe((event) => {
console.log(event.name);
});You can also create a selection without the root registry.
import { select } from "@kellanjs/eventcraft";
select([events.notes.updated, events.tags.updated]).$subscribe((event) => {
console.log(event.name);
});Overlapping sources are deduplicated by event node identity. If you select both a branch and one of its child events, the listener runs once for that child event. Separate registries can still contain distinct events with the same $name.
Cleanup
$subscribe returns an unsubscribe function.
const unsubscribe = events.notes.updated.$subscribe(listener);
unsubscribe();You can bind subscriptions to an AbortSignal.
const controller = new AbortController();
events.notes.updated.$subscribe(listener, {
signal: controller.signal,
});
controller.abort();Use $once when you only want the next matching event.
events.saved.$once((payload, event) => {
console.log(event.id);
});
events.notes.$once((event) => {
console.log(event.name);
});React
The React hook is available from the React subpath.
import { useEvent } from "@kellanjs/eventcraft/react";
type AppEvents = typeof events;
function NoteListener({ events }: { events: AppEvents }) {
useEvent(events.notes.updated, (payload) => {
console.log("Note updated:", payload.id);
});
return null;
}useEvent accepts the same event sources as the core API.
useEvent(events.notes, (event) => {
console.log(event.name);
});
useEvent(events.searchInvalidating, (event) => {
console.log(event.name);
});
useEvent(events.$select([events.notes, events.tags.updated]), (event) => {
console.log(event.name);
});
useEvent(events, (event) => {
console.log(event.name);
});
useEvent([events.notes.created, events.tags.updated], (event) => {
console.log(event.name);
});The hook unsubscribes on unmount. If the source changes, it cleans up the old subscription before subscribing to the new source. The listener can change every render without resubscribing, and inline arrays are compared by member identity.
Async Iteration
Every event source is an async iterable.
for await (const event of events.notes) {
console.log(event.name);
}Use break, return, or throw to stop the iterator and clean up its internal subscription.
Error Handling
Listeners can be synchronous or asynchronous. Eventcraft runs every listener for an emit and waits for all of them to settle. One failing listener does not stop the others.
events.notes.created.$subscribe(async (payload) => {
await saveToDatabase(payload);
});After listeners settle:
- If one handler failed, the emit promise rejects with that error.
- If multiple handlers failed, the emit promise rejects with an
AggregateError.
Use onEmit and onListenerError for observability.
const events = eventcraft({
onEmit(event) {
console.log("Emitted:", event.name);
},
onListenerError(error, event) {
console.error("Listener failed:", event.name, error);
},
})
.events({
saved: event(),
})
.build();If onEmit throws or rejects, Eventcraft still delivers the event to listeners, then rejects the emit promise after listener delivery settles. onListenerError runs for listener failures and preserves the normal emit rejection behavior.
Naming Rules
Eventcraft uses $ properties internally, such as $emit, $subscribe, $once, $name, $members, and $select.
Event and group keys cannot start with $ or include .. Dots are reserved for nested event names like notes.updated.
eventcraft()
.events({
$saved: event(),
})
.build();That throws at build() time.
Groups also cannot shadow existing events or branches.
eventcraft()
.events({
notes: {
updated: event<{ id: string }>(),
},
})
.groups((events) => ({
notes: group(events.notes.updated),
}))
.build();That throws because notes is already a branch.
Public API
Core:
import { event, eventcraft, group, select } from "@kellanjs/eventcraft";React:
import { useEvent } from "@kellanjs/eventcraft/react";Types:
import type {
AnyEventSource,
BranchNode,
EventDef,
EventEnvelope,
EventListener,
EventNode,
EventcraftOptions,
EventRegistry,
GroupNode,
RootNode,
SubscribeOptions,
} from "@kellanjs/eventcraft";Thanks
If you made it this far, thanks for checking out the library, and I hope you find it useful in your projects!
License
Keycraft is open source under the terms of the MIT license.