Skip to content

Signal Patterns

Loading sandbox...

Signals are simple—read with sig(), write with sig(value). But there are patterns that make them more ergonomic for common use cases.


When updating arrays and objects, read the current value and write the new value:

const items = signal<Item[]>([]);
// Append
items([...items(), newItem]);
// Remove by id
items(items().filter(x => x.id !== id));
// Update one item
items(items().map(x => x.id === id ? { ...x, done: true } : x));
// Toggle a property
items(items().map(x => x.id === id ? { ...x, done: !x.done } : x));

The standard behavior pattern returns an object with state and actions:

const counter = (svc: SignalsSvc) => (initial = 0) => {
const count = svc.signal(initial);
return {
count,
increment: () => count(count() + 1),
decrement: () => count(count() - 1),
};
};
const { count, increment, decrement } = svc(counter)(0);

An alternative: attach actions directly to the signal:

const counter = (svc: SignalsSvc) => (initial = 0) => {
const count = svc.signal(initial);
return Object.assign(count, {
increment: () => count(count() + 1),
decrement: () => count(count() - 1),
reset: () => count(initial),
});
};
// Usage - no destructuring needed
const count = svc(counter)(0);
count(); // read: 0
count(5); // write: 5
count.increment(); // action: 6
count.reset(); // action: 0

A common case of derived actions—boolean signals with on/off/toggle:

const toggle = (svc: SignalsSvc) => (initial = false) => {
const value = svc.signal(initial);
return Object.assign(value, {
on: () => value(true),
off: () => value(false),
toggle: () => value(!value()),
});
};
// Usage
const isOpen = svc(toggle)(false);
isOpen(); // false
isOpen.toggle(); // true
isOpen.off(); // false
// Still works as a normal signal
isOpen(true); // true

Track the previous value for transitions, animations, or undo:

const withPrevious = <T>(svc: SignalsSvc) => (initial: T) => {
const { signal, computed } = svc;
const current = signal(initial);
const previous = signal(initial);
const set = (value: T) => {
previous(current());
current(value);
};
const changed = computed(() => current() !== previous());
return { current, previous, set, changed };
};
// Usage
const page = svc(withPrevious)(1);
page.set(2);
page.current(); // 2
page.previous(); // 1
page.changed(); // true
page.set(2);
page.changed(); // false (same value)

For undo/redo, extend the pattern with a history stack:

const withHistory = <T>(svc: SignalsSvc) => (initial: T, maxHistory = 10) => {
const { signal, computed } = svc;
const current = signal(initial);
const past = signal<T[]>([]);
const future = signal<T[]>([]);
const canUndo = computed(() => past().length > 0);
const canRedo = computed(() => future().length > 0);
const set = (value: T) => {
past([...past().slice(-(maxHistory - 1)), current()]);
future([]);
current(value);
};
const undo = () => {
if (!canUndo()) return;
const prev = past();
const last = prev[prev.length - 1];
past(prev.slice(0, -1));
future([...future(), current()]);
current(last!);
};
const redo = () => {
if (!canRedo()) return;
const fut = future();
const next = fut[fut.length - 1];
future(fut.slice(0, -1));
past([...past(), current()]);
current(next!);
};
return { current, set, undo, redo, canUndo, canRedo };
};

Delay signal updates until input settles:

const debounced = <T>(svc: SignalsSvc) => (
source: Readable<T>,
ms: number
) => {
const { signal, effect } = svc;
const value = signal(source.peek());
let timeout: number | undefined;
effect(() => {
const v = source();
clearTimeout(timeout);
timeout = window.setTimeout(() => value(v), ms);
});
return value;
};
// Usage
const searchInput = signal('');
const debouncedSearch = svc(debounced)(searchInput, 300);
// searchInput updates immediately on keystroke
// debouncedSearch updates 300ms after typing stops

A variant that debounces both reads and writes:

const debouncedSignal = <T>(svc: SignalsSvc) => (initial: T, ms: number) => {
const { signal, effect } = svc;
const immediate = signal(initial);
const debounced = signal(initial);
let timeout: number | undefined;
effect(() => {
const v = immediate();
clearTimeout(timeout);
timeout = window.setTimeout(() => debounced(v), ms);
});
// Return a signal-like object that writes to immediate, reads from debounced
const result = ((value?: T) => {
if (arguments.length === 0) return debounced();
immediate(value!);
}) as Writable<T>;
result.peek = () => debounced.peek();
return Object.assign(result, {
immediate, // Access the non-debounced value if needed
});
};

For mutations that need loading and error state:

const asyncAction = <T, Args extends unknown[]>(svc: SignalsSvc) => (
action: (...args: Args) => Promise<T>
) => {
const { signal } = svc;
const pending = signal(false);
const error = signal<Error | null>(null);
const lastResult = signal<T | null>(null);
const execute = async (...args: Args): Promise<T> => {
pending(true);
error(null);
try {
const result = await action(...args);
lastResult(result);
return result;
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
error(err);
throw err;
} finally {
pending(false);
}
};
return { execute, pending, error, lastResult };
};
// Usage
const saveUser = svc(asyncAction)(async (user: User) => {
const res = await fetch('/api/users', {
method: 'POST',
body: JSON.stringify(user),
});
if (!res.ok) throw new Error('Failed to save');
return res.json();
});
// In a component
el('button')
.props({
onclick: () => saveUser.execute({ name: 'Alice' }),
disabled: saveUser.pending,
})(
computed(() => saveUser.pending() ? 'Saving...' : 'Save')
);
// Show errors
match(saveUser.error, (err) =>
err ? el('div').props({ className: 'error' })(err.message) : null
);

When working with reactive lists, pre-compute common derived views:

const todoList = (svc: SignalsSvc) => () => {
const { signal, computed } = svc;
const items = signal<Todo[]>([]);
// Pre-computed views - computed once, cached automatically
const active = computed(() => items().filter(t => !t.done));
const completed = computed(() => items().filter(t => t.done));
const counts = computed(() => ({
total: items().length,
active: active().length,
completed: completed().length,
}));
const allDone = computed(() =>
items().length > 0 && completed().length === items().length
);
return {
items,
active,
completed,
counts,
allDone,
// Actions...
add: (text: string) => items([...items(), { id: crypto.randomUUID(), text, done: false }]),
toggle: (id: string) => items(items().map(t => t.id === id ? { ...t, done: !t.done } : t)),
remove: (id: string) => items(items().filter(t => t.id !== id)),
clear: () => items(items().filter(t => !t.done)),
};
};

Consumers can read whichever view they need:

// Only re-renders when active items change
const ActiveList = (svc: Service) => (todos: ReturnType<typeof todoList>) => {
const { el, map } = svc;
return map(todos.active, t => t.id, TodoItem);
};
// Only re-renders when counts change
const Stats = (svc: Service) => (todos: ReturnType<typeof todoList>) => {
const { el, computed } = svc;
return el('div')(
computed(() => `${todos.counts().active} items left`)
);
};

Pre-bind commonly used element tags:

const svc = compose(SignalModule, createElModule(adapter));
const { el } = svc;
// Partial application - call el() once per tag
const div = el('div');
const button = el('button');
const input = el('input');
const span = el('span');
// Use without repeating tag names
const Form = () =>
div.props({ className: 'form' })(
div.props({ className: 'field' })(
input.props({ type: 'text', placeholder: 'Name' })(),
),
button.props({ type: 'submit' })('Submit')
);

This is especially useful in files with many elements of the same type.


For applications that need multiple isolated instances:

// Define service creation as a factory
const createService = () => {
const adapter = createDOMAdapter();
return compose(
SignalModule, ComputedModule, EffectModule,
createElModule(adapter),
createMapModule(adapter),
createMatchModule(adapter),
);
};
// Each call creates a fresh service with its own state
const app1 = createService();
const app2 = createService();
// Mount separate instances
mount(App(app1), document.getElementById('app1')!);
mount(App(app2), document.getElementById('app2')!);

This is useful for:

  • Embedding multiple independent widgets on a page
  • Testing with isolated services
  • SSR where each request needs fresh state

Signals created at module scope are shared across your entire application. This causes state to leak between components and makes testing difficult:

// ❌ WRONG - module-level signal is global shared state
const count = signal(0); // Created once when module loads
const Counter = () => {
return el('div')(
el('span')(computed(() => `Count: ${count()}`)),
el('button').props({ onclick: () => count(count() + 1) })('+')
);
};
// Every Counter instance shares the same count!
// ✅ CORRECT - state created per instance
const Counter = () => {
const count = signal(0); // Fresh state each time
return el('div')(
el('span')(computed(() => `Count: ${count()}`)),
el('button').props({ onclick: () => count(count() + 1) })('+')
);
};

Signals hold values by reference. Mutating the value directly bypasses reactivity:

// ❌ WRONG - mutation doesn't trigger updates
const items = signal<string[]>([]);
items().push('new item'); // Mutates the array, but signal doesn't know
items(); // Still returns the same reference - no reactive update!
// ✅ CORRECT - create new values with explicit read/write
const items = signal<string[]>([]);
items([...items(), 'new item']); // New array, signal updates

The same applies to objects:

// ❌ WRONG
const user = signal({ name: 'Alice', age: 30 });
user().age = 31; // Mutation - no reactive update
// ✅ CORRECT
user({ ...user(), age: 31 }); // New object - reactive update

Don’t Read Signals Outside Reactive Context

Section titled “Don’t Read Signals Outside Reactive Context”

Reading a signal outside of computed or effect won’t track dependencies:

// ❌ WRONG - this value won't update
const name = signal('Alice');
const greeting = `Hello, ${name()}!`; // Read once, never updates
el('div')(greeting); // Always shows "Hello, Alice!"
// ✅ CORRECT - wrap in computed for reactive updates
const name = signal('Alice');
const greeting = computed(() => `Hello, ${name()}!`);
el('div')(greeting); // Updates when name changes

Don’t Create Circular Dependencies in Computeds

Section titled “Don’t Create Circular Dependencies in Computeds”

Computeds that depend on each other create infinite loops:

// ❌ WRONG - circular dependency
const a = computed(() => b() + 1);
const b = computed(() => a() + 1); // Boom! Infinite loop

If you need derived values that reference each other, use signals with explicit updates:

// ✅ CORRECT - use signals with controlled updates
const a = signal(0);
const b = signal(0);
const updateA = (value: number) => {
a(value);
b(value + 1); // Explicitly update b when a changes
};

Computeds are for deriving primitive values—strings, numbers, booleans, objects. They can’t return element specs:

// ❌ WRONG - computed can't return elements
const content = computed(() => {
if (showDetails()) {
return el('div')('Details here'); // Won't work!
}
return el('span')('Summary');
});
return el('div')(content); // Broken

For conditional rendering, use match instead:

// ✅ CORRECT - use match for conditional elements
return el('div')(
match(showDetails, (show) =>
show
? el('div')('Details here')
: el('span')('Summary')
)
);

Computeds are for values. match is for conditional UI.