Signal Patterns
Try It
Section titled “Try It”Signals are simple—read with sig(), write with sig(value). But there are patterns that make them more ergonomic for common use cases.
Working with Arrays and Objects
Section titled “Working with Arrays and Objects”When updating arrays and objects, read the current value and write the new value:
const items = signal<Item[]>([]);
// Appenditems([...items(), newItem]);
// Remove by iditems(items().filter(x => x.id !== id));
// Update one itemitems(items().map(x => x.id === id ? { ...x, done: true } : x));
// Toggle a propertyitems(items().map(x => x.id === id ? { ...x, done: !x.done } : x));Derived Actions
Section titled “Derived Actions”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 neededconst count = svc(counter)(0);
count(); // read: 0count(5); // write: 5count.increment(); // action: 6count.reset(); // action: 0Toggle
Section titled “Toggle”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()), });};
// Usageconst isOpen = svc(toggle)(false);
isOpen(); // falseisOpen.toggle(); // trueisOpen.off(); // false
// Still works as a normal signalisOpen(true); // truePrevious Value
Section titled “Previous Value”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 };};
// Usageconst page = svc(withPrevious)(1);
page.set(2);page.current(); // 2page.previous(); // 1page.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 };};Debounced Signals
Section titled “Debounced Signals”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;};
// Usageconst searchInput = signal('');const debouncedSearch = svc(debounced)(searchInput, 300);
// searchInput updates immediately on keystroke// debouncedSearch updates 300ms after typing stopsA 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 });};Async Actions
Section titled “Async Actions”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 };};
// Usageconst 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 componentel('button') .props({ onclick: () => saveUser.execute({ name: 'Alice' }), disabled: saveUser.pending, })( computed(() => saveUser.pending() ? 'Saving...' : 'Save') );
// Show errorsmatch(saveUser.error, (err) => err ? el('div').props({ className: 'error' })(err.message) : null);Computed Collections
Section titled “Computed Collections”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 changeconst ActiveList = (svc: Service) => (todos: ReturnType<typeof todoList>) => { const { el, map } = svc; return map(todos.active, t => t.id, TodoItem);};
// Only re-renders when counts changeconst Stats = (svc: Service) => (todos: ReturnType<typeof todoList>) => { const { el, computed } = svc; return el('div')( computed(() => `${todos.counts().active} items left`) );};Element Partial Application
Section titled “Element Partial Application”Pre-bind commonly used element tags:
const svc = compose(SignalModule, createElModule(adapter));const { el } = svc;
// Partial application - call el() once per tagconst div = el('div');const button = el('button');const input = el('input');const span = el('span');
// Use without repeating tag namesconst 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.
Service Factory
Section titled “Service Factory”For applications that need multiple isolated instances:
// Define service creation as a factoryconst createService = () => { const adapter = createDOMAdapter(); return compose( SignalModule, ComputedModule, EffectModule, createElModule(adapter), createMapModule(adapter), createMatchModule(adapter), );};
// Each call creates a fresh service with its own stateconst app1 = createService();const app2 = createService();
// Mount separate instancesmount(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
Anti-patterns
Section titled “Anti-patterns”Don’t Create Module-Level Signals
Section titled “Don’t Create Module-Level Signals”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 stateconst 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 instanceconst Counter = () => { const count = signal(0); // Fresh state each time
return el('div')( el('span')(computed(() => `Count: ${count()}`)), el('button').props({ onclick: () => count(count() + 1) })('+') );};Don’t Mutate Signal Values Directly
Section titled “Don’t Mutate Signal Values Directly”Signals hold values by reference. Mutating the value directly bypasses reactivity:
// ❌ WRONG - mutation doesn't trigger updatesconst items = signal<string[]>([]);
items().push('new item'); // Mutates the array, but signal doesn't knowitems(); // Still returns the same reference - no reactive update!// ✅ CORRECT - create new values with explicit read/writeconst items = signal<string[]>([]);
items([...items(), 'new item']); // New array, signal updatesThe same applies to objects:
// ❌ WRONGconst user = signal({ name: 'Alice', age: 30 });user().age = 31; // Mutation - no reactive update
// ✅ CORRECTuser({ ...user(), age: 31 }); // New object - reactive updateDon’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 updateconst name = signal('Alice');const greeting = `Hello, ${name()}!`; // Read once, never updates
el('div')(greeting); // Always shows "Hello, Alice!"// ✅ CORRECT - wrap in computed for reactive updatesconst name = signal('Alice');const greeting = computed(() => `Hello, ${name()}!`);
el('div')(greeting); // Updates when name changesDon’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 dependencyconst a = computed(() => b() + 1);const b = computed(() => a() + 1); // Boom! Infinite loopIf you need derived values that reference each other, use signals with explicit updates:
// ✅ CORRECT - use signals with controlled updatesconst a = signal(0);const b = signal(0);
const updateA = (value: number) => { a(value); b(value + 1); // Explicitly update b when a changes};Don’t Return Elements from Computeds
Section titled “Don’t Return Elements from Computeds”Computeds are for deriving primitive values—strings, numbers, booleans, objects. They can’t return element specs:
// ❌ WRONG - computed can't return elementsconst content = computed(() => { if (showDetails()) { return el('div')('Details here'); // Won't work! } return el('span')('Summary');});
return el('div')(content); // BrokenFor conditional rendering, use match instead:
// ✅ CORRECT - use match for conditional elementsreturn el('div')( match(showDetails, (show) => show ? el('div')('Details here') : el('span')('Summary') ));Computeds are for values. match is for conditional UI.