Skip to main content

Optimizations

Exodra is fast by default. The optimizations here make specific hot paths cheaper — they never gate correctness. You can reach for the hand-tuned ones directly, and the compiler applies others automatically when you use the plugin.

It works without any of this

Nothing below is required for an app to run correctly, and the always-on parts of the model are already fast:

  • Three‑props, resolved at compile time. static / bindables / bindableLists / handlers are separate buckets, so there is no per‑prop runtime type dispatch — the renderer knows what each key is by which bucket it's in. Static props cost nothing at update time (no subscription).
  • Fine‑grained updates, no virtual DOM. A bindable writes straight to the DOM node it's bound to — no tree diff, no scheduler between the write and the textContent/attribute assignment.
  • Build once, mutate in place. A node's DOM is built once; reactive changes mutate it, they don't rebuild.

The trade behind "no scheduler"

Writing straight to the DOM is fast precisely because Exodra's core reactivity does no glitch‑batching — there is no scheduler coalescing writes or topologically ordering a derived graph the way Solid's or Voby's (oby) do. That is a deliberate design choice with two sides:

  • The common case pays nothing. A signal driving a DOM prop, or a derive off a single source (plain pub/sub), has no deep dependency graph — so there is nothing to glitch and nothing to batch. You skip the per‑write scheduler overhead entirely. (This is why Exodra edges the scheduler‑based fine‑grained libs on the reactive-update benchmark.)
  • Heavy multi‑source state delegates coherence upward. If you build many interdependent values or computeds over collections, batching and glitch‑free consistency are not the view layer's job — you hand them to the data layer. A reactive store like @oimdb/* already owns a transactional queue: it coalesces writes, flushes a consistent snapshot, and feeds already‑settled values into bindables as plain direct writes. The batching didn't disappear — it lives where a transaction model already exists, instead of being duplicated inside the renderer.

So the direct‑write speed in the benchmarks is one face of a coin whose other face is "core reactivity stays thin; the store owns coherence." Most apps never touch that other face; data‑dense ones get a clean division of labour instead of two schedulers.

So a hand‑written app — or one that opts into nothing below — still runs correctly and quickly. The rest is about shaving specific costs.

By hand

Things you control directly:

Put values in the right bucket

static is baked and never subscribed; bindables/bindableLists are reactive. Putting a constant in bindables needlessly creates a subscription; putting a per‑instance-but-unchanging value in static is free. Match the bucket to how the value actually behaves.

Use the imperative list API for known mutations

A bindableList exposes move / insert / remove / push — each does a direct, targeted DOM operation (O(1) per op), no diff:

rows.move(0, 3); // one DOM move
rows.insert(2, item); // one DOM insert
rows.remove(5); // one DOM remove

Prefer these over replacing the whole list (setValue(newArray) / reset), which forces a full keyed reconcile. See Lists. (This is why Exodra leads the list‑update benchmark by a wide margin — it does the operation, other frameworks diff their way to it.)

cache:key for a repeated static template

If you build the same fully‑static subtree many times, mark it with cache:key — the renderer builds it once and clones the rest instead of re‑creating every node:

{items.map(() => <li cache:key={ROW} static={{ class: 'row' }}></li>)}

cache:key compiles to the 3rd argument of h(type, attrs, cacheKey) (see @exodra/dom). The key must be shared across occurrences (one symbol/string), and the subtree must be static — the key is your promise of that.

With the compiler

When you compile with @exodra/vite-plugin / @exodra/babel-plugin-jsx / @exodra/babel-preset, you write plain JSX and the compiler emits the optimized h() calls for you. It never changes behavior — the output renders identically, just faster.

Auto clone + patch of mostly‑static subtrees

A subtree written inline inside a .map()/loop whose structure is fixed — even if it has a few reactive holes — is given an automatic cacheKey. At runtime the renderer builds that skeleton once and, for every occurrence, clones it and patches only the holes (reactive text/attributes, event handlers) instead of building every node with createElement/setAttribute. For a list of cards this turns per‑node construction into one native cloneNode + a couple of patches.

Honest limits:

  • It fires for inline repeated JSX, not component invocations — a <Card/> component body is not templatized yet.
  • It handles scalar holes (text, attribute, handler), not a dynamic child‑list nested inside a template.

Static hoisting

A fully‑static repeated subtree (no reactive holes) is the zero‑hole case of the above: built once, cloned per occurrence. Note this is a clone‑cache keyed by a shared symbol — the h() call stays inline (a fresh schema object per iteration); only the key is shared. Hoisting a shared schema object to a variable is not how it works and would be rejected (a schema object may live in only one position at a time).

If you don't use the compiler, none of this fires — but everything still works, and you can add cache:key by hand.

Measuring

Compare against Solid/Svelte/React with npm run bench (see Benchmarks), and profile the DOM renderer per method — in the browser or under jsdom — with @exodra/dom-profiler.