6. Announce what happened
Tell other plugins what happened, with typed, runtime-owned subscriptions.
Owner: @bitakit/core; discovery generates the event types from each plugin's events.ts.
An event announces that something happened. Here the Counter increments, emits counter.incremented, and an audit plugin records it. The service owns the data; the event lets other plugins react without calling each other directly.
Inside a plugin you rarely need an event: actions and pages follow a service's store() automatically. Use an action when you want to request an operation and receive its outcome, a store when something should follow state, and an event when another plugin should be notified.
1. Declare one event
// src/plugins/counter/events.ts
import { defineEvents, event } from '@bitakit/core';
export default defineEvents({ incremented: event<number>() });Discovery names it counter.incremented and generates its payload type.
Event names start with a letter and then use letters, digits, _ or -, without dots.
event() declares no payload; event({ schema }) can validate data through the schema's
parse method at emit time. The bus belongs to this runtime, with no global state,
persistence or cross-process delivery.
2. Emit after the change
In the Counter Action, add events to the execution context:
execute: ({ services, settings, events }) => {
services.counter.value.increment(settings.counter.step.get());
events.emit('counter.incremented', services.counter.value.state.get().count);
},This is an edit to the existing Action, not a separate file. Only the declaring plugin can emit its event.
3. Listen from another plugin
Select a listener to see its service. Both subscribe to the same event and react independently; select Counter to see the complete publishing Action. The event was declared in step 1.
counter.incremented
Delivered to both subscribers
Audit listens
This service logs the notification. Its subscription is automatically released when the service stops.
import { defineService } from '@bitakit/core';export default defineService(({ events }) => { events.on('counter.incremented', ({ data }) => console.log('Count:', data)); return {};});Subscriptions belong in a service, or in React's useEvent hook, so their lifetime is owned.
An Action definition does not subscribe by itself. A listener can call a service operation;
if it explicitly invokes an Action, for example app.actions.counter.increment.execute(), the
Action's usual guards and execution rules still apply.
The subscription is released with its service. Events are not replayed; read current state through its owning service rather than waiting for an event that may already have happened.
events.on accepts an exact name, an array of names (a discriminated { type, data }
union), a prefix such as counter.*, or *. It returns an idempotent unsubscribe function.
In React, useEvent from @bitakit/ui follows the latest callback and releases the
subscription when its names/runtime change or the component unmounts. Inline arrays with
the same names do not cause resubscription. It waits for runtime readiness; earlier events
are not replayed.
What you have now
- This step gave you a typed announcement,
counter.incremented, that only the Counter emits. - Others extend it by listening from a service or with
useEvent, without the Counter knowing who listens.
Next: Add an extension point →
Later: cancelable events
Use cancellation only when the emitter must ask listeners before continuing:
// Independent example: src/plugins/documents/events.ts
export default defineEvents({ deleting: event<string>({ cancelable: true }) });Keep the same imports as the declaration example. In a documents Action or service:
const allowed = await events.emit('documents.deleting', documentId);
if (!allowed) return;
// Continue with the owning service's delete operation.A listener can call event.cancel?.('Reason'). Cancelable emit returns Promise<boolean>;
ordinary emit returns void. This is coordination, not a replacement for server authorization.
Delivery and errors
emit calls exact-name listeners first, then matching wildcard groups. Each group retains subscription order. An async handler starts immediately, but the emitter does not await it; cancelable events are the exception. Both synchronous throws and promise rejections go to onEventError(error, event) on the runtime configuration, console.error by default. One failing handler does not prevent other handlers from receiving the event. Callbacks may emit further events synchronously, so avoid cycles.
Each subscription supplies an AbortSignal, aborted when unsubscribed. Pass it to abort-aware operations and check it before external side effects after await:
events.on('documents.reload', async (event, { signal }) => {
const response = await fetch('/api/documents', { signal });
const documents = await response.json();
if (!signal.aborted) updateDocuments(documents);
});Without discovery
In a manual composition, add the declaration to the plugin (events: counterEvents) and augment the maps yourself when you need types:
import type { CancelableOf, EventsOf } from '@bitakit/core';
import type counterEvents from '../plugins/counter/events.ts';
import type documentEvents from '../plugins/documents/events.ts';
declare module '@bitakit/core' {
interface EventMap
extends EventsOf<typeof counterEvents, 'counter'>,
EventsOf<typeof documentEvents, 'documents'> {}
interface CancelableEventMap extends CancelableOf<typeof documentEvents, 'documents'> {}
}This example lives in src/types/events.ts and references the two declarations above.
Types do not register events; each plugin must still include its declaration.
Known events require their declared payload; names nobody declared remain allowed with an
unknown payload. See Testing plugins to assert emitted events.