Skip to content

ContinuumUI where change is data

Starts like useState — state(0), .set, values straight in JSX. Then every change becomes an occurrence in a stream with transactional semantics, and undo, race-free search and cross-tab sync cost one fold each — not a library each.

Solid-class speed and memory — in a fraction of the bytes

Bundle size — full counter appkB gzip · lower is better
Continuum5.6 kB
Solid~7 kB
React + ReactDOM~45 kB
Memory — 10,000 rows, heap after GCMB · lower is better
Continuum9.7 MB
Solid14.1 MB
GC garbage — create 10,000 rowsMB allocated · lower is better
Continuum8.4 MB
Solid13.8 MB

−32 % memory, ~40 % less garbage, faster interaction — and one third the download. Measured on one machine through the same Playwright harness (npm run bench, bench:mem, size); full table with per-operation timings in the overview. React is a virtual-DOM re-render model — several times slower on the same table, and ~8× the bytes.

The same counter, two models

import { useState } from "react";

function Counter() {
  // the component re-runs on every render
  const [count, setCount] = useState(0);
  const double = count * 2; // recomputed each render

  return (
    <button onClick={() => setCount((c) => c + 1)}>
      {count} / {double}
    </button>
  );
}

Re-runs top to bottom on every change; double is recomputed, the tree is diffed.

Try it — right here

Edit the code, hit Run, and open the Compiled tab to see it become a parse-once template with a single insert hole. Then scaffold your own:

bash
npm create continuum-js@latest my-app

If you know useState, you already know this — except Counter never runs again. {count} binds a text node to the value; clicking patches exactly that node.

Then it stops being another framework

Everything above, Solid also promises. Here is the part it doesn't: change itself is a value. Route every change through one stream of actions and fold it — and features that are normally a library each become one line each:

tsx
const actions = stream<Action>();

const todos = actions.accum(loadPersisted("todos", []), reduce); // the state
const history = actions.accum(emptyHistory, undoReduce); //       + undo
onCleanup(persist("todos", todos)); //                            + persistence
// the `storage` event dispatching into the same reducer:         + cross-tab sync

And because requests are a stream too, search cannot race:

tsx
const results = resource(debounce(query.updates, 300), search);
// a stale response physically cannot overwrite a fresh one

The reducer never changes. The patterns cookbook walks through all of these — undo, optimistic updates, race-free search — each as a few lines over the same stream.