Skip to content

Signals

Problem

You want to understand the core Stateit primitive: signal(). This is the starting point for the reactivity model before computed values, effects, or stores.

Solution

Counter with computed and effect

A self-contained reactive counter — no framework required:

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

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

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

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

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

Async Loading State with Signals

Manage loading, data, and error state reactively:

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

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/stateit';

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/stateit';

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
}

Pitfalls

  • Signal updates are reference-based. Mutating an object in place (for example, pushing into an array) does not notify subscribers — assign a new value or use update() to produce a new reference.
  • 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.