tosijs
tosijs.net | tosijs-ui | github | npm | cdn | react-tosijs | discord
tosijs keeps the state of a web app in plain objects and wires it to real DOM elements.
Change the state and the elements bound to it update, with no virtual DOM, no JSX and no
build step. It is small enough to read in an afternoon.
npm install tosijs # or: bun add tosijs
import { tosi, elements } from 'tosijs'
const { app } = tosi({ app: { name: 'world' } })
const { h1, input } = elements
document.body.append(
h1('Hello, ', app.name),
input({ bindValue: app.name })
)
No build step at all? The same code runs from a CDN in a module script:
<script type="module">
import { tosi, elements } from 'https://cdn.jsdelivr.net/npm/tosijs/dist/module.js'
// …
</script>
New here? Start Here is a ten-minute tutorial on the two tools everything else is built on: the observer proxy and element creators. Building Apps is the full guide.
Principles
A web page is a set of visible things (elements) on a screen, showing information
(data). An app differs from a static page in one way: the data can change.
tosijs is built on three principles that follow from that:
- The browser is incredible. Let it do what it does best. HTML, the DOM, CSS, real
events and standard
<input>s with their native accessibility are the platform, not something to re-implement. - Elements are the user interface. Creating them, fine-tuning them and updating them should be insanely easy. That is what element creators are for.
- State is why the app exists. Maintaining it, updating it from what the user does and from what happens elsewhere, and showing every change to the user quickly and accurately should be insanely easy. That is what the observer proxy is for.
And one about you: what you already know about the browser is leveraged, not set
aside. The more you know about what goes on behind the scenes, the better you will be
with tosijs; the less you know, the less you will have to learn that is not universally
true and useful. There is no framework-shaped replacement for the platform to learn, and
re-learn at every major version.
Better apps with less code
Less code to write, read, run, debug, and maintain — which, as a bonus in the age of AI assistants, also means fewer tokens to generate and reason about. What the principles buy you:
- O(~1) DOM updates, even for big lists. A state change surgically updates exactly
the bound nodes — no virtual DOM, no diffing, no re-render-the-world. And because
virtual list bindings are built in, a 100,000-row list only ever renders (and
updates) the handful of rows actually on screen. (Give the list
virtualand a container that scrolls; an unvirtualized list of more than 10 items warns. See Array Bindings.) - No JSX, no transpilation, no build step required. Pure JS/TS in, native DOM nodes out — works in plain JavaScript or TypeScript.
- No lock-in. State is a plain observable object graph, not a framework you marry; bind it to vanilla DOM, web-components, React, or Angular.
- Zero runtime dependencies.
~30 kB gzipped from a script tag, ~28 kB for
tosijs/core, ~18 kB for the DOM-freetosijs/state; the full ESM entry is ~47 kB. The agent surface is opt-in and shakes away if you never import it. It costs~16.7 kB — the marginal measurement, the same ESM bundle built with and without it — including the schematic renderer, the accessibility audit and the contract harnesses. (The gap betweentosijsandtosijs/coreis a larger~19.0 kB, because `core` also drops blueprints, share/sync and hotReload.) Both figures are measured by the build. Entry points has the detail.
On top of that you get the conveniences you'd actually want: most binding code
eliminated, web-components you can build in pure JS more compactly than JSX, and
CSS handled with variables and real Color math.
import { elements, tosi, touch, deleteListItem } from 'tosijs'
const todo = {
list: [],
addItem(reminder) {
if (reminder.trim()) {
todo.list.push({ id: Math.random(), reminder })
}
},
}
todo.addItem('wash the cat')
todo.addItem('buy milk')
const { readmeTodoDemo } = tosi({ readmeTodoDemo: todo })
const { h4, ul, label, input } = elements
preview.append(
h4('To Do List'),
ul(
...readmeTodoDemo.list.tosi.listBinding(
({ li, button }, item) =>
li(
item.reminder,
button('Done!', {
style: {
marginLeft: 10,
},
onClick(event) {
// deleteListItem resolves the row from any child node — pass the
// button and it walks up to find its list item automatically
deleteListItem(event.target)
},
})
),
{ idPath: 'id' }
)
),
label(
'Reminder',
input({
placeholder: 'enter a reminder',
onKeydown(event) {
if (event.key === 'Enter') {
event.preventDefault()
readmeTodoDemo.addItem(event.target.value)
event.target.value = ''
// addItem() pushed onto the original array, not through the proxy,
// so tell tosijs it changed (a write through the proxy needs no touch)
touch(readmeTodoDemo)
}
},
})
)
)
In general, tosijs is able to accomplish the same or better compactness, expressiveness,
and simplicity as you get with highly-refined React-centric toolchains, but without transpilation,
domain-specific-languages, or other tricks that provide "convenience" at the cost of becoming locked-in
to React, a specific state-management system (which permeates your business logic), and usually a specific UI framework.
tosijs lets you work with pure HTML and web-components as cleanly—more cleanly—and efficiently than
React toolchains let you work with JSX.
export default function App() {
return (
<div className="App">
<h1>Hello React</h1>
<h2>Start editing to see some magic happen!</h2>
</div>
);
}
Becomes:
const { div, h1, h2 } = elements // exported from tosijs
export const App = () => div(
{ class: 'App' },
h1('Hello tosijs'),
h2('Start editing to see some magic happen!')
)
Except this reusable component outputs native DOM nodes. No transpilation, spooky magic at a distance, or virtual DOM required. And it all works just as well with web-components. This is what you get when you run App() in the console:
▼ <div class="App">
<h1>Hello tosijs</h1>
<h2>Start editing to see some magic happen!</h2>
</div>
The ▼ is there to show that's DOM nodes, not HTML.
tosijs lets you lean into web-standards and native browser functionality while writing less code that's
easier to run, debug, deploy, and maintain. Bind data direct to standard input elements—without having
to fight their basic behavior—and now you're using native functionality with deep accessibility support
as opposed to whatever the folks who wrote the library you're using have gotten around to implementing.
Aside:
tosijswill also probably work perfectly well withAngular,Vue, et al, but I haven't bothered digging into it and don't want to deal withngZonestuff unless someone is paying me.
If you want to build your own web-components versus use something off-the-rack like
Shoelace, tosijs offers a Component base class that, along with
its elements and css libraries allows you to implement component views in pure Javascript
more compactly than with jsx (and without a virtual DOM).
import { Component, elements, css } from 'tosijs'
const { h1, slot } = elements
export class MyComponent extends Component {
static shadowStyleSpec = css({
h1: {
color: 'blue'
}
})
content = [ h1('hello world'), slot() ]
}
Give a component attributes with withAttributes() — class MyCard extends withAttributes({ caption: '' }) {} — so they are typed on this. Since 1.10.0
Component has no index signature, so an undeclared this.caption is a type
error rather than a silent any. static initAttributes is not deprecated; see
Migration for both forms.
The difference is that web-components are drop-in replacements for standard HTML elements
and interoperate happily with one-another and other libraries, load asynchronously,
and are natively supported by all modern browsers.
Entry points
| import | what you get | gz |
|---|---|---|
tosijs |
everything, agent surface included — bundlers shake what you don't import | ~47 kB |
tosijs/agent |
the agent surface, schematic renderer, audit and contract harnesses (same file, narrower types) — EXPERIMENTAL | — |
tosijs/core |
no blueprint loader, no share/sync/hotReload, no agent surface |
~28 kB |
tosijs/state |
the DOM-free state layer: tosi, observe, touch, paths |
~18 kB |
<script src=…> (IIFE) |
registers <tosi-blueprint> so components load from markup; it sets no global, so for the API in a page use a module script (above) |
~30 kB |
tosijs/agent resolves to the same file as tosijs on purpose: a
separately-bundled agent surface would carry its own copy of the state
registry and describe an empty app. One runtime copy, always.
tosijs/core is opt-in rather than automatic because blueprints hydrate
from markup, so no import statement protects them — shaking the
registration would fail silently. Slim core is present, so it can speak: in
dev it warns if the page contains blueprint elements it cannot hydrate.
The agent surface shakes away, but not back to 1.7.x: when 1.8.0 shipped, an identical
consumer app that never touched the agent surface measured +2.9 kB gzipped
(+13.7%) against 1.7.9, because the contract seam, the path-segment guard
and the binding bookkeeping sit on the ordinary path. That figure is a
1.8.0-era measurement and has not been re-taken. If size matters more to you
than the features, tosijs/core is the smaller door.
Scaffolding
bunx tosijs create app my-app # a runnable app (bun index.html)
bunx tosijs create component my-widget # a component — blueprint form (default)
bunx tosijs create component my-widget --bare # …plain class form
bunx tosijs create blueprint my-widget # a publishable blueprint package
Everything scaffolded is agent-ready: components are born with a
contract (description, value schema, parts map, and a declared test), so
they self-describe on the agent surface and pass exerciseComponent() from
their first minute. The blueprint form is the default because blueprints
are consumable directly from markup — no build step on the consumer's
side:
<tosi-blueprint tag="my-widget" src="https://cdn.jsdelivr.net/npm/my-widget/dist/index.js"></tosi-blueprint>
<my-widget></my-widget>
An ecosystem with tosijs at its heart
tosijs is the observable core a whole family of tools is built on. Each is useful
on its own; together they let you build almost anything without leaving web
standards behind.
| Project | What it is |
|---|---|
| tosijs | This library — the path-based observant state core everything else is built on. |
| tosijs-ui | Just enough extra web-components to build any interface — it complements the native elements that already work rather than replacing them. Also ships the documentation-site system that renders these very docs: literate programming with live, editable examples pulled straight from Markdown and source comments. |
| tjs-lang | TypeScript that really transpiles in the browser (no server, no "just strip the types" fake) — and a better JavaScript: types that survive to runtime as contracts, safety boundaries, inline tests, and a gas-metered VM for genuinely safe eval (ship the logic, not a container to run it in). |
| react-tosijs | Dramatically simplify state management in React apps, integrate React with other frameworks or web-components, or give yourself an off-ramp from React. |
| ngx-tosijs | The same for Angular (signals, zoneless-first). |
| tosijs-schema | Foundational, slightly bleeding-edge plumbing: a type-by-example JSON-Schema engine with arguably the strongest performance / flexibility / architecture story of any JSON-Schema implementation — and increasingly so as it grows computed predicates. Most people won't need to think about it; the rest of the stack leans on it. |
| tosijs-product | Cinematic, scroll-linked product pages (Lottie, video, 3D, maps) authored in plain HTML. |
| tosijs-3d | Declarative 3D / VR / XR as web components, built on Babylon.js (WIP). |
What tosijs does
Observe Object State
tosijs tracks the state of objects you assign to it using paths allowing economical
and direct updates to application state.
import { tosi, observe } from 'tosijs'
const { app } = tosi({
app: {
prefs: {
darkmode: false
},
docs: [
{
id: 1234,
title: 'title',
body: 'markdown goes here'
}
]
}
})
observe('app.prefs.darkmode', () => {
document.body.classList.toggle('dark-mode', app.prefs.darkmode.value)
})
observe('app.docs', () => {
// render docs
})
What does
tosido, and what is aBoxedProxy?
tosiregisters your object intotosijs's central state tree and hands it back to you as aBoxedProxy.A
BoxedProxyis an ES Proxy wrapped around anobject(which in Javascript means anything that has aconstructorwhich in particular includesArrays,classinstances,functions and so on, but not "scalars" likenumbers,strings,booleans,null, andundefined)All you need to know about a
BoxedProxyis that it's a Proxy wrapped around your original object that allows you to interact with the object normally, but which allowstosijsto observe changes made to the wrapped object and tell interested parties about the changes.If you want the original object back you can use
.valueon any proxy to unwrap it.
No Tax, No Packaging
tosijs does not modify the stuff you hand over to it… it just wraps objects
with a Proxy, and when you make changes through the returned proxy, tosijs
notifies any interested observers.
import { tosi, observe } from 'tosijs'
const { foo } = tosi({
foo: {
bar: 17
}
})
observe('foo.bar', (path) => {
console.log('foo.bar was changed to', foo.bar.value)
})
foo.bar = 17 // does not trigger the observer
foo.bar = Math.PI // triggers the observer
Paths are like JavaScript
A proxy behaves just like the JavaScript Object it wraps — tosijs doesn't
copy or replace your object, so what you put in is what you get out (call
.value to unwrap a scalar):
import { tosi } from 'tosijs'
const original = { bar: 'baz' }
const { foo } = tosi({ foo: original })
// read through the proxy; .value unwraps the scalar
foo.bar.value === 'baz'
// really, it's just the original object
foo.bar = 'lurman'
original.bar === 'lurman' // true
// seriously, it's just the original object
original.bar = 'luhrman'
foo.bar.value === 'luhrman' // true
…but better!
It's very common to deal with arrays of objects that have unique id values,
so tosijs supports the idea of id-paths
import { tosi, boxed } from 'tosijs'
const { app } = tosi({
app: {
list: [
{
id: '1234abcd',
text: 'hello world'
},
{
id: '5678efgh',
text: 'so long, redux'
}
]
}
})
console.log(app.list[0].text.value) // hello world
console.log(app.list['id=5678efgh'].text.value) // so long, redux
console.log(boxed['app.list[id=1234abcd]'].text.value) // hello world
Telling tosijs about changes using touch()
Sometimes you will modify an object behind tosijs's back (e.g. for efficiency).
When you want to trigger updates, simply touch the path.
import { tosi, boxed, observe, touch } from 'tosijs'
const raw = { bar: 17 }
const { foo } = tosi({ foo: raw })
observe('foo.bar', (path) => console.log(path, '->', boxed[path].value))
foo.bar = -2 // console will show: foo.bar -> -2
raw.bar = 100 // nothing happens (changed behind tosijs's back)
touch('foo.bar') // console will show: foo.bar -> 100
Every BoxedProxy also has a .touch() method:
app.user.name.touch() // force update for a scalar
app.items[2].touch() // force update for a list item
For list items with idPath, .touch() automatically synthesizes the
equivalent id-path touch, so DOM bindings update correctly.
List Operations
Proxied arrays have listFind, listUpdate, and listRemove methods
for common list operations:
// Find — returns proxied item (mutations trigger observers)
const item = app.items.tosi.listFind((item) => item.id, 'abc')
// Find by DOM element (in click handlers)
const item = app.items.tosi.listFind(clickedElement)
// Upsert — update in place or push if not found
app.items.tosi.listUpdate((item) => item.id, { id: 'abc', name: 'New' })
// Remove — returns true if found
app.items.tosi.listRemove((item) => item.id, 'abc')
listUpdate preserves object identity — it mutates the existing object
property by property, so only changed properties fire observers and DOM
elements are reused (no teardown/recreation).
CSS
tosijs includes utilities for working with css.
import { css, vars } from 'tosijs'
The vars proxy converts camelCase properties into css variable references:
vars.fooBar // emits 'var(--foo-bar)'
`calc(${vars.width} + 2 * ${vars.spacing})` // emits 'calc(var(--width) + 2 * var(--spacing))'
css() processes an object, rendering it as CSS:
css({
'.container': {
position: 'relative'
}
}) // emits .container { position: relative; }
CSS variables can be declared using _ and __ prefixes in css() objects:
css({
':root': {
_textFont: 'sans-serif', // emits --text-font: sans-serif
_color: '#111', // emits --color: #111
}
})
Color
tosijs includes a powerful Color class for manipulating colors.
import { Color } from 'tosijs'
const translucentBlue = new Color(0, 0, 255, 0.5) // r, g, b, a parameters
const postItBackground = Color.fromCss('#e7e79d')
const darkGrey = Color.fromHsl(0, 0, 0.2)
The color objects have computed properties for rendering the color in different ways, making adjustments, blending colors, and so forth.
Use invertLuminance() to generate dark-mode equivalents of color values.
Hot Reload
hotReload() lets you pick parts of your app's state that survive a page
refresh. It was written as a debugging aid, and keeps what it saves in
localStorage, so read Hot Reload for what it
keeps, what it cannot, and why not to use it for anything serious.
Development
Working on tosijs itself? See Development.
License
Apache-2.0 as of 1.8.0 (BSD-3-Clause through 1.7.x) — the
change adds an explicit patent grant and a patent-retaliation clause. If you
redistribute a build containing tosijs, Apache-2.0 §4(d) asks you to carry
the NOTICE text in your attribution notices; BSD-3 had no such
requirement. Hosting an app you built with it is unaffected.
History & credits
tosijs descends from b8rjs → xinjs → tosijs — see tosijs history
for the full lineage. Upgrading? See Migration — 1.8.0
removes the deprecations that named it, keeps the rest working with warnings,
and relicenses to Apache-2.0. Developed with bun; logo animation by
@anicoremotion.