on$#
Binds a callback to a source$, with automatic cleanup.
Use it inside an insertion to let a primitive react to an event.
A method bound with `on$` is not exposed
It works internally, driven by the source, and does not appear on the primitive's ref. That is intentional: the source is the trigger, not the caller.
Overview#
on$ enables reactive side effects by:
Listening to source emissions and executing callbacks
Automatically unsubscribing when the injection context is destroyed
Working with
source$,EventEmitter, and any ObservableAccepting a source-returning
craftServicehelper directlyReturning
SourceBrandedto prevent method exposure in state insertionsProviding a clean way to coordinate state updates with source events
Signature#
function on$<State, SourceType>(
source: {
subscribe: EventEmitter<SourceType>['subscribe'];
},
callback: (source: SourceType) => State,
): SourceBranded;
function on$<State, SourceService>(
source: SourceService,
callback: (source: SourceServiceOutput) => State,
): SourceBranded;
Parameters#
_source- A source, EventEmitter, or Observable to listen tocallback- Function executed when the source emits. Receives the emitted value and can perform side effects
Returns#
SourceBranded - A branded symbol indicating the method is not exposed on the state/store
When source is a Craft service helper returning a Source$, on$ resolves the helper in the current injection context and tracks that helper as a dependency of the containing primitive.
const { Reset } = craftService(
{ name: 'Reset', providedIn: 'global' },
function* () {
yield* source$<void>('reset$');
},
);
const counter = yield* state('counter', 0, ({ set }) => ({
reset: on$(Reset, () => set(0)),
}));
on$(Reset.reset$, ...) is the dependency edge. The service exposes the yielded source under reset$; consumers can emit through yield* Reset.reset$().
Primary Use Case#
Create internal reactive methods in state insertions that respond to sources without being exposed:
const { myState } = state('myState', 0, ({ set }) => ({
// Exposed method
increment: () => set((v) => v + 1),
// Internal reactive method (not exposed)
reset: on$(resetSource$, () => set(0)),
}));
myState.increment(); // ✅ Available as a yieldable method (`yield*` / pass the reference)
myState.reset(); // ❌ Not available (TypeScript error)
Automatic Cleanup#
on$ automatically unsubscribes from the source when the injection context is destroyed, preventing memory leaks.
Common Patterns#
State reset:
on$(resetSource, () => set(initialValue))- reset state on source emissionState synchronization:
on$(source, (value) => set(value))- sync state with sourceMulti-state coordination: Multiple states can use
on$with the same sourceConditional updates:
on$(source, (value) => { if(condition) set(value) })- conditional state changes
Examples#
Basic state reset on source emission#
import { state, source$ } from '@craft-ts/core';
import { on$ } from '@craft-ts/core';
const resetSource = source$<void>('resetSource');
const { counter } = state('counter', 0, ({ set, update }) => ({
// Exposed methods
increment: () => update((v) => v + 1),
decrement: () => update((v) => v - 1),
// Internal: resets when resetSource emits
reset: on$(resetSource, () => set(0)),
}));
console.log(yield* counter()); // 0
yield* counter.increment();
console.log(yield* counter()); // 1
resetSource.emit(); // Triggers reset
console.log(yield* counter()); // 0
// counter.reset() ❌ TypeScript error - not exposed
Syncing state with a source#
import { state, source$ } from '@craft-ts/core';
import { on$ } from '@craft-ts/core';
interface User {
id: string;
name: string;
}
const userUpdateSource = source$<User>('userUpdateSource');
const { currentUser } = state(
'currentUser',
null as User | null,
({ set }) => ({
// Exposed method
clear: () => set(null),
// Internal: updates when source emits
syncFromSource: on$(userUpdateSource, (user) => set(user)),
}),
);
console.log(currentUser()); // null
userUpdateSource.emit({ id: '1', name: 'Alice' });
console.log(currentUser()); // { id: '1', name: 'Alice' }
userUpdateSource.emit({ id: '2', name: 'Bob' });
console.log(currentUser()); // { id: '2', name: 'Bob' }
Coordinating multiple states with a single source#
import { state, source$ } from '@craft-ts/core';
import { on$ } from '@craft-ts/core';
const resetAllSource = source$<void>('resetAllSource');
const { search } = state('search', '', ({ set, update }) => ({
set,
clear: () => set(''),
// Reset when resetAllSource emits
resetOnSignal: on$(resetAllSource, () => set('')),
}));
const { page } = state('page', 1, ({ set, update }) => ({
next: () => update((v) => v + 1),
previous: () => update((v) => Math.max(1, v - 1)),
// Reset when resetAllSource emits
resetOnSignal: on$(resetAllSource, () => set(1)),
}));
const { filters } = state('filters', [] as string[], ({ set }) => ({
add: (filter: string) => set((current) => [...current, filter]),
// Reset when resetAllSource emits
resetOnSignal: on$(resetAllSource, () => set([])),
}));
// Set some values
search.set('craft');
page.next();
page.next();
filters.add('tutorial');
filters.add('advanced');
console.log(search()); // 'craft'
console.log(page()); // 3
console.log(filters()); // ['tutorial', 'advanced']
// Reset all states at once
resetAllSource.emit();
console.log(search()); // ''
console.log(page()); // 1
console.log(filters()); // []
Using on$ in a craft service#
import { craftPrivate, craftService, state, source$, craftExpose } from '@craft-ts/core';
import { on$ } from '@craft-ts/core';
const { Filters } = craftService({ name: 'Filters', providedIn: 'global' }, function* () {
const reset = yield* craftPrivate(source$<void>('reset'));
yield* state('search', '', ({ set }) => ({
set,
// Internal: reset on source emission
handleReset: on$(reset, () => set('')),
}));
yield* state('category', 'all', ({ set }) => ({
set,
// Internal: reset on source emission
handleReset: on$(reset, () => set('all')),
}));
yield* craftExpose('resetFilters', () => reset.emit());
});
const filters = Filters();
filters.search.set('craft');
filters.category.set('frameworks');
console.log(filters.search()); // 'craft'
console.log(filters.category()); // 'frameworks'
// Reset all filters
filters.resetFilters();
console.log(filters.search()); // ''
console.log(filters.category()); // 'all'
Conditional state updates#
import { state, source$ } from '@craft-ts/core';
import { on$ } from '@craft-ts/core';
interface DataUpdate {
value: number;
force?: boolean;
}
const dataSource = source$<DataUpdate>('dataSource');
const { data } = state('data', 0, ({ state, set }) => ({
// Exposed methods
setValue: (value: number) => set(value),
// Internal: conditionally update based on source data
handleUpdate: on$(dataSource, (update) => {
// Only update if value is higher or force flag is set
if (update.force || update.value > state()) {
set(update.value);
}
}),
}));
console.log(data()); // 0
dataSource.emit({ value: 10 });
console.log(data()); // 10
dataSource.emit({ value: 5 });
console.log(data()); // 10 (not updated, 5 < 10)
dataSource.emit({ value: 3, force: true });
console.log(data()); // 3 (updated due to force flag)
Working with complex transformations#
import { state, source$ } from '@craft-ts/core';
import { on$ } from '@craft-ts/core';
interface ApiResponse {
data: {
items: Array<{ id: string; value: number }>;
};
metadata: {
total: number;
};
}
const apiResponseSource = source$<ApiResponse>('apiResponseSource');
const { items } = state(
'items',
[] as Array<{ id: string; value: number }>,
({ set }) => ({
add: (item: { id: string; value: number }) =>
set((current) => [...current, item]),
// Internal: extract and set items from API response
handleApiResponse: on$(apiResponseSource, (response) => {
set(response.data.items);
}),
}),
);
const { totalCount } = state('totalCount', 0, ({ set }) => ({
// Internal: extract and set total from API response
handleApiResponse: on$(apiResponseSource, (response) => {
set(response.metadata.total);
}),
}));
apiResponseSource.emit({
data: {
items: [
{ id: '1', value: 100 },
{ id: '2', value: 200 },
],
},
metadata: {
total: 2,
},
});
console.log(items()); // [{ id: '1', value: 100 }, { id: '2', value: 200 }]
console.log(totalCount()); // 2
Using with EventEmitter#
import { EventEmitter } from '@craft-ts/core';
import { state } from '@craft-ts/core';
import { on$ } from '@craft-ts/core';
const clickEmitter = new EventEmitter<{ x: number; y: number }>();
const { lastClick } = state(
'lastClick',
null as { x: number; y: number } | null,
({ set }) => ({
clear: () => set(null),
// Internal: update on emitter events
handleClick: on$(clickEmitter, (position) => set(position)),
}),
);
clickEmitter.emit({ x: 100, y: 200 });
console.log(lastClick()); // { x: 100, y: 200 }
clickEmitter.emit({ x: 150, y: 250 });
console.log(lastClick()); // { x: 150, y: 250 }
Best Practices#
✅ Use for internal state coordination - Perfect for state updates that shouldn't be exposed as methods ✅ Coordinate multiple states - Use the same source with multiple on$ calls across different states ✅ Keep callbacks simple - Focus on state updates, avoid heavy computation ✅ Leverage automatic cleanup - No need to manually unsubscribe ✅ Prefer for side effects - Use on$ for actions, afterRecomputation for transformations
❌ Don't expose complex logic - Keep the callback focused on state changes ❌ Don't use for query/mutation params - Use afterRecomputation instead ❌ Don't chain multiple on$ calls - Keep it simple and flat
Related#
source$- Create reactive sourcesafterRecomputation- Transform sources for method parametersstate- Create reactive state with insertionscraftService- Organize coordinated states inside reusable services
See Also#
source$ — what
on$listens toLocal state — event-driven methods