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. fromEventToSource$

fromEventToSource$#

Turns a DOM event into a readonly source$, with automatic cleanup.

Use it when a primitive should react to something happening on the page: a scroll, a key, a window resize.

Overview#

fromEventToSource$ bridges DOM events with craft-ts's reactive system by combining:

  • Event conversion to ReadonlySource$ emissions

  • Automatic event listener cleanup via DestroyRef

  • Optional event payload transformation

  • Signal-based reactive access to the last emitted value

  • Manual disposal capability for dynamic use cases

Import#

typescript
import { fromEventToSource$ } from '@craft-ts/core';

The component examples below also use the hyperscript helpers:

typescript
import { button, craftComponent, div, forNode, form, input, p } from '@craft-ts/component';

Signature#

typescript
function fromEventToSource$<T>(
  target: EventTarget,
  eventName: string,
  options?: {
    event?: boolean | AddEventListenerOptions;
    computedValue?: never;
  },
): FromEventToSource$<T>;

function fromEventToSource$<T, ComputedValue>(
  target: EventTarget,
  eventName: string,
  options?: {
    event?: boolean | AddEventListenerOptions;
    computedValue: (event: T) => ComputedValue;
  },
): FromEventToSource$<ComputedValue>;

Parameters#

  • target - The DOM element or event target to listen to (HTMLElement, Window, Document, etc.)

  • eventName - The event name to listen for ('click', 'input', 'scroll', etc.)

  • options (optional)

    • event - Event listener options (capture, passive, once, etc.)

    • computedValue - Function to transform the event before emission

Returns#

FromEventToSource$<T> - A readonly source with:

  • subscribe(callback: (value: T) => void) - Subscribe to event emissions

  • value: Signal<T | undefined> - Read-only signal containing the last emitted value

  • dispose() - Method to manually remove the event listener

The result is also a named yieldable primitive. The yielded source remains readonly and keeps dispose():

typescript
const clickSource = fromEventToSource$(button, 'click');
const click = yield* clickSource;

click.subscribe((event) => console.log(event));
click.dispose();

Types#

FromEventToSource$#

typescript
type FromEventToSource$<T> = ReadonlySource$<T> & {
  dispose: () => void;
} & NamedCraftPrimitiveGen<
    string,
    ReadonlySource$<T> & {
      dispose: () => void;
    }
  >;

ReadonlySource$#

typescript
type ReadonlySource$<T> = {
  subscribe: (callback: (value: T) => void) => Subscription;
  value: Signal<T | undefined>;
};

Key Features#

Source services and dependency tracking#

Expose the event source through a craftService when consumers should depend on the event handle:

typescript
const { Click } = craftService(
  { name: 'Click', providedIn: 'global' },
  function* () {
    yield* fromEventToSource$(button, 'click');
  },
);

const counter = yield* state('counter', 0, ({ set }) => ({
  click: on$(Click.click, () => set(1)),
}));

The service exposes the event source as Click.click. on$(Click.click, ...) tracks that source. Calling dispose() only removes the DOM listener and does not alter dependency metadata.

Automatic Cleanup#

Event listeners are automatically removed when the injection context is destroyed:

ts
import { craftComponent, p } from '@craft-ts/component';
import { fromEventToSource$ } from '@craft-ts/core';

export const Demo = craftComponent('Demo', {}, function* () {
  const _keydown$ = fromEventToSource$<KeyboardEvent>(document, 'keydown');

  // the listener is removed automatically when the component is destroyed
  return p('Press any key');
});

Signal Integration#

Access the last emitted value reactively via the value signal:

typescript
const input$ = fromEventToSource$(inputElement, 'input', {
  computedValue: (event: Event) => (event.target as HTMLInputElement).value,
});

// Use in template or computed
const trimmedValue = craftUse(craftComputed('trimmedValue', function* () {
  return (yield* input$.value())?.trim() ?? '';
}));

Event Transformation#

Transform events before emission using computedValue:

typescript
const resize$ = fromEventToSource$(window, 'resize', {
  computedValue: () => ({
    width: window.innerWidth,
    height: window.innerHeight,
  }),
});

// resize$.value() returns { width: number; height: number } | undefined

Integration with State#

Use with on$() to trigger state updates on DOM events:

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

const button = document.querySelector('button')!;
const click$ = fromEventToSource$<MouseEvent>(button, 'click');

const { counter } = state('counter', 0, ({ update }) => ({
  increment: on$(click$, () => update((count) => count + 1)),
}));

Examples#

Basic Click Counter#

typescript
import { craftComponent, p } from '@craft-ts/component';
import { fromEventToSource$, on$, state } from '@craft-ts/core';

export const Clicker = craftComponent('Clicker', {}, function* () {
  const click$ = fromEventToSource$<MouseEvent>(document, 'click');

  const clicks = yield* state('clicks', 0, ({ update }) => ({
    // bound to the source, so NOT exposed on the ref
    increment: on$(click$, () => update((count) => count + 1)),
  }));

  return p(function* () {
    return `Clicks: ${yield* clicks()}`;
  });
});

Input Value Tracking#

typescript
export const Search = craftComponent('Search', {}, function* () {
  const input$ = fromEventToSource$(document, 'input', {
    computedValue: (event: Event) => (event.target as HTMLInputElement).value,
  });

  // reactive access to the current input value
  const searchTerm = input$.value;

  return [
    input({ type: 'text', placeholder: 'Search…' }),
    p(function* () {
      return `You typed: ${(yield* searchTerm()) || 'nothing yet'}`;
    }),
  ];
});

Window Scroll Tracking#

typescript
export const InfiniteScroll = craftComponent(
  'InfiniteScroll',
  {},
  function* () {
    const scroll$ = fromEventToSource$(window, 'scroll', {
      computedValue: () => ({
        scrollY: window.scrollY,
        scrollHeight: document.documentElement.scrollHeight,
        clientHeight: window.innerHeight,
      }),
      event: { passive: true }, // optimize performance
    });

    scroll$.subscribe((data) => {
      const nearBottom =
        data.scrollY + data.clientHeight >= data.scrollHeight - 100;

      if (nearBottom) {
        loadMoreData();
      }
    });

    return div(
      p(function* () {
        return `Scroll position: ${(yield* scrollPosition())?.scrollY}`;
      }),
    );
  },
);

Window Resize Handling#

typescript
export const Responsive = craftComponent('Responsive', {}, function* () {
  const resize$ = fromEventToSource$(window, 'resize', {
    computedValue: () => ({
      width: window.innerWidth,
      height: window.innerHeight,
    }),
  });

  const dimensions = resize$.value;

  const isMobile = yield* craftComputed('isMobile', function* () {
    const dims = yield* dimensions();
    return dims ? dims.width < 768 : false;
  });

  return div(
    { 'data-mobile': isMobile },
    p(function* () {
      const dims = yield* dimensions();
      return `Viewport: ${dims?.width} x ${dims?.height}`;
    }),
  );
});

Keyboard Shortcuts#

ts
import { craftComponent, p } from '@craft-ts/component';
import { fromEventToSource$ } from '@craft-ts/core';

interface ShortcutEvent {
  key: string;
  ctrlKey: boolean;
  shiftKey: boolean;
  altKey: boolean;
}

export const Shortcuts = craftComponent('Shortcuts', {}, function* () {
  const save = () => console.log('Save triggered');
  const undo = () => console.log('Undo triggered');

  const keydown$ = fromEventToSource$(document, 'keydown', {
    computedValue: (event: KeyboardEvent) => ({
      key: event.key,
      ctrlKey: event.ctrlKey,
      shiftKey: event.shiftKey,
      altKey: event.altKey,
    }),
  });

  keydown$.subscribe((shortcut) => {
    if (shortcut.ctrlKey && shortcut.key === 's') save();
    else if (shortcut.ctrlKey && shortcut.key === 'z') undo();
  });

  return p('Try Ctrl+S or Ctrl+Z');
});

Dynamic Element Listening#

typescript
export const Dynamic = craftComponent(
  'Dynamic',
  {},
  function* (items: Input<readonly Item[]>) {
    let currentListener$: FromEventToSource$<MouseEvent> | undefined;

    const attachListener = (element: HTMLElement) => {
      // remove the previous listener, if any
      currentListener$?.dispose();

      currentListener$ = fromEventToSource$<MouseEvent>(element, 'click');
      currentListener$.subscribe((event) => {
        console.log('Element clicked:', event);
      });
    };

    return forNode(
      () => items(),
      { track: (item) => item.id },
      (item) =>
        div(
          button(
            { click: (event) => attachListener(event.target as HTMLElement) },
            'Attach listener',
          ),
        ),
    );
  },
);

Mouse Position Tracker#

typescript
interface Position {
  x: number;
  y: number;
}

export const CursorTracker = craftComponent('CursorTracker', {}, function* () {
  const mouseMove$ = fromEventToSource$(document, 'mousemove', {
    computedValue: (event: MouseEvent) => ({
      x: event.clientX,
      y: event.clientY,
    }),
    event: { passive: true },
  });

  return div(
    p(function* () {
      const pos = yield* position();
      return `Mouse position: ${pos?.x}, ${pos?.y}`;
    }),
  );
});

Form Submission#

ts
import { button, craftComponent, form, input, p } from '@craft-ts/component';
import { craftComputed, fromEventToSource$, on$, state, craftUse } from '@craft-ts/core';

export const SubmitDemo = craftComponent('SubmitDemo', {}, function* () {
  const submit$ = fromEventToSource$(document, 'submit', {
    computedValue: (event: Event) => {
      event.preventDefault();
      const submitted = new FormData(event.target as HTMLFormElement);
      return Object.fromEntries(submitted);
    },
  });

  const formData = yield* state(
    'formData',
    null as Record<string, unknown> | null,
    ({ state, set }) => ({
      // bound to the source, so NOT exposed on the ref
      handleSubmit: on$(submit$, (data) => set(data)),
      formDataJson: craftUse(craftComputed('formDataJson', function* () {
        return JSON.stringify(yield* state());
      })),
    }),
  );

  return form([
    input('username', { type: 'text', name: 'username' }),
    button('submit', { type: 'submit' }, 'Submit'),
    p(formData.formDataJson),
  ]);
});

Comparison with sourceFromEvent#

FeaturefromEventToSource$sourceFromEvent
Return typeReadonlySource$<T> (with subscribe, value, dispose)SignalSource<T> (with set, mutation methods)
ModificationRead-only, no emit methodWritable via set method
Use caseEvent observation and subscriptionEvent-driven source with manual control
Signal access✅ via value property✅ as direct signal
Subscription✅ via subscribe method❌ (uses afterRecomputation())

Best Practices#

Use Passive Event Listeners#

For scroll and mouse events, use passive: true to improve performance:

typescript
const scroll$ = fromEventToSource$(window, 'scroll', {
  computedValue: () => window.scrollY,
  event: { passive: true },
});

Extract Only Needed Data#

Transform events to extract only the data you need:

typescript
// ❌ Bad - stores entire event object
const click$ = fromEventToSource$<MouseEvent>(button, 'click');

// ✅ Good - extracts only needed properties
const click$ = fromEventToSource$(button, 'click', {
  computedValue: (event: MouseEvent) => ({
    x: event.clientX,
    y: event.clientY,
  }),
});

Cleanup Dynamic Listeners#

For dynamic elements, manually dispose of listeners:

typescript
private listener$?: FromEventToSource$<Event>;

attachToElement(element: HTMLElement) {
  this.listener$?.dispose(); // Clean up previous
  this.listener$ = fromEventToSource$(element, 'click');
}

ngOnDestroy() {
  this.listener$?.dispose();
}

Combine with State Management#

Integrate with state management using on$():

typescript
const input$ = fromEventToSource$(inputElement, 'input', {
  computedValue: (e: Event) => (e.target as HTMLInputElement).value,
});

const { searchResults } = state('searchResults', [], ({ set }) => ({
  search: on$(input$, async (term) => {
    const results = await api.search(term);
    set(results);
  }),
}));

Common Patterns#

Debounced Input#

craft-ts does not depend on RxJS; this example uses it as your own optional dependency, through the structural Subscribable contract.

typescript
import { debounceTime } from 'rxjs/operators';

const input$ = fromEventToSource$(inputElement, 'input', {
  computedValue: (e: Event) => (e.target as HTMLInputElement).value,
});

// Use with rxjs operators if needed
from(input$).pipe(
  debounceTime(300),
  subscribe((value) => console.log(value)),
);

Multiple Event Handlers#

typescript
const buttonClick$ = fromEventToSource$(button, 'click');
const buttonHover$ = fromEventToSource$(button, 'mouseenter');

buttonClick$.subscribe(() => console.log('Clicked'));
buttonHover$.subscribe(() => console.log('Hovered'));

Conditional Event Processing#

typescript
const keydown$ = fromEventToSource$(document, 'keydown', {
  computedValue: (event: KeyboardEvent) => event.key,
});

keydown$.subscribe((key) => {
  if (key === 'Escape') {
    this.closeModal();
  } else if (key === 'Enter') {
    this.submit();
  }
});

Notes#

  • Must be called within an injection context

  • Event listeners are automatically removed on component destruction

  • Returns a readonly source - no emit method is exposed

  • The value signal is undefined until the first event is emitted

  • Use dispose() for manual cleanup when needed

See Also#

  • source$ - Event emitter with signal tracking

  • sourceFromEvent - Writable source from events

  • on$ - Subscribe to sources in state management

  • state - State primitive with source integration

←on$sourceFromEvent→

On this page

  • Overview
  • Import
  • Signature
  • Parameters
  • Returns
  • Types
  • FromEventToSource$
  • ReadonlySource$
  • Key Features
  • Source services and dependency tracking
  • Automatic Cleanup
  • Signal Integration
  • Event Transformation
  • Integration with State
  • Examples
  • Basic Click Counter
  • Input Value Tracking
  • Window Scroll Tracking
  • Window Resize Handling
  • Keyboard Shortcuts
  • Dynamic Element Listening
  • Mouse Position Tracker
  • Form Submission
  • Comparison with sourceFromEvent
  • Best Practices
  • Use Passive Event Listeners
  • Extract Only Needed Data
  • Cleanup Dynamic Listeners
  • Combine with State Management
  • Common Patterns
  • Debounced Input
  • Multiple Event Handlers
  • Conditional Event Processing
  • Notes
  • See Also

@craft-ts · MIT licensed

GitHubnpm

Search