Skip to content

Signals

Problem

You need to create reactive values that automatically notify dependents when they change, derive computed values from them, and run side effects — without a framework.

Solution

Use signal() for reactive atoms, computed() for derived values, and effect() for side effects.

Counter with computed and effect

A self-contained reactive counter — no framework required:

ts
import { signal, computed, effect, watch } from '@vielzeug/ripple';

const count = signal(0);
const doubled = computed(() => count.value * 2);
const isEven = computed(() => count.value % 2 === 0);

// effect runs immediately and re-runs on any dependency change
const sub = effect(() => {
  console.log(`count=${count.value}, doubled=${doubled.value}, even=${isEven.value}`);
});

count.value++; // → count=1, doubled=2, even=false
count.value++; // → count=2, doubled=4, even=true

sub.dispose();
doubled.dispose();
isEven.dispose();

Updating Signal Values

Write .value directly. For read-modify-write patterns, read .value on the right-hand side:

ts
import { signal } from '@vielzeug/ripple';

const count = signal(0);
count.value = count.value + 1; // 1
count.value = count.value * 2; // 2

const tags = signal(['ts', 'js']);
tags.value = [...tags.value, 'tsx']; // ['ts', 'js', 'tsx']

Async Loading State with Signals

Manage loading, data, and error state reactively:

ts
import { signal, computed, batch } from '@vielzeug/ripple';

const loading = signal(false);
const data = signal<string[] | null>(null);
const error = signal<Error | null>(null);

const status = computed(() => {
  if (loading.value) return 'loading' as const;
  if (error.value) return 'error' as const;
  if (data.value) return 'success' as const;
  return 'idle' as const;
});

async function fetchItems() {
  batch(() => {
    loading.value = true;
    error.value = null;
  });
  try {
    const res = await fetch('/api/items');
    data.value = await res.json();
  } catch (e) {
    error.value = e as Error;
  } finally {
    loading.value = false;
  }
}

One-Time Watch with Explicit Stop

Subscribe to the first change only, then auto-unsubscribe:

ts
import { signal, watch } from '@vielzeug/ripple';

const authToken = signal<string | null>(null);

const stop = watch(authToken, (token) => {
  console.log('First login:', token);
  stop();
});

using Declarations — Automatic Disposal

With the TC39 explicit resource management proposal (using), disposables are cleaned up automatically when their block exits:

ts
import { signal, effect, computed } from '@vielzeug/ripple';

const count = signal(0);

{
  using sub = effect(() => console.log('count:', count.value));
  using doubled = computed(() => count.value * 2);

  count.value = 5; // both reactive
  // ← block exits; sub and doubled are automatically disposed
}

Signal Combinators — derive and filter

Use the standalone derive() and filter() utilities to create computed signals from a source:

ts
import { signal, derive, filter, watch } from '@vielzeug/ripple';

// derive() — project to a derived type
const count = signal(3);
const doubled = derive(count, (n) => n * 2); // ComputedSignal<number>
console.log(doubled.value); // 6

count.value = 5;
console.log(doubled.value); // 10

doubled.dispose();

// filter() — pass values matching a predicate, undefined otherwise
const even = filter(count, (n) => n % 2 === 0);
console.log(even.value); // undefined (5 is odd)

count.value = 8;
console.log(even.value); // 8
even.dispose();

// Type-guard filter — narrow to a subtype
const maybeStr = signal<string | null>(null);
const str = filter(maybeStr, (v): v is string => v !== null);
maybeStr.value = 'hello';
console.log(str.value); // 'hello'
str.dispose();

Pitfalls

  • Signal updates are reference-based. Mutating an object in place (for example, pushing into an array) does not notify subscribers — always assign a new value to trigger notifications.
  • effect() runs immediately on creation. If it has side effects (DOM mutations, network calls), it fires before the component is fully initialized. Use a mounted flag to defer.
  • Creating a computed() inside a component render function without memoization creates a new computed instance on every render, leaking watchers. Create computeds at module scope or in the component setup phase.