state()
Creates a reactive state. Writing a new value to its .value updates every place it's used in JSX and runs its effects.
function state<T>(initialValue: T): State<T>;Reading and writing
import { state } from "qwrk";
const count = state(0);
count.value; // 0
count.value = 10; // updates the DOM bound to count
count.value++; // works tooWriting the value a state already holds, compared with Object.is, does nothing. That includes todos.value[0].done = true when it already is, and todos.value = todos.value. To signal a change made inside a Map, Set or class instance, assign a new one.
Each write updates the DOM and runs effects before the next line runs. To update once after several writes, group them with batch().
Arrays and objects
Arrays and plain objects also notify when you change them in place, at any depth:
const todos = state([{ text: "Write docs", done: false }]);
todos.value.push({ text: "Ship it", done: false });
todos.value[0].done = true;
todos.value.splice(1, 1);
todos.value = []; // assigning still works tooEach mutator call (push, sort...) notifies once, so push() updates the DOM once. Writing the value a key already has, or deleting a key that isn't there, doesn't notify. Since the array is the same object before and after, .effect() receives the same value as value and oldValue.
To do this, .value returns a Proxy of the array or object. It behaves like the original, and todos.value === todos.value holds. Only arrays and plain objects are wrapped: changes inside a Map, Set, Date or class instance don't notify, so assign a new one.
Items you read are proxies too, so they aren't === to the object you stored:
const item = { text: "Write docs", done: false };
todos.value = [item];
todos.value[0] === item; // false: a proxy of item
todos.value.includes(item); // true
todos.value.indexOf(item); // 0includes, indexOf and lastIndexOf find either one, and the same object always gives the same proxy. In find and filter, compare by id: todos.value.find((todo) => todo.id === id).
A state stored inside an array or object stays a state and isn't wrapped. Writing it updates only its own bindings, not everything bound to the outer state:
const rows = state([{ label: state("a") }]);
rows.value[0].label.value = "b"; // updates the label, not the whole listUsing it in JSX
Pass the state itself to keep the DOM in sync. With the compiler, any expression that reads .value stays in sync too. Without it, reading .value in JSX takes a one-time snapshot.
export default function App() {
const count = state(0);
return (
<>
<h1>Count: {count}</h1>
<p>Doubled: {count.value * 2}</p>
<button onClick={() => count.value++}>Increment</button>
</>
);
}States work as attributes too: <button disabled={isSaving}>. See Components & JSX.
Lists
.map(fn) renders an array state as a list that updates in place:
const todos = state([{ text: "Write docs" }, { text: "Ship it" }]);
<ul>
{todos.map((todo) => (
<li>{todo.text}</li>
))}
</ul>;
todos.value.push({ text: "Celebrate" }); // adds one <li>Rows are keyed by the items themselves, compared with ===: numbers and strings by value, objects by identity, so an object and its proxy count as the same item.
fnruns once per new item. An item that stays keeps its row and its DOM nodes, so an input keeps its value and focus while other rows move.- A change only adds, removes and moves the rows that changed: a
pushinserts one row, swapping two items moves two rows, aspliceremoves one. - Replacing an item with a new object, even an equal one, rebuilds its row. Change the object in place instead, or keep the same objects when you build a new array:
todos.value = todos.value.filter((todo) => !todo.done). - The same item twice renders two rows.
fnreceives the item raw, as stored, not as a proxy. Writing a captured item,todo.done = true, notifies nothing: write through the state instead, as intodos.value[i].done = true. There is no index argument, since the index changes whenever rows move.- A captured item subscribes to nothing, so reading
todo.textin a derive doesn't update it. Read fields that don't change once infn, and keep changing ones in a state inside the item. See Lists. - Removing a row stops the derives and effects its
fncreated. A list created while a derive runs stops when the derive runs again. nullandundefinedrender nothing.
.map() returns a DocumentFragment holding the rows between two empty text nodes that mark the list's place. It works anywhere a node does: as a JSX child, in root.append(...), or as the value of a derive.
A derive that maps the array, derive(() => todos.value.map(...)), still works, but rebuilds every row on each change. See Lists.
Keyed selection
.is(key) compares with Object.is and, inside a tracked context, subscribes only to key instead of the whole state. Changing selection re-runs only the two affected rows:
<tr class={selected.value === row.id ? "danger" : ""}>The compiler rewrites this to selected.is(row.id). States that never call .is() pay nothing.
Without the compiler, call it yourself in a derive: derive(() => (selected.is(id) ? "danger" : "")).
Subscribing to changes
.effect(fn) runs fn after every change, after the DOM is updated, with the new and previous values:
const count = state(1);
count.effect((value, oldValue) => {
console.log(`${oldValue} -> ${value}`);
});
count.value = 2; // logs "1 -> 2"It returns a function that stops it. Until then, the effect keeps the state alive:
const stop = count.effect((value) => console.log(value));
stop();
count.value = 3; // logs nothingInside a batch() it runs once, with the value from before the batch as oldValue. A .effect() created while a derive or an effect runs stops when that one runs again. Derives created in fn keep updating after fn runs again.
To run code once after mount as well as on changes, use effect().
peek()
peek(state) returns the current value without subscribing, so a derive or an effect that calls it doesn't re-run when that state changes:
import { state, derive, peek } from "qwrk";
const count = state(1);
const step = state(10);
const next = derive(() => count.value + peek(step));
step.value = 20; // next stays 11
count.value = 2; // next recomputes: 22A derive passed to peek is brought up to date first. The value comes back raw: arrays and objects aren't wrapped, so changing them in place doesn't notify.
TypeScript
state infers its type from the initial value, or you can set it explicitly. .map() only type-checks on states of arrays, which may also be null or undefined. The State<T> type is exported:
import { state, type State } from "qwrk";
const name = state<string | null>(null);
function greet(user: State<string | null>) {
return user.value ?? "stranger";
}