Skip to content

Event Handling

You’ve seen onclick in props. That works for simple cases. For more complex event handling—multiple listeners, automatic cleanup, batched updates—use on().


For simple click handlers, props work fine:

const count = signal(0);
el('button').props({
onclick: () => count(count() + 1)
})('Click me')

This attaches the handler directly to the element. Simple and direct.


on() provides automatic cleanup and batching. When multiple signals update in a handler, batching ensures only one re-render.

Add the on module:

import { compose } from '@rimitive/core';
import { SignalModule, ComputedModule, EffectModule, BatchModule } from '@rimitive/signals/extend';
import { createDOMAdapter } from '@rimitive/view/adapters/dom';
import { createElModule } from '@rimitive/view/el';
import { OnModule } from '@rimitive/view/deps/addEventListener';
import { MountModule } from '@rimitive/view/deps/mount';
const adapter = createDOMAdapter();
const svc = compose(
SignalModule,
ComputedModule,
EffectModule,
BatchModule,
createElModule(adapter),
OnModule,
MountModule
);
const { el, on, signal, mount } = svc;

on() returns a lifecycle callback for use with .ref():

const count = signal(0);
const button = el('button').ref(
on('click', () => count(count() + 1))
)('Click me');

When the element is removed from the DOM, the listener is automatically cleaned up.

Stack multiple on() calls:

const input = el('input')
.ref(
on('focus', () => console.log('focused')),
on('blur', () => console.log('blurred')),
on('input', (e) => handleInput(e))
)();

Each listener is independently attached and cleaned up.


A common pattern—controlled inputs with on():

const text = signal('');
const input = el('input')
.props({
type: 'text',
value: text,
})
.ref(
on('input', (e) => text((e.target as HTMLInputElement).value))
)();

The value prop binds the input’s value to the signal. The on('input') handler updates the signal when the user types.

Handle Enter key and form submission:

const searchQuery = signal('');
const handleSubmit = () => {
console.log('Searching for:', searchQuery());
// perform search...
};
const searchInput = el('input')
.props({
type: 'text',
placeholder: 'Search...',
value: searchQuery,
})
.ref(
on('input', (e) => searchQuery((e.target as HTMLInputElement).value)),
on('keydown', (e) => {
if (e.key === 'Enter') handleSubmit();
})
)();
const searchButton = el('button').ref(
on('click', handleSubmit)
)('Search');

When a handler updates multiple signals, on() batches them into a single update:

const firstName = signal('');
const lastName = signal('');
const loading = signal(false);
const handleSubmit = () => {
// All three updates batched into one render
firstName('');
lastName('');
loading(true);
};
el('button').ref(
on('click', handleSubmit)
)('Submit')

Without batching, this would trigger three separate re-renders. With on(), it’s one.

Consider a form with derived state:

const firstName = signal('John');
const lastName = signal('Doe');
const fullName = computed(() => `${firstName()} ${lastName()}`);
// Display updates once, not twice
el('button').ref(
on('click', () => {
firstName('Jane');
lastName('Smith');
})
)('Change Name')

The fullName computed only recalculates once after both signals update.


Pass standard addEventListener options as the third argument:

// Capture phase
on('click', handler, { capture: true })
// Once only
on('click', handler, { once: true })
// Passive (for scroll performance)
on('scroll', handler, { passive: true })

Use props (onclick, oninput, etc.) when:

  • Simple, single handler
  • No need for cleanup management
  • Handler updates one signal

Use on() when:

  • Multiple listeners on one element
  • Handler updates multiple signals (batching)
  • Need explicit cleanup
  • Using event options (capture, passive, once)

Both are valid. Props are simpler; on() is more powerful.


A search form with debounced input:

import { compose } from '@rimitive/core';
import { SignalModule, ComputedModule, EffectModule, BatchModule } from '@rimitive/signals/extend';
import { createDOMAdapter } from '@rimitive/view/adapters/dom';
import { createElModule } from '@rimitive/view/el';
import { createMatchModule } from '@rimitive/view/match';
import { OnModule } from '@rimitive/view/deps/addEventListener';
import { MountModule } from '@rimitive/view/deps/mount';
const adapter = createDOMAdapter();
const svc = compose(
SignalModule,
ComputedModule,
EffectModule,
BatchModule,
createElModule(adapter),
createMatchModule(adapter),
OnModule,
MountModule
);
const { el, match, on, signal, computed, effect, mount } = svc;
// State
const query = signal('');
const results = signal<string[]>([]);
const loading = signal(false);
// Debounced search
let debounceTimer: number;
const debouncedSearch = (q: string) => {
clearTimeout(debounceTimer);
if (!q.trim()) {
results([]);
return;
}
loading(true);
debounceTimer = setTimeout(() => {
// Simulate API call
const mockResults = ['Apple', 'Banana', 'Cherry', 'Date']
.filter(item => item.toLowerCase().includes(q.toLowerCase()));
// Batched update
results(mockResults);
loading(false);
}, 300);
};
// Watch query changes
effect(() => {
debouncedSearch(query());
});
// App
const App = () => el('div').props({ className: 'search-app' })(
el('h1')('Search'),
// Search input
el('input')
.props({
type: 'text',
placeholder: 'Type to search...',
value: query,
})
.ref(
on('input', (e) => query((e.target as HTMLInputElement).value)),
on('keydown', (e) => {
if (e.key === 'Escape') query('');
})
)(),
// Loading indicator
match(loading, (isLoading) =>
isLoading ? el('div').props({ className: 'loading' })('Searching...') : null
),
// Results
el('ul')(
...[] // Would use map() here for dynamic results
),
// Result count
el('div').props({ className: 'count' })(
computed(() => {
const r = results();
return r.length === 0
? 'No results'
: `${r.length} result${r.length === 1 ? '' : 's'}`;
})
)
);
const app = mount(App());
document.body.appendChild(app.element!);

Event handling, batching, cleanup—all handled. Next: Loading Data for async data fetching.