Written to be searched rather than read. Anything not listed here is an internal detail and may change without a major version bump.
State
observable
observable(initial, {equals?}) → {value, peek(), set(v), subscribe(fn), extend(spec)}
const count = observable(0);
count.value; // 0 - tracked: registers a dependency
count.value = 5; // write
count.set(5); // the same write, as a call
count.peek(); // read WITHOUT registering a dependency
peek() and set() are closures rather than methods, so they survive being destructured off the observable or handed
straight to a callback.
The equals comparator gates notification, not the write. A write always lands; the graph hears about it only when
the comparator reports a change:
const user = observable({id: 1, seenAt: 0}, {equals: (a, b) => a.id === b.id});
The default is deep equality. The corollary: mutating the held value in place and assigning it back is invisible, because old and new are the same reference. Produce new values.
observableArray
observableArray(initial?, {equals?}) → {value, length, peek(), set(a), remove(), removeAll(), indexOf(), replace(), destroy(), destroyAll(), subscribe(), extend(), …mutators}
const rows = observableArray([{id: 1}]);
rows.value; // the underlying array - tracked
rows.length; // tracked, so a rendered count updates
rows.peek(); // the live array, untracked
rows.value = [...]; // wholesale replacement - gated by `equals`
rows.push(item); // and pop, shift, unshift, splice, sort, reverse, fill, copyWithin
rows.remove(item); // every occurrence of that exact object
rows.remove(r => r.id > 2); // every item the test accepts
rows.removeAll();
rows.indexOf(item); // tracked, unlike peek().indexOf(item)
rows.replace(old, next); // swaps the first occurrence, in place
rows.destroy(item); // marks rather than removes - see below
rows.destroyAll();
In-place mutators run the native method and return exactly what it returns, so pop() gives you the item and splice()
the removed slice.
Two write paths, two rules. Mutators notify unconditionally - an in-place mutation leaves the array equal to
itself. Wholesale assignment is gated, exactly as observable() is. The accepted cost is a spurious notification from
a mutator that changed nothing.
The initial array, and any array assigned wholesale, is copied rather than adopted - holding your reference would
let a push bypass the graph. If you want the live array, take it from peek().
destroy() marks an item _destroy: true and leaves it in the array; both render paths skip a marked item. It exists
for Rails' accepts_nested_attributes_for convention. Outside that, use remove().
computed
computed(fn | {read, write}, {label?}) → Computation
const total = computed(() => price.value * qty.value);
total.value; // recompute if stale, then return - and register a dependency
total.get(); // identical; `.value` is the form a template can use
Computeds are lazy: the body runs on first read, then only when something it read has changed. Dependencies are re-collected on every run, so a computed whose branch changed stops depending on the branch not taken.
Read-only unless you say where a write should land:
const celsius = observable(100);
const fahrenheit = computed({
read: () => celsius.value * 9 / 5 + 32,
write: (f) => { celsius.value = (f - 32) * 5 / 9; }
});
fahrenheit.value = 32; // → celsius.value === 0
That is what lets data-model="fahrenheit.value" bind a derived value. Assigning to a computed with no write warns
and does nothing. The write runs untracked.
Extenders
.extend({…}) layers behaviour onto an observable after it exists.
| Extender | Value | Effect |
|---|---|---|
rateLimit |
ms, or {timeout, method} |
hold notifications; method is 'notifyWhenChangesStop' (default) or 'notifyAtFixedRate' |
throttle |
ms | Knockout's older name; given rateLimit's behaviour |
notify |
'always' |
announce every write, including one the change gate would swallow |
const query = observable('').extend({rateLimit: 300});
A write always lands immediately, whatever is extended onto it - only the notification is held. rateLimit uses a
timer rather than the graph's flush, so flushSync() does not deliver a held notification. .extend({rateLimit: 0})
switches it off.
registerExtender(name, fn) adds your own. The handler receives a control surface with exactly two powers -
setEquals(fn) and intercept(wrap) - and no way to touch the stored value.
The graph
effect
effect(fn, {label?}) → Computation
Runs immediately, so its dependencies are collected up front, and re-runs on the microtask flush after any of them changes. Nothing depends on an effect - they sit at the leaves.
const stop = effect(() => { document.title = `${unread.value} unread`; });
stop.dispose();
An effect that does asynchronous work must read its dependencies before the first await, or they are not
collected.
untracked
untracked(fn) → any - runs fn with dependency collection suspended.
effect(() => {
const rows = list.value; // tracked
untracked(() => analytics.send(rows.length)); // not
});
subscribe
observable.subscribe(fn) → off
Subscribers fire synchronously, at the write, not on the flush that follows. They are not Computations, so a
hundred subscriptions do not add a hundred nodes to the graph. A subscriber that throws is reported and skipped.
const off = count.subscribe(value => console.log('now', value));
off(); // or off.dispose()
Batching and flushSync
Writes never recompute anything synchronously. They mark computations dirty, queue them, and schedule one microtask flush, so a burst of writes collapses into a single propagation pass:
first.value = 'Ada';
last.value = 'Lovelace';
// → one flush, one re-render
flushSync() drains the queue immediately - what a test uses to assert on the DOM without awaiting a microtask.
Disposal
| Created by | Torn down by |
|---|---|
effect(fn) |
.dispose() on the returned Computation |
compile(…) |
controller.destroy() |
applyBindings(…) |
handle.dispose() |
observable.subscribe(fn) |
the returned off(), or off.dispose() |
Both are safe to call twice.
The twelve bindings
Five come from mustache syntax, and exist only in a compiled template:
| Kind | Template | Updated by |
|---|---|---|
text |
{{name}} |
textContent on a <span> anchor |
attr |
class="{{cls}}" |
setAttribute on the owning element |
block |
{{#if x}}…{{/if}} |
re-rendering a comment-delimited region |
raw |
{{{html}}} |
re-rendering a comment-delimited region |
each |
{{#each xs key=id}}…{{/each}} |
reconciling, per item |
Seven are attributes, and work in both entry points. Every value on the right of one is an expression.
data-bind-<name>
The suffix is the target:
| Suffix | Effect |
|---|---|
text |
textContent |
class |
adds/removes only the tokens this binding applied last time |
style |
an object of CSS properties |
style-<property> |
one CSS property, e.g. data-bind-style-font-weight |
value checked disabled readonly required selected multiple indeterminate open hidden |
the DOM property |
| anything else | an attribute of that name |
For an attribute, false / null / undefined removes it and true sets it to the empty string.
data-bind-class is additive on purpose - el.className = value would delete every static class - so the handler
remembers the tokens it applied and swaps only those. A falsy value contributes none, which is what makes
data-bind-class="isActive && 'on'" work.
Style comes in two spellings. Since 1.2 an object literal parses too (data-bind-style="{color: shade}"), but one
property per attribute stays the clearer spelling:
<p data-bind-style-color="shade"></p> <!-- one property -->
<p data-bind-style-font-weight="weight"></p> <!-- kebab-cased -->
<p data-bind-style---brand="accent"></p> <!-- custom property -->
<p data-bind-style="look"></p> <!-- {color, fontWeight, …} -->
A falsy value removes the property (0 is not treated as falsy - opacity: 0 is a real value). No unit is ever added.
There is no data-bind-html. Assigning innerHTML from data is the shortest route to an XSS hole, and the template
already has an explicit opt-out: {{{triple-stache}}}. Using the attribute logs one warning and writes nothing.
data-on-<event>
<button data-on-click="save">Save</button>
<button data-on-click="remove(item, 2)">Delete</button>
<button data-on-click="$parent.remove($data)">Delete</button>
Your declared arguments come first and the event is always the last argument. Returning false calls
preventDefault().
A method call is allowed here and nowhere else - an event fires on a gesture, outside every effect, where a call is
not a side effect during a read. this follows JavaScript's own rule:
| Expression | this |
|---|---|
save |
$data - a reference; no receiver was named |
save(x) |
$data - a bare callee is a name on $data |
handlers.save |
$data - still a reference |
handlers.save() |
handlers - a method call keeps its receiver |
Event bindings declare no dependencies: the listener is attached once and reads the context at dispatch time.
data-model
Two-way. The expression must be a settable path - a bare name, or a member chain ending in one.
<input data-model="query.value">
<input data-model="user.email">
<input data-model="rows[i]">
| Control | Property | Listened events |
|---|---|---|
| checkbox | checked (boolean) |
change |
| radio | checked, against its value |
change |
select[multiple] |
an array of selected values | change |
select |
value |
change |
number, range |
value coerced to Number, empty → null |
input, change |
| everything else | value |
input, change |
Anything that is not a path warns and writes nothing. __proto__, constructor and prototype are refused as keys in
every form. An unchecked radio writes nothing, so the group's value is not cleared by the sibling that lost the
selection. The data → DOM direction writes only when the value actually differs, so re-rendering while someone types
does not move their caret.
data-if
<div data-if="isOpen">…</div>
The element is in the DOM or it is not - it is not hidden with CSS. Use data-bind-hidden if that is what you want.
Truthiness is mustache truthiness, so an empty array is falsy.
The two entry points differ here, and only here: applyBindings removes the element and puts the same node back, so
it keeps its children, listeners and focus; compile() re-renders the region instead, because a detached element's
bindings would go stale.
data-options
<select data-options="cities.value" data-model="chosen.value"></select>
<select data-options="people.value"
data-options-text="first + ' ' + last"
data-options-value="id"
data-options-caption="'Anyone'"
data-model="assignee.value"></select>
| Attribute | Meaning |
|---|---|
data-options |
the collection - the array, so groups.value, not groups |
data-options-text |
the label - an expression against the item; defaults to the item |
data-options-value |
the value - likewise |
data-options-caption |
a leading option with an empty value; a literal needs its quotes |
{{#each}} produces the same markup; what it does not produce is the selection. This binding rebuilds and puts the
selection back. Values need not be strings - the real value is kept alongside, so data-model reads back the object or
the number that went in. Order and timing do not matter: a value the model asked for while no option carried it is
remembered and applied by the rebuild that brings it.
data-focus
<input data-model="title.value" data-focus="editing.value">
Two-way between a value and focus. Setting it true moves focus into the field; the user tabbing in sets it true;
blurring sets it false. Unlike data-model, an expression it cannot write through is not fatal - the value → focus
direction keeps working and only the write-back warns.
data-component
<div data-component="'contact-card'" data-param-contact="$data"></div>
<div data-component="currentView.value"></div>
See Components. The name is an expression, so a literal takes inner quotes.
Custom bindings
registerBinding(name, handler) → handler. All twelve built-ins are registered through this exact function, so
anything a built-in does, a custom binding can do.
registerBinding('currency', {
attribute: 'data-currency', // or attributePrefix: 'data-currency-'
expression: true, // parse the value; binding.evaluate is set
tracks: true, // contribute the expression's dependencies
primes: true, // run update() once after the first paint
update({binding, nodes, context}) {
const text = money(binding.evaluate(context));
for (const element of nodes) element.textContent = text;
return true;
}
});
update is required; attach({binding, node, controller}) and detach(…) are optional and are what data-on-* and
data-model use. region: true wraps the element in comment anchors and fills binding.body - how data-if works -
and is refused by applyBindings, because there the markup is the page. Register before compiling.
Binding context
| Name | Meaning |
|---|---|
$data |
the object names resolve against |
$root |
the top-level data, however deep the nesting |
$parent |
the enclosing data (not the enclosing context) |
$parents |
all ancestor data, nearest first - $parents[0] is $parent |
$parentContext |
the enclosing context - the one name here that is one |
$index |
position within a list |
$length |
size of the enclosing list |
$component |
the enclosing component's view model |
All eight resolve everywhere; outside a list, the positional ones are null. There is no scope-chain walk - a bare
name resolves against $data only. Reach up with $parent.name, which says what it means.
Contexts are frozen. Writing to $parents[0] or through $parentContext warns and does nothing - write to ancestor
data instead, which $parents[1].name = x does.
createRootContext(data) and createChildContext(parent, data, index?, length?) build them by hand; plain data passed
anywhere a context is accepted is promoted for you.
Keyed lists
data-each="items key=id", or {{#each items key=id}}…{{/each}}.
An item that stays in the collection keeps its DOM nodes and its effects across any change to the list, so focus, half-typed input, scroll position, CSS transitions and media playback all survive.
key= names the property that identifies an item; a dotted path (key=meta.ref) works. It must be an identity, not
a value - a key that changes when the item's contents change defeats the mechanism.
| Situation | Behaviour |
|---|---|
{{#each}} without key= |
falls back to re-rendering wholesale, warns once. {warnUnkeyed: false} silences it |
data-each without key= |
refused, with a warning - an unkeyed list cannot promise node identity |
A keyed block inside an unkeyed {{#each}} or a {{#with}} |
demoted to a re-rendered block, with a warning. Add key= to the enclosing block |
data-each inside another list |
inert, with a warning - use {{#each}}, which works to any depth |
{{> partial}} inside a keyed block |
not expanded; the body is compiled once, before any render pass exists |
Everything works inside a keyed block: per-item bindings, nested keyed lists, {{#if}}, and the renderer's loop
variables ({{.}}, {{@index}}, {{@first}}, {{@last}}).
Lifecycle. Each item is an instance - two comment anchors, the nodes between them, a context, and one effect per binding. It is disposed effects first, then nodes when its key leaves the collection, when an enclosing region re-renders over it, or when the controller is destroyed.
Placement is in order, which performs more DOM moves than strictly necessary on a reverse or a long drag. Nothing about correctness or node identity depends on it.
Components
registerComponent(name, {template, create?}) → definition - throws on a bad definition, because a bad registration
is a programming error at startup.
registerComponent('contact-card', {
template: `
<div class="card">
<b data-bind-text="contact.name.value"></b>
<button data-on-click="toggle" data-bind-text="label.value"></button>
</div>`,
create(params, {element}) {
const editing = observable(false);
return {
contact: params.contact,
editing,
label: computed(() => (editing.value ? 'done' : 'edit')),
toggle() { editing.value = !editing.value; },
dispose() { /* optional */ }
};
}
});
create is a plain factory - no new, no constructor form. Leave it out and the component is template-only, with
the params themselves as $data.
Params
<div data-component="'contact-card'" data-param-contact="$data" data-param-editable="canEdit"></div>
<div data-component="'contact-card'" data-params="cardParams"></div>
Both forms may appear together; a named attribute beats the same key in the object, and a collision warns.
Attribute names are kebab-case and arrive camelCased: data-param-first-name is params.firstName.
data-param-* on an element with no data-component warns at compile time.
Params are evaluated once, when the instance is created - a constructor argument, not a live binding. What that means for writes falls out of reads being explicit:
| Markup | The view model receives | Can it write back? |
|---|---|---|
data-param-contact="user.name" |
the observable itself | yes - the parent sees the write |
data-param-contact="user.name.value" |
a snapshot of the value | no |
The params object is frozen; the observables inside it stay writable through .value.
Swapping
Point data-component at an observable and the rendered component follows it. Changing the name tears the old instance
down - view model dispose() first, then its effects, then its nodes - and builds the replacement, re-evaluating every
param. Setting the name to what it already is does nothing at all.
A component renders inside its element rather than replacing it, so the host keeps its attributes and identity
across a swap, and create(params, {element}) receives it.
Slots
{{#slot name}}fallback{{/slot}} in the template; data-slot="name" at the usage site. Anything without a data-slot
goes to the default slot, text included. The block body is the fallback, used only when nothing is projected.
| Markup | Compiled by | Resolves against |
|---|---|---|
the fallback, inside {{#slot}} |
the component's template | the component's view model |
| projected content, inside the host element | the page | the outer context |
A component cannot inject values into its slot content. To hand something outward, pass a callback param. Projected
content keeps working after it is placed - it is the same DOM, moved - so a data-model inside a slot writes straight
back to the page's observable.
It is a block rather than an element because an element does not survive the HTML parser: <tr><dm-slot> is hoisted out
of the table, <select><dm-slot> is deleted, and comment anchors sit anywhere.
Failure
registerComponent throws. Everything after it warns once and skips:
| Situation | Behaviour |
|---|---|
| The name expression does not parse | warn once, host left empty |
| The name is not a string | warn once naming the quoting rule, host left empty |
| No component registered under that name | warn once naming the name, host left empty |
| A param expression does not parse | warn once, that param is absent |
create() throws |
warn once, host left empty, no instance registered |
dispose() throws |
warn, and teardown continues |
data-slot="x" and no slot x |
warn once naming x, that content is left out |
Content given to a component with no {{#slot}} |
warn once - it would vanish silently otherwise |
| Two slots with the same name | warn once; the first is filled |
applyBindings
applyBindings(data, rootElement) → handle
Activates binding attributes on HTML that already exists, leaving the markup otherwise as it found it.
| Returns | {bindings, context(), update(data), dispose()} |
| Idempotent | applying twice skips elements already bound and warns once, naming the root |
| Disposable | dispose() drops every effect, listener, list instance and marker it created, restores a hidden data-if element, and leaves the markup as it was found |
Every binding gets its own effect, so a view model built from observables updates itself. For a plain, untracked object,
handle.update(data) re-runs everything.
{{ }} is not interpolated here, deliberately, and it says so once if it finds a token that looks like a binding.
The exception is the contents of a data-each, which are a template rather than rendered output.
Virtual bindings
For markup with no element to spare:
<ul>
<li>Always shown</li>
<!-- dm if: showExtras -->
<li>Only when showExtras</li>
<!-- /dm -->
<!-- dm each: rows key=id -->
<li data-bind-text="name"></li>
<!-- /dm -->
</ul>
<p>Signed in as <!-- dm text: user.name -->…<!-- /dm -->.</p>
| Form | Behaviour |
|---|---|
<!-- dm if: expr --> |
the run of nodes is in the document or held aside - the same nodes come back |
<!-- dm each: expr key=id --> |
the run is the item template; key= is required |
<!-- dm text: expr --> |
one text node between the anchors |
Every closer is <!-- /dm -->, whatever it closes, and they nest. An opener with no closer is skipped with a warning,
and a virtual binding inside a virtual list's body is not read - that body is compiled as a template, and the
compiler knows mustache, not comments.
compile
compile(template, data, container, renderFn?, options?) → controller
Turns a mustache template into fine-grained bindings. Pass {reactive: true} and every binding gets its own effect.
| Controller | |
|---|---|
bindings |
the binding records, each with id, kind, expr, deps |
deps(id) |
the root names one binding reads |
update(id, data) |
re-run one binding |
updateAll(data) |
re-run all of them |
context() |
the binding context in force |
destroy() |
tear everything down |
annotate(template, options?) and scanBlocks(template) are the string-only halves, needing no DOM.
TemplateCompiler groups them, plus resolvePath.
The renderer
renderTemplate(template, data, {partials?}) → string is the default renderFn, exported for use on its own. It
supports {{x}}, {{{x}}}, {{#if}} / {{else}}, {{#unless}}, {{#each}}, {{#with}}, {{> partial}}, {{.}},
{{@index}}, {{@first}}, {{@last}} and {{! comments }}. Interpolations escape; triple-staches do not.
A {{ }} becomes a live text binding when it is a dotted path, or when it contains unambiguous operator syntax and
parses; {{.}}, {{@index}} and {{helper arg}} are left to the renderer. - and + count as operators only with
whitespace around them, so {{first-name}} reads a kebab-case key and {{ a - b }} is arithmetic.
Expressions
Parsed by hand, never compiled with the Function constructor - which is what lets the library run under
script-src 'self'.
| Category | Forms |
|---|---|
| Paths | a, a.b.c, a[0], a[key], a['x'] |
| Literals | 'str', "str", 1, 1.5, 1e3, true, false, null |
| Arithmetic | + - * / % (+ also concatenates) |
| Comparison | === !== < <= > >= |
| Logical | && || ! - short-circuiting |
| Ternary | a ? b : c |
| Unary | - + ! |
| Objects and arrays (1.2) | {a: x, 'b-c': y}, [a, b], [a, b][i] - no computed keys, shorthand, spread, methods or holes |
| Calls | helper(arg, …) - registered helpers only |
| Context | $data, $root, $parent, $parents, $parentContext, $index, $length, $component |
Precedence and associativity are JavaScript's. Nesting is capped at 64 levels.
Not supported, and will not be: assignment, new, member calls (user.toUpperCase()), loose equality, ??,
regular expressions, template literals, comma sequences, and reads of __proto__,
constructor or prototype in any form - including a[key] where key holds one of them at runtime. Most are
recognised specifically so they can be refused with a message saying what to do instead.
data-on-* is the single exception for calls, and only because an event fires outside every effect.
Failure is never fatal. A malformed expression logs one warning naming the source and the template, and yields
null from parseExpression / undefined from evaluateExpression. Nothing in this module throws on expression
input.
Helpers
registerHelper(name, fn) - the only callable thing an expression may name. Throws on a bad name or a non-function.
registerHelper('upper', s => String(s).toUpperCase());
compileExpression("count > 0 ? upper(label) : 'none'");
API index
State
| Name | Signature |
|---|---|
observable |
(initial, {equals?}) → {value, peek(), set(v), subscribe(fn), extend(spec)} |
observableArray |
(initial?, {equals?}) → {value, length, peek(), set(a), remove(valueOrTest), removeAll(), indexOf(v), replace(old, new), destroy(valueOrTest), destroyAll(), subscribe(fn), extend(spec), …mutators} |
isEqual |
(a, b) → boolean - the deep comparison the change gate uses |
Graph
| Name | Signature |
|---|---|
computed |
(fn | {read, write}, {label?}) → Computation |
effect |
(fn, {label?}) → Computation - runs immediately; .dispose() to stop |
untracked |
(fn) → any |
flushSync |
() → void |
Dep |
class - one reactive slot; track(), trigger() |
DepMap |
class - lazily-populated keyed collection of Deps |
Computation |
class - the node type behind computed and effect |
trackingProxy |
(target, depFor, {onSet?}) → Proxy |
Bindings
| Name | Signature |
|---|---|
compile |
(template, data, container, renderFn?, options?) → controller |
applyBindings |
(data, rootElement) → handle |
annotate |
(template, options?) → {annotated, bindings} - string-only |
scanBlocks |
(template) → block records - string-only |
TemplateCompiler |
the above grouped, plus resolvePath |
registerBinding |
(name, handler) → handler |
unregisterBinding |
(name) → boolean |
registerExtender |
(name, fn) → fn - throws on a bad name |
unregisterExtender |
(name) → boolean - refuses the built-ins |
registerComponent |
(name, {template, create?}) → definition - throws on a bad definition |
unregisterComponent |
(name) → boolean |
createRootContext |
(data) → context |
createChildContext |
(parent, data, index?, length?) → context |
Expressions and rendering
| Name | Signature |
|---|---|
parseExpression |
(source, options?) → AST | null |
evaluateAst |
(ast, context) → any |
evaluateExpression |
(source, context, options?) → any |
compileExpression |
(source, options?) → (context) => any | null |
expressionDependencies |
(sourceOrAst, options?) → Set<string> |
registerHelper |
(name, fn) → fn - throws on a bad name |
unregisterHelper |
(name) → boolean |
clearExpressionCache |
() → number - entries dropped |
renderTemplate |
(template, data, {partials?}) → string |
What throws, and what warns
Only three things throw, and all three are programming errors at startup rather than authored data met mid-paint:
registerComponent, registerExtender and registerHelper, each on a bad definition.
Everything else - a malformed expression, a missing component, an unkeyed data-each, a write to a read-only computed,
a data-bind-html, a subscriber that throws, a dispose() that throws - logs one warning naming the expression and
the template, and skips that binding alone. One broken binding never takes a page down.