Reference

Thirty-three exports, twelve binding kinds, one expression grammar.

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.

Setup Tutorial Get it