Skip to main content
@craft-ts
LearnLearn with EffectAI agentsGuideReference
@craft-ts/core@craft-ts/component@craft-ts/dev-tools@craft-ts/effect@craft-ts/style@craft-ts/style-testing@craft-ts/i18n@craft-ts/i18n-effect@craft-ts/mcp
ExamplesMigrationEffect compatibility & maturityAdopting CraftTS progressivelyPress kitRoadmapBacklog
Beta
  • Learn
  • Learn with Effect
  • AI agents
  • Guide
  • Reference
  • Packages
  • Resources
  • Guide overview
  • Create a project
i.Core concepts
  • The mental model
  • Which primitive should I use?
  • Anatomy of a primitive
  • Generators and yield*
  • Insertions
  • Typed insertion pipes
  • Exceptions as values
ii.Managing state
  • Local state
  • State machines
  • query
  • Mutations
  • queryParams
  • asyncProcess
  • Selecting
  • Reacting to mutations
  • Collections
  • Collection utilities
  • Persistence
  • GlobalPersisterHandler
  • Pagination placeholders
  • Schema validation
iii.Recommended approaches
  • Inject at the point of use
iv.Structuring the app
  • craftService
  • Service scopes
  • Shaping the public API
  • Abstract services
  • App start
  • Lazy services
  • Server functions
  • craftRegisterFor
  • Target wrapper
v.Routing & type-safe DI
  • Setup
  • CLI automation
  • ESLint rules
  • Route providers
  • Route guards
  • Route exception handling
  • Non-blocking navigation
  • Global error component
  • Route load errors
  • Scaling routes
vi.Components & templates
  • Components
  • Activating the style system
  • Typed styles
  • Defining a design system
  • Tokens and variables
  • Global foundation and fonts
  • Pseudo-elements and animations
  • Axes and the matrix
  • Context obligations
  • Testing visual states
  • Text contrast
  • Visual attestation
  • Template obligations
  • Typed CSS variables
  • Fine-grained reactivity
  • Progressive rendering
  • Directives and .pipe(...)
  • settledValue & pendingNode
  • Accessibility
  • Customization
  • Content projection
  • Styling a component
  • Template migrator
vii.Forms
  • Overview
  • Validators
  • Submitting
  • Nested forms
  • Exception handling
  • Complete examples
viii.Testing
  • Testing services
  • Testing components
  • Type-level tests
  • Browser boundaries
  • Page stability
  • Architecture rules
    • Overview
    • Declarative baseline
    • Unique identities
    • HTTP endpoint ownership
    • Computed purity
    • Dependency cycles
    • Mutation reactions
    • Route DI proofs
    • Route component files
    • Path boundaries
    • Exclusive links
    • Persisted identities
    • insertSelect keys
    • craftEffect and network
    • craftEffect and imperative sync
    • Interactive element names
    • Metric thresholds
    • Server-state loaders
    • Primitive loader requirements
  • Extensible architecture graph
  • Graph insights
  • Craft graph vs Nx
ix.Reactivity utilities
  • craftComputed
  • craftEffect
  • craftMethod
  • source$
  • on$
  • fromEventToSource$
  • sourceFromEvent
  • afterRecomputation
x.Internationalisation
  • Type-safe i18n
  • The catalogue
  • Tokens
  • The runtime
  • With Effect
xi.Deployment (experimental)
  • Overview
  • Manifest reference
  • Diagnostics
  • Providers
  • Alchemy provider
xii.Going further
  • SSR and hydration
  • Effect integration
  • Program operators
  • Pattern matching
  • Observability
  • AI agents
  • Temporal runtime
  1. Reactivity utilities
  2. on$

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* () {
    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:

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 { 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#

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

Related#

  • source$ - Create reactive sources

  • afterRecomputation - Transform sources for method parameters

  • state - Create reactive state with insertions

  • craftService - Organize coordinated states inside reusable services

See Also#

  • source$ — what on$ listens to

  • fromEventToSource$

  • Local state — event-driven methods

←source$fromEventToSource$→

On this page

  • Overview
  • Signature
  • Parameters
  • Returns
  • Primary Use Case
  • Automatic Cleanup
  • Common Patterns
  • Examples
  • Basic state reset on source emission
  • Syncing state with a source
  • Coordinating multiple states with a single source
  • Using on$ in a craft service
  • Conditional state updates
  • Working with complex transformations
  • Using with EventEmitter
  • Best Practices
  • Related
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search