Skip to content

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 Observable
  • Accepting a source-returning craftService helper directly
  • Returning SourceBranded to prevent method exposure in state insertions
  • Providing a clean way to coordinate state updates with source events

Signature ​

typescript
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 to
  • callback - 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.

typescript
const { Reset } = craftService(
  { name: 'Reset', providedIn: 'global' },
  function* () {
    const reset$ = yield* source$<void>('reset$');
    return reset$;
  },
);

const counter = yield* state('counter', 0, ({ set }) => ({
  reset: on$(Reset, () => set(0)),
}));

on$(Reset, ...) is the dependency edge. yield* Reset() can then be used to expose the same source for producing events with reset.emit().

Primary Use Case ​

Create internal reactive methods in state insertions that respond to sources without being exposed:

typescript
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 emission
  • State synchronization: on$(source, (value) => set(value)) - sync state with source
  • Multi-state coordination: Multiple states can use on$ with the same source
  • Conditional updates: on$(source, (value) => { if(condition) set(value) }) - conditional state changes

Examples ​

Basic state reset on source emission ​

typescript
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 ​

typescript
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 ​

typescript
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 ​

typescript
import { craftService, state, source$ } from '@craft-ts/core';
import { on$ } from '@craft-ts/core';

const { Filters } = craftService({ name: 'Filters', providedIn: 'global' }, () => {
  const reset = source$<void>('reset');
  const { search } = state('search', '', ({ set }) => ({
    set,
    // Internal: reset on source emission
    handleReset: on$(reset, () => set('')),
  }));
  const category = state('all', ({ set }) => ({
    set,
    // Internal: reset on source emission
    handleReset: on$(reset, () => set('all')),
  }));

  return {
    search,
    category,
    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 ​

typescript
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 ​

typescript
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 ​

typescript
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

See Also ​