Event support for reactive objects. Based on @conterra/reactivity-core
.
Click here to visit the rendered API Documentation.
import { emit, emitter, on } from "@conterra/reactivity-events";
interface ClickEvent {
x: number;
y: number;
}
class View {
readonly clicked = emitter<ClickEvent>();
}
const view = new View();
// Subscribe to the click event
on(view.clicked, (event) => {
console.log(`Click at ${event.x}, ${event.y}`);
});
// Emit the click event, calling all subscribers
emit(view.clicked, { x: 10, y: 20 });
Use emitter()
to create an event emitter.
Event emitters can be used to emit events (using emit()
) and to subscribe to them (using on
/ onSync
).
Example:
import { emitter } from "@conterra/reactivity-events";
// Emits numbers
const e1 = emitter<number>();
// Emits objects with x and y properties
const e2 = emitter<{ x: number; y: number }>();
// Emits nothing (void)
const e3 = emitter();
Use emit(emitter, event)
to emit an event.
For example:
import { emit, emitter } from "@conterra/reactivity-events";
// Emits objects with x and y properties
const clicked = emitter<{ x: number; y: number }>();
// Broadcasts the event to all subscribers of `clicked`
emit(clicked, { x: 10, y: 20 });
Use on(emitter, callback)
(or onSync()
) to subscribe to events:
import { emitter, on } from "@conterra/reactivity-events";
const clicked = emitter<{ x: number; y: number }>();
// Callback will be invoked whenever the event is emitted
const handle = on(clicked, (event) => {
console.log("Clicked at", event.x, event.y);
});
// Use the handle to unsubscribe from the event when no longer needed
handle.destroy();
Similar to watch
/ watchSync
in @conterra/reactivity-core
, there are two versions of the on
function: on
and onSync
:
on
: Callbacks are called asynchronously (in a future task, similar to setTimeout(cb, 0)
).
This is usually what you want.onSync
: Callbacks are called synchronously, immediately after they have been emitted.
Note that there is a subtle interaction with batch
(see Integration with batch
).on
and onSync
support multiple kinds of event source parameters:
view.clicked
in the example above; not reactive).In the following examples, the signal currentLogger
points to the active logger object (either logger1
or logger2
).
The subscription configured by on
automatically switches between the two loggers whenever the signal changes.
In other words, it always stays subscribed to the current one.
import { nextTick, reactive } from "@conterra/reactivity-core";
import { emit, emitter, on } from "@conterra/reactivity-events";
class Logger {
onMessage = emitter<string>();
}
const logger1 = new Logger();
const logger2 = new Logger();
const currentLogger = reactive(logger1);
// Stays subscribed to the current logger, even if it changes.
on(
() => currentLogger.value.onMessage,
(message) => console.log("on message:", message)
);
emit(logger1.onMessage, "Message to logger 1");
// Wait for event listener to be called (just for illustration).
await nextTick();
// Signal changes, `on` automatically unsubscribes from logger1 and subscribes to logger2.
currentLogger.value = logger2;
emit(logger2.onMessage, "Message to logger 2");
Configuring once: true
within a subscription will automatically unsubscribe after the first event has been emitted:
import { emit, emitter, on } from "@conterra/reactivity-events";
const clicked = emitter();
on(clicked, () => console.log("clicked"), { once: true });
emit(clicked);
emit(clicked);
// Output: clicked (only once)
Note that is still a good idea to call destroy
on the handle returned by on
if you want to unsubscribe manually before the first event has been emitted.
This package provides two important TypeScript types:
EventEmitter<T>
.
The return type of emitter<T>
.
This type supports both emitting and subscribing to events.EventSource<T>
.
This type allows only subscribing to events.
This is useful for public interfaces where users are not supposed to emit events themselves.Note that the restrictions imposed by EventSource<T>
are a compile time feature only:
at runtime, both interfaces are represented by the same object.
The following example demonstrates the use of EventSource
to separate the public interface from the implementation:
import { emit, emitter, on, EventSource } from "@conterra/reactivity-events";
interface ClickEvent {
x: number;
y: number;
}
// Public interface
interface ViewApi {
readonly clicked: EventSource<ClickEvent>;
}
// Private implementation
class ViewImpl implements ViewApi {
clicked = emitter<ClickEvent>();
}
const viewImpl = new ViewImpl();
const viewApi: ViewApi = viewImpl;
on(viewApi.clicked, (event) => console.log("Clicked at", event.x, event.y));
emit(viewImpl.clicked, { x: 1, y: 2 });
// would be a TypeScript error:
// emit(viewApi.clicked, { x: 3, y: 4 });
batch
The batch()
function from @conterra/reactivity-core
can be used to group reactive changes to one or more signals, comparable to a transaction.
Effects or watches are not executed until the batch completes.
This prevents intermediate states (which may be inconsistent) from being observed by other parts of the application.
Event handling works the same way: even when onSync
is used, event handlers are not running immediately, but only after the batch completes.
This prevents event handlers from observing intermediate states as well.
For example:
import { batch, reactive } from "@conterra/reactivity-core";
import { emit, emitter, onSync } from "@conterra/reactivity-events";
class Foo {
changed = emitter();
a = reactive(0);
b = reactive(1);
changeValues() {
batch(() => {
console.debug("batch start");
// Values are changed together.
this.a.value += 1;
emit(this.changed); // Callbacks are _not_ running here
this.b.value += 1;
console.debug("batch end");
}); // Callbacks are running here
}
}
const foo = new Foo();
onSync(foo.changed, () => {
console.log("on change");
});
foo.changeValues();
// Output:
// batch start
// batch end
// on change
Try removing the batch
call to see the difference.