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, onceCore
The reactive primitives. Everything else — including the hook layer — is built on these.
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.
| Parameter | Type | Description |
|---|---|---|
value | T | Initial value. Omit for `T | undefined`. |
options.equals | false | ((prev: T, next: T) => boolean) | How to decide a write is a change. Defaults to `Object.is`. |
| returns | readonly [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 myFunctionA 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.
| Parameter | Type | Description |
|---|---|---|
fn | () => T | Computation. Signals it reads become its dependencies. |
options | SignalOptions<T> | Same equality options as `signal`. |
| returns | Accessor<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 consumerRuns 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.
| Parameter | Type | Description |
|---|---|---|
fn | () => void | Cleanup | Effect body. Return a function to register cleanup. |
| returns | Cleanup | — |
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
});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.
| Parameter | Type | Description |
|---|---|---|
fn | () => T | Function performing the writes. Its return value is passed through. |
| returns | T | — |
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"
});Reads signals without subscribing to them. Ownership is preserved — anything created inside is still disposed with the surrounding scope — only dependency tracking is suspended.
| Parameter | Type | Description |
|---|---|---|
fn | () => T | Function whose reads should not create subscriptions. |
| returns | T | — |
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);
});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.
| Parameter | Type | Description |
|---|---|---|
cleanup | () => void | Teardown to run before the next execution and on dispose. |
| returns | void | — |
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(() => {}); // KoyoStateErrorCreates 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.
| Parameter | Type | Description |
|---|---|---|
fn | (dispose: Cleanup) => T | Runs inside the new scope. Receives the scope's dispose function. |
| returns | T | — |
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.
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.
| Parameter | Type | Description |
|---|---|---|
render | () => void | Render function. May call the use* hooks. |
| returns | ComponentHandle | — |
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
});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()`.
| Parameter | Type | Description |
|---|---|---|
initial | T | (() => 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);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()`.
| Parameter | Type | Description |
|---|---|---|
callback | () => void | Cleanup | Effect body. Return a function to register cleanup. |
deps | readonly unknown[] | `[]` runs once; `[a, b]` re-runs when a or b changes; omitted runs every render. |
| returns | void | — |
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
});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()`.
| Parameter | Type | Description |
|---|---|---|
factory | () => T | Computation to cache. Signals it reads are tracked as well. |
deps | readonly unknown[] | Recompute when these change. |
| returns | T | — |
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);
});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()`.
| Parameter | Type | Description |
|---|---|---|
initial | T | Initial value of `.current`. |
| returns | Ref<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);
});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()`.
| Parameter | Type | Description |
|---|---|---|
reducer | (state: S, action: A) => S | Pure transition function. |
initial | S | Initial 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" });
});