Documentation / State

State

Signals, SolidJS-style — with an optional React-style hook layer over the same graph. Import from the root or from koyojs/State.

// Root import
import { signal, memo, effect, batch } from "koyojs";

// Per-module import
import { signal, effect, createRoot } from "koyojs/State";
import { component, useState, useEffect } from "koyojs/State";

How the graph settles

Memos are flushed before effects, so a diamond dependency settles before any side effect observes it — an effect never sees one branch updated and the other stale.

An effect that writes a signal it also reads throws after 1000 flush passes (Maximum update depth exceeded) rather than hanging the tab. The queues are cleared first, so unrelated graphs still work afterwards.

import { signal, memo, effect } from "koyojs/State";

const [n, setN] = signal(1);
const double = memo(() => n() * 2);
const triple = memo(() => n() * 3);

effect(() => {
  // Always consistent — never 2 and 3 from different values of n
  console.log(double(), triple());
});

setN(2);   // logs 4 6, once

Core

The reactive primitives. Everything else — including the hook layer — is built on these.

signalsignal<T>(value?: T, options?: SignalOptions<T>): Signal<T>

A reactive value as a `[read, write]` pair. Reading inside an `effect`, `memo`, or `component` subscribes that scope to the signal; reading anywhere else is a plain value read with no bookkeeping. Writes notify only when the value actually changes, compared with `Object.is` — pass `equals: false` to notify on every write, which is what you want for objects mutated in place.

ParameterTypeDescription
valueTInitial value. Omit for `T | undefined`.
options.equalsfalse | ((prev: T, next: T) => boolean)How to decide a write is a change. Defaults to `Object.is`.
returnsreadonly [Accessor<T>, Setter<T>]
import { signal, effect } from "koyojs/State";

const [count, setCount] = signal(0);

effect(() => console.log(count()));  // logs 0, then again on every change

setCount(1);              // logs 1
setCount((n) => n + 1);   // logs 2 — updater form
setCount(2);              // silent: Object.is(2, 2) — not a change

// Objects mutated in place need equals: false
const [items, setItems] = signal<string[]>([], { equals: false });
effect(() => render(items()));
setItems((list) => {
  list.push("new");       // same array reference
  return list;            // still notifies
});

// To store a function AS the value, use the updater form —
// a bare function argument is treated as an updater
const [handler, setHandler] = signal<() => void>(() => {});
setHandler(() => myFunction);   // stores myFunction

memomemo<T>(fn: () => T, options?: SignalOptions<T>): Accessor<T>

A derived value that recomputes only when a signal it reads changes, and only notifies downstream scopes when the result actually differs. Use it to put an expensive computation, or a noisy signal, behind a stable read — a plain arrow function would re-run for every consumer, every time.

ParameterTypeDescription
fn() => TComputation. Signals it reads become its dependencies.
optionsSignalOptions<T>Same equality options as `signal`.
returnsAccessor<T>
import { signal, memo, effect } from "koyojs/State";

const [items, setItems] = signal<string[]>([]);
const count = memo(() => items().length);

effect(() => console.log(count()));

// Swapping in a different list of the same length does not log again —
// the memo recomputed, but the result was equal
setItems(["a", "b"]);   // logs 2
setItems(["c", "d"]);   // silent

// Expensive work stays behind one stable read
const sorted = memo(() => [...rows()].sort(byName));
effect(() => renderTable(sorted()));
effect(() => updateCount(sorted().length));
// sorted() computed once per change, not once per consumer

effecteffect(fn: EffectFunction): Cleanup

Runs a side effect now, and again whenever a signal it read has changed. Dependencies are tracked automatically — there is no dependency array, and a dependency dropped between runs stops triggering it. Returning a function registers cleanup, run before the next execution and on dispose. Returns a disposer; when created inside another reactive scope, the effect is also disposed with its parent.

ParameterTypeDescription
fn() => void | CleanupEffect body. Return a function to register cleanup.
returnsCleanup
import { signal, effect } from "koyojs/State";

const [id, setId] = signal(1);

const dispose = effect(() => {
  const controller = new AbortController();
  fetch(`/user/${id()}`, { signal: controller.signal });
  return () => controller.abort();  // before the next run, and on dispose
});

setId(2);    // aborts the first request, starts the second
dispose();   // aborts the second, stops the effect

// Dependencies are tracked per run, not declared once
const [showDetail, setShowDetail] = signal(false);
const [detail, setDetail] = signal("");

effect(() => {
  if (!showDetail()) return;   // detail() never read on this pass
  console.log(detail());       // so writing detail() does not re-run it
});

batchbatch<T>(fn: () => T): T

Applies several writes as one update — effects run once, after `fn` returns, instead of once per write. Reads inside the batch still see values written earlier in the same batch. Nesting is safe; only the outermost batch flushes.

ParameterTypeDescription
fn() => TFunction performing the writes. Its return value is passed through.
returnsT
import { signal, effect, batch } from "koyojs/State";

const [first, setFirst] = signal("Ada");
const [last, setLast]   = signal("Byron");

effect(() => console.log(`${first()} ${last()}`));

// Without batch: two writes, two runs — including "Ada Lovelace"
setFirst("Augusta");
setLast("Lovelace");

// With batch: one run, no intermediate state observed
batch(() => {
  setFirst("Augusta");
  setLast("Lovelace");
});

// Reads inside the batch see earlier writes from the same batch
batch(() => {
  setFirst("Grace");
  console.log(first());   // "Grace"
});

untrackuntrack<T>(fn: () => T): T

Reads signals without subscribing to them. Ownership is preserved — anything created inside is still disposed with the surrounding scope — only dependency tracking is suspended.

ParameterTypeDescription
fn() => TFunction whose reads should not create subscriptions.
returnsT
import { signal, effect, untrack } from "koyojs/State";

const [message, setMessage] = signal("");
const [sessionId] = signal("abc123");

effect(() => {
  // Re-runs when message changes, never when sessionId does
  send(message(), untrack(() => sessionId()));
});

// Common use: read current state to decide, without subscribing to it
effect(() => {
  const next = queue();
  if (untrack(() => isPaused())) return;
  process(next);
});

onCleanuponCleanup(cleanup: Cleanup): void

Registers teardown for the current reactive scope. Runs before the scope's next execution and once more when it is disposed. Throws `KoyoStateError` when called outside `effect`, `memo`, `component`, or `createRoot` — silently dropping the callback would leak whatever it releases.

ParameterTypeDescription
cleanup() => voidTeardown to run before the next execution and on dispose.
returnsvoid
import { signal, effect, onCleanup } from "koyojs/State";

const [delay, setDelay] = signal(1000);

effect(() => {
  const timer = setInterval(tick, delay());
  onCleanup(() => clearInterval(timer));
});

setDelay(500);   // clears the old interval, starts a new one

// Several cleanups in one scope all run, in registration order
effect(() => {
  const socket = connect();
  onCleanup(() => socket.close());

  const handler = () => socket.send("ping");
  window.addEventListener("focus", handler);
  onCleanup(() => window.removeEventListener("focus", handler));
});

// Outside a reactive scope this throws rather than leaking
onCleanup(() => {});   // KoyoStateError

createRootcreateRoot<T>(fn: (dispose: Cleanup) => T): T

Creates an owner scope with no parent and hands its dispose function to `fn`. Everything created inside — effects, memos, cleanups — is torn down when that dispose is called. This is the boundary between reactive code and the rest of your program: an effect created at top level with no root has nothing to dispose it.

ParameterTypeDescription
fn(dispose: Cleanup) => TRuns inside the new scope. Receives the scope's dispose function.
returnsT
import { signal, effect, createRoot } from "koyojs/State";

const [count, setCount] = signal(0);

const stop = createRoot((dispose) => {
  effect(() => render(count()));
  effect(() => syncToStorage(count()));
  return dispose;
});

setCount(1);   // both effects run
stop();        // both effects stop, all cleanups run

// Scoping a whole feature so it can be unmounted as a unit
function mountWidget(el: Element) {
  return createRoot((dispose) => {
    effect(() => el.textContent = String(count()));
    return dispose;
  });
}

const unmount = mountWidget(node);
unmount();

Hooks

An optional React-style layer over the same graph. Component bodies read like React while the underlying reactivity is still signals. Every hook below is only valid inside component() — calling one outside throws a KoyoStateError.

componentcomponent(render: () => void): ComponentHandle

Runs a render function in a reactive scope that provides hook slots, re-running it whenever a signal — or a `useState` value — it read has changed. Hook order is checked between renders: breaking the rules of hooks throws a `KoyoStateError` naming the slot and both hook kinds, instead of quietly handing back another hook's state. Returns a handle whose `dispose` stops re-rendering and runs every pending cleanup.

ParameterTypeDescription
render() => voidRender function. May call the use* hooks.
returnsComponentHandle
import { component, useState, useEffect } from "koyojs/State";
import { $ } from "koyojs/DOM";

const app = component(() => {
  const [count, setCount] = useState(0);

  useEffect(() => {
    document.title = `${count} clicks`;
  }, [count]);

  $("#count").text(String(count));
  $("#inc").off("click").on("click", () => setCount((n) => n + 1));
});

app.dispose();   // stops re-rendering, runs cleanups

// Breaking the rules of hooks throws instead of corrupting state
component(() => {
  if (someCondition) useState(0);   // conditional hook
  useRef(null);                     // → KoyoStateError naming the slot
});

useStateuseState<T>(initial: T | (() => T)): [T, Setter<T>]

Component-scoped state, React style. Returns the plain current value rather than an accessor — the re-render is what refreshes it — and a setter that re-runs the component. A function passed as `initial` is a lazy initialiser, computed once. Only valid inside `component()`.

ParameterTypeDescription
initialT | (() => T)Initial value, or a lazy initialiser computed once.
returns[T, Setter<T>]
import { component, useState } from "koyojs/State";

component(() => {
  const [count, setCount] = useState(0);
  const [user]  = useState(() => loadUser());  // lazy — runs once

  setCount((n) => n + 1);   // updater form
  setCount(5);              // direct

  console.log(count);       // a plain number, not count()
});

// To store a function as the value, use the updater form —
// a bare function is treated as an updater, and a bare
// function `initial` as a lazy initialiser
const [fn, setFn] = useState<() => void>(() => noop);
setFn(() => myHandler);

useEffectuseEffect(callback: EffectCallback, deps?: DependencyList): void

Runs a side effect after the component renders, re-running it when `deps` change. Returning a function registers cleanup, run before the next execution and on dispose. The callback runs untracked, so signals it reads do not become dependencies of the component — unlike the auto-tracking `effect()`, this hook honours the dependency array and nothing else. Only valid inside `component()`.

ParameterTypeDescription
callback() => void | CleanupEffect body. Return a function to register cleanup.
depsreadonly unknown[]`[]` runs once; `[a, b]` re-runs when a or b changes; omitted runs every render.
returnsvoid
import { component, useState, useEffect } from "koyojs/State";

component(() => {
  const [query, setQuery] = useState("");

  useEffect(() => {
    const id = setInterval(tick, 1000);
    return () => clearInterval(id);
  }, []);                        // once, on mount

  useEffect(() => {
    search(query);
  }, [query]);                   // when query changes

  useEffect(() => {
    log("rendered");
  });                            // every render
});

useMemouseMemo<T>(factory: () => T, deps?: DependencyList): T

Caches an expensive result across renders, recomputing only when `deps` change. Signals read by the factory are tracked on top of the dependency array: changing one recomputes the value and re-renders, even when `deps` is `[]`. A stale dependency array can therefore never freeze a signal-derived value — the usual way `useMemo` goes wrong in React. Only valid inside `component()`.

ParameterTypeDescription
factory() => TComputation to cache. Signals it reads are tracked as well.
depsreadonly unknown[]Recompute when these change.
returnsT
import { component, useState, useMemo } from "koyojs/State";
import { signal } from "koyojs/State";

const [theme] = signal("light");

component(() => {
  const [rows] = useState(() => loadRows());

  const sorted = useMemo(() => [...rows].sort(byName), [rows]);

  // Reads a signal with an empty dep array — still stays correct,
  // because signal reads are tracked on top of deps
  const styled = useMemo(() => applyTheme(sorted, theme()), []);

  render(styled);
});

useRefuseRef<T>(initial: T): Ref<T>

A mutable box that survives re-renders. Writing to `.current` does not re-render — that is the point. Use it for timer ids, DOM nodes, and previous values; use `useState` for anything the render output depends on. Only valid inside `component()`.

ParameterTypeDescription
initialTInitial value of `.current`.
returnsRef<T>
import { component, useRef, useState } from "koyojs/State";

component(() => {
  const renders = useRef(0);
  renders.current++;            // no re-render

  const node = useRef<Element | null>(null);
  const timer = useRef<number | undefined>(undefined);

  const [value, setValue] = useState("");

  // Debounce without re-rendering on every keystroke
  clearTimeout(timer.current);
  timer.current = setTimeout(() => search(value), 300);
});

useReduceruseReducer<S, A>(reducer: Reducer<S, A>, initial: S): [S, Dispatch<A>]

State transitions as a reducer, for updates that are easier to name than to inline. `dispatch` keeps the same identity for the component's lifetime but always calls the reducer from the latest render, so it never closes over stale values. Only valid inside `component()`.

ParameterTypeDescription
reducer(state: S, action: A) => SPure transition function.
initialSInitial state.
returns[S, Dispatch<A>]
import { component, useReducer } from "koyojs/State";

component(() => {
  const [count, dispatch] = useReducer(
    (n: number, action: "inc" | "dec" | "reset") =>
      action === "inc" ? n + 1 : action === "dec" ? n - 1 : 0,
    0,
  );

  dispatch("inc");
  dispatch("reset");
});

// Named transitions keep complex state readable
type Action =
  | { type: "add"; item: string }
  | { type: "remove"; index: number }
  | { type: "clear" };

component(() => {
  const [items, dispatch] = useReducer(
    (state: string[], action: Action) => {
      switch (action.type) {
        case "add":    return [...state, action.item];
        case "remove": return state.filter((_, i) => i !== action.index);
        case "clear":  return [];
      }
    },
    [],
  );

  dispatch({ type: "add", item: "first" });
});