The tosi proxy
tosi() assigns an object passed to it to a global state object,
and returns an observer proxy (BoxedProxy) wrapped around the global state object.
BoxedProxy wraps any object you pull out of it in an observer proxy. It boxes booleans, numbers, and strings in lightweight proxies that know their path and can access/modify the underlying value.
In rough terms:
const state = {}
const boxed = new Proxy(state, ...)
tosi = (obj<T>): BoxedProxy<T> => {
Object.assign(state, obj)
return state
}
This allows the following pattern, which gives Typescript a lot of useful information for free, allowing autocomplete, etc. with a minimumn of boilerplate.
import { tosi, elements, bind } from 'tosijs'
const { prefs } = tosi({
prefs: {
theme: 'system',
highcontrast: false
}
})
// this example continues…
So { prefs: ... } is assigned to the state object, and now prefs
holds the same stuff except it's wrapped in a BoxedProxy.
The BoxedProxy behaves just like the original object, except
that it:
- knows where it came from, so
prefs.path === 'prefs' - will automatically trigger updates if its properties are changed through it
- can return the underlying value:
prefs.value === prefs.valueOf() === the prefs property of the object passed totosi()` - it will wrap its non-object properties in objects and wrap those objects
in a BoxedProxy, so
prefs.theme.path === 'prefs.theme'
prefs.theme.value === 'system' // true
prefs.theme.path === 'prefs.theme' // true
prefs.theme.valueOf() === 'system' // true
String(prefs.theme) === 'system' // true (via Symbol.toPrimitive)
The BoxedProxy observes changes made through it and updates bound elements
accordingly:
bind(document.body, prefs.theme, {
toDOM(element, value) {
element.classList.toggle('dark-mode', value === 'dark')
}
}
const { select, option } = elements
document.body.append(
select(
{ bindValue: prefs.theme },
option('system'),
option('dark'),
option('light')
)
)
Setting up the binding to document.body will set the class
appropriately, and modifying prefs.theme will update the
bound element automatically.
proxy.theme.value = 'dark'
In javascript you can just write
proxy.theme = 'dark'(TypeScript doesn't allow this due to asymmetric get/set type limitations).
This, in a nutshell, explains exactly what tosijs is designed to do.
tosi tracks state and allows you to bind data to your user interface
directly and with almost no code, with clean separation between business
logic and presentation.
The elements proxy lets you build HTML elements with
data and event bindings more compactly and efficiently than you can using
JSX/TSX, and it deals in regular HTMLElement—no virtual DOM, tranpilation
magic, spooky action at a distance, or any similar nonsense.
If you need to do complex bindings, the bind method lets you directly
link data to the DOM and automatically sets up observers for you.
Component lets you create reusable web-components.
css lets you write CSS economically and makes it easy to leverage the power
of CSS variables, while Color allows you to do color math quickly and
easily until similar functionality is added to CSS.
In Finnish, tosi means true or really.
As conceived,
tosi()is an observerProxywrapped around your application's state. It's the single source of truth for application state.Note that the interactive examples on the tosijs.net website allow TypeScript but the Typescript is simply stripped to javascript using sucrase.
xin
xin is a path-based implementation of the observer or pub/sub
pattern designed to be very simple and straightforward to use, leverage
Typescript type-checking and autocompletion, and let you get more done with
less code and no weird build magic (such as special decorators or "execution zones").
In a nutshell
xin is a single object wrapped with an observer proxy.
- when you assign an object (or array) to
xinas a property, you're just assigning a property to the object. When you pull it out, you get a proxy of the underlying value, but the original value is still there, untouched.const foo = { bar: 'baz' } xin.foo = foo xin.foo.bar === foo.bar xin.foo.bar === 'baz' xin.foo !== foo // xin.foo is a proxy xin.foo.value === foo // foo is still there! - if you change a property of something already in
xinthen this change will beobservedand anything listening for changes to the value at that path will be notified. - tosijs's
bindmethod leverages the proxy to keep the UI synced with application state.
In the following example there's a <div> and an <input>. The
textContent of the former and the value of the latter are bound to
the path xinExample.string.
xin is exposed as a global in the console, so you can go into
console and look at xin.xinExample and (for example) directly
change it via the console.
You can also turn on Chrome's rendering tools to see how efficiently the DOM is updated. And also note that typing into the input field doesn't lose any state (so your text selection and insertion point are stable.
import { xin, elements } from 'tosijs'
xin.xinExample = {
string: 'hello, xin'
}
const { label, input, div, span } = elements
preview.append(
div(
{
style: {
display: 'flex',
flexDirection: 'column',
gap: 10,
padding: 10
}
},
div({bindText: 'xinExample.string'}),
label(
span('Edit this'),
input({ bindValue: 'xinExample.string'})
)
)
)
- a data-path typically resembles the way you'd reference a value inside a javascript object…
xinalso supports id-paths which allow you to create stable references to elements in arrays using a (hopefully unique) identifier. E.g. instead of referring to an item in an array asxin.foo.array[3], assuming it had anidofabcd1234you could writexin.foo.array[id=abcd1234]. This makes handling large arrays much more efficient.- when you pull an object-value out of
xinit comes wrapped in the xin observer proxy, so it continues to support id-paths and so on.
A Calculator
import { xin, elements, touch } from 'tosijs'
// here's a vanilla javascript calculator
const calculator = {
x: 4,
y: 3,
op: '+',
result: 0,
evaluate() {
this.result = eval(`${this.x} ${this.op} ${this.y}`)
}
}
calculator.evaluate()
xin.calculatorExample = calculator
// now we'll give it a user interface…
const { input, select, option, div, span } = elements
preview.append(
div(
{
onChange() {
calculator.evaluate()
touch('calculatorExample.result')
}
},
input({bindValue: 'calculatorExample.x', placeholder: 'x'}),
select(
{
bindValue: 'calculatorExample.op'
},
option('+'),
option('-'),
option({value: '*'}, '×'),
option({value: '/'}, '÷'),
),
input({bindValue: 'calculatorExample.y', placeholder: 'y'}),
span('='),
span({bindText: 'calculatorExample.result' })
)
)
Important points:
xinpoints at a single object. It's a Singleton.boxedpoints to the same objectxinandboxedare observers. They watch the object they point to and track changes made by accessing the underlying data through them.- because
calculator.evaluate()changescalculator.resultdirectly,touch()is needed to tellxinthat the change occurred. See path-listener for more documentation ontouch(). xinis more than just an object!xin['foo.bar']gets you the same thingxin.foo.bargets you.xin.foo.bar = 17tellsxinthatfoo.barchanged, which triggers DOM updates.
If you're reading this on tosijs.net then this the demo app you're looking works a bit like this and
xin(andboxed) are exposed as globals so you can play with them in the debug console.Try going into the console and typing
xin.app.titleto see what you get, and then try `xin.app.title = 'foobar' and see what happens to the heading.Also try
xin.prefs.themeand tryapp.prefs.theme = 'dark'etc.
Once an object is assigned to xin, changing it within xin is simple.
Try this in the console:
xin.calculatorExample.x = 17
This will update the x field in the calculator, but not the result.
The result is updated when a change event is triggered.
If you wanted the calculator to update based on any change to its internal state, you could instead write:
observe('calculatorExample', () => {
calculator.evaluate()
touch('calculatorExample.result')
})
Now the onChange handler isn't necessary at all. observe
is documented in path-listener.
import { observe, xin, elements } from 'tosijs'
const { h3, div } = elements
const history = div('This shows changes made to the preceding example')
preview.append(
h3('Changes to the calculatorExample'),
history
)
observe(/calculatorExample\./, path => {
const value = xin[path]
history.insertBefore(div(`${path} = ${value}`), history.firstChild)
})
Now, if you sneakily make changes behind xin's back, e.g. by modifying the values
directly, e.g.
const emails = await getEmails()
xin.emails = emails
// notes that xin.emails is really JUST emails
emails.push(...)
emails.splice(...)
emails[17].from = '...'
Then xin won't know and observers won't fire. So you can simply touch the path
impacted:
import { touch } from 'tosijs'
touch('emails')
In the calculator example, the vanilla calculator code calls evaluate behind
xin's back and uses touch('calculatorExample.result') to let xin know that
calculatorExample.result has changed. This causes xin to update the
DOM.
How it works
xin is a Proxy wrapped around a bare object: effectively a map of strings to values.
When you access the properties of an object assigned to xin it wraps the values in
similar proxies, and tracks the path that got you there:
xin.foo = {
bar: 'baz',
luhrman: {
job: 'director'
}
}
Now if you pull objects back out of xin:
let foo = xin.foo
let luhrman = foo.luhrman
foo is a Proxy wrapped around the original untouched object, and it knows it came from 'foo'.
Similarly luhrman is a Proxy that knows it came from 'foo.luhrman'.
If you change a value in a wrapped object, e.g.
foo.bar = 'bob'
luhrman.job = 'writer'
Then it will trigger any observers looking for relevant changes. And each change will fire the observer
and tell it the path that was changed. E.g. an observer watching lurman will be fired if lurman
or one of lurman's properties is changed.
The boxed proxy
boxed is a sister to xin that wraps "scalar" values (boolean, number, string) in
lightweight proxies. These proxies know their path and provide convenient access to the
underlying value. E.g. if you write something like:
xin.test = { answer: 42 }
boxed.box = { pie: 'apple' }
Then:
xin.test.answer === 42
xin.box.pie === 'apple'
// boxed scalars have .value and .path
boxed.test.answer.value === 42
boxed.box.pie.value === 'apple'
boxed.test.answer.path === 'test.answer'
boxed.box.pie.path === 'box.pie'
// valueOf() works for coercion
boxed.test.answer.valueOf() === 42
String(boxed.box.pie) === 'apple'
Boxed scalars also delegate to the underlying value's prototype methods, so you can call string, number, and boolean methods directly:
boxed.box.pie.toUpperCase() === 'APPLE'
boxed.box.pie.startsWith('app') === true
boxed.box.pie.length === 5
boxed.test.answer.toFixed(2) === '42.00'
Aside from always "boxing" scalar values, boxed works just like xin.
In the console, you can also access boxed and look at what happens if you
access boxed.xinExample.string. Note that this changes the value you get,
the underlying value is still what it was. If you set it to a new string
value that's what will be stored. xin doesn't monkey with the values you
assign.
Why?!
As far as Typescript is concerned, xinProxy just passes back what you put into it,
which means that you can now write bindings with type-checking and autocomplete and
never use string literals. So something like this just works:
const div = elements.div({bindText: boxed.box.pie})
…because boxed.box.pie has a xinPath which is what is actually used for binding,
whereas xin.box.pie is just a scalar value. Without boxed you could write
bindText: 'box.pie' but you don't get lint support or autocomplete. (Also, in
some cases, you might even mangle the names of an object during minification and
boxed will know the mangled name).
If you need the thing itself or the path to the thing…
proxys returned by xin are typically indistinguishable from the original object, but
in a pinch tosiPath() will give you the path (string) of a XinProxy while tosiValue
will give its "bare" value. tosiPath() can also be used to test if something is actually
a proxy, as it will return undefined for regular objects.
E.g.
tosiPath(luhrman) === 'foo.luhrman' // true
const bareLurhman = tosiValue(luhrman) // not wrapped
You may want the thing itself to, for example, perform a large number of changes to an
object without firing observers. You can let xin know you've made changes behind its back using
touch, e.g.
doTerribleThings(tosiValue(luhrman))
// eslint-disable-next-line
touch(luhrman)
This is useful because boxed.foo.bar always knows where it came from, while
xin.foo.bar only knows where it came from if it's an object value.
This means you can write:
import { boxed, elements } from 'tosijs'
boxed.boxedExample = {
string: 'hello, boxed'
}
const { boxedExample } = boxed
const { label, input, div, span } = elements
preview.append(
div(
{
style: {
display: 'flex',
flexDirection: 'column',
gap: 10,
padding: 10
}
},
div({bindText: boxedExample.string}),
label(
span('Edit this'),
input({ bindValue: boxedExample.string})
)
)
)
And the difference here is you can bind direct to the reference itself rather than a string. This leverages autocomplete, linting, and so on in a way that using string paths doesn't.
It does have a downside! boxedExample.string !== 'hello, boxed' and
boxedExample.string !== boxedExample.string because they're proxies, not primitives.
This is critical for comparisons such as === and !==.
Always use .value, tosiValue(), or valueOf() when comparing:
boxed.foo.bar.value === 'hello' or tosiValue(boxed.foo.bar) === 'hello'.
The .tosi accessor
Every boxed proxy has a .tosi property that provides the full reactive API
without risk of name collisions. If your data has properties named value,
path, observe, etc., those will shadow the proxy's direct API — but .tosi
is always available.
proxy.foo.tosi.value // get the underlying value
proxy.foo.tosi.value = 17 // set it (triggers observers)
proxy.foo.tosi.path // 'foo'
proxy.foo.tosi.observe(cb) // watch for changes
proxy.foo.tosi.touch() // force update notification
proxy.foo.tosi.bind(el, binding)
proxy.foo.tosi.on(el, eventType)
proxy.foo.tosi.binding(binding)
proxy.foo.tosi.listBinding(templateBuilder, options)
proxy.foo.tosi.listFind(selector, value)
proxy.foo.tosi.listUpdate(selector, newValue)
proxy.foo.tosi.listRemove(selector, value)
This is the recommended API. The only reserved name is tosi itself — don't
use it as a property name in your data.
If you do have data with a tosi property, use tosiAccessor(proxy) or
the TOSI_ACCESSOR symbol directly — these are guaranteed collision-free:
import { tosiAccessor, TOSI_ACCESSOR } from 'tosijs'
const acc = tosiAccessor(proxy.foo) // always works
const acc2 = proxy.foo[TOSI_ACCESSOR] // also always works
acc.value // underlying value
acc.path // path string
Direct helper properties (deprecated)
The following properties are also available directly on boxed proxies, but
they can be shadowed by actual properties on the target object. Prefer
.tosi.* instead:
.valuegets or sets the underlying value.pathgets the string path.observe(callback)watches for changes, returns an unsubscribe function.touch()forces an update notification.bind(element, binding, options?)binds the value to a DOM element.on(element, eventType)binds an event handler.binding(binding)returns an inline binding spec for use with elements.listBinding(templateBuilder, options?)returns a list binding spec.valueOf()/.toJSON()for type coercion (scalars also have.toString())
Boxed scalars also expose all methods from the underlying primitive's prototype
(e.g. .toUpperCase(), .startsWith(), .toFixed(), .length, index access).
Type coercion
Number(), String(), arithmetic, template literals, and == comparison
all work on boxed scalars via Symbol.toPrimitive:
Number(proxy.score) // 42
String(proxy.name) // 'Alice'
proxy.score + 1 // 43
`hello ${proxy.name}` // 'hello Alice'
proxy.score == 42 // true
Boolean() is the exception — in JavaScript, Boolean(anyObject) always
returns true. This is a language-level behavior, not a tosijs limitation
(Boolean(new Boolean(false)) is also true). For boolean checks, use
.value or .valueOf():
// ❌ Always true — Boolean() on any object
if (Boolean(proxy.flag)) ...
// ✅ Correct
if (proxy.flag.value) ...
if (proxy.flag.valueOf()) ...
Arrays also have:
.listFind(selector, value)finds an item by field and returns it proxied.listFind(element)finds the array item bound to a DOM element.listUpdate(selector, newValue)updates an existing item in place or pushes if not found.listRemove(selector, value)removes an item by field match
Note: The
xinValue,xinPath,xinObserve,xinBind,xinOn, andtosiValue,tosiPath, etc. names still work but are deprecated. Use.tosi.*instead.
.take() — Reactive Binding Transforms
.take() creates a reactive binding descriptor that transforms values before
they reach the DOM. It eliminates most custom bindings.
Single-path transform
import { tosi, elements } from 'tosijs'
const { takeDemo } = tosi({ takeDemo: { count: 3, items: ['a', 'b', 'c'] } })
const { span, button } = elements
preview.append(
span({ bindText: takeDemo.count.tosi.take(n => `Count: ${n}`) }),
button('Delete', {
bindEnabled: takeDemo.items.tosi.take(list => list.length > 0),
})
)
Multi-path transform
Pass additional proxies before the transform function. The transform receives all current values when any of the watched paths change.
import { tosi, elements } from 'tosijs'
const { takeMultiDemo } = tosi({
takeMultiDemo: { firstName: 'Alice', lastName: 'Smith' }
})
const { span, input, label } = elements
preview.append(
span({ bindText: takeMultiDemo.firstName.tosi.take(
takeMultiDemo.lastName,
(first, last) => `${first} ${last}`
) }),
label('First', input({ bindValue: takeMultiDemo.firstName })),
label('Last', input({ bindValue: takeMultiDemo.lastName })),
)
.take() is efficient: the transform only runs when the input values actually
change (compared by identity). If an observer fires but the value is the same,
the transform is skipped entirely.
Filtered list with .take()
.take() works with list bindings to create reactive filtered views. The filter
re-evaluates when any of the watched paths change, but the list binding still
does surgical DOM updates.
import { elements, tosi, touch } from 'tosijs'
const { takeFilterDemo } = tosi({
takeFilterDemo: {
search: '',
items: [
{ id: 1, name: 'Alice', role: 'engineer' },
{ id: 2, name: 'Bob', role: 'designer' },
{ id: 3, name: 'Carol', role: 'engineer' },
{ id: 4, name: 'Dave', role: 'manager' },
{ id: 5, name: 'Eve', role: 'designer' },
]
}
})
const { div, input, label, ul } = elements
preview.append(
div(
{ style: { display: 'flex', flexDirection: 'column', gap: 10, padding: 10 } },
label('Filter', input({
placeholder: 'type to filter...',
bindValue: takeFilterDemo.search,
})),
div({
bindText: takeFilterDemo.items.tosi.take(
takeFilterDemo.search,
(items, search) => {
const s = search.toLowerCase()
const count = s ? items.filter(i => i.name.toLowerCase().includes(s) || i.role.includes(s)).length : items.length
return `Showing ${count} of ${items.length}`
}
),
style: { fontStyle: 'italic', opacity: 0.7 },
}),
ul(
...takeFilterDemo.items.tosi.listBinding(
({li, span}, item) => li(
span({ bindText: item.name, style: { fontWeight: 'bold' } }),
' — ',
span({ bindText: item.role }),
),
{
idPath: 'id',
filter: (items, needle) => needle
? items.filter(i => i.name.toLowerCase().includes(needle) || i.role.includes(needle))
: items,
needle: takeFilterDemo.search.tosi.take(s => s.toLowerCase()),
}
)
)
)
)
List Operations
When working with list-bound arrays, you often need to find, update, or remove
items efficiently. The listFind, listUpdate, and listRemove methods on
proxied arrays handle this with the same selector pattern used by listBinding.
Selectors
All three methods use a selector callback to identify which field to match on.
The callback receives a placeholder proxy (the same ^ proxy trick used by
listBinding) and should return a property of the item:
(item) => item.id // match on the 'id' field
(item) => item.uid // match on 'uid'
(item) => item.meta.key // nested field paths work too
listFind(selector, value) / listFind(element)
Find an item and return it as a proxied object (so mutations trigger observers):
const item = app.items.listFind((item) => item.id, 'abc')
if (item) {
item.name.value = 'Updated' // triggers observers + DOM updates
}
You can also pass a DOM element to find the array item bound to it — useful in click handlers on list-bound elements:
container.addEventListener('click', (e) => {
const item = app.items.listFind(e.target)
if (item) console.log('Clicked:', item.name.value)
})
listUpdate(selector, newValue)
Upsert: update an existing item in place or push a new one. This is the
recommended way to update list items because it preserves object identity —
the itemToElement WeakMap still maps to the same DOM element, so no
teardown/recreation occurs:
// Update existing — only changed properties fire observers
app.items.listUpdate((item) => item.id, {
id: 'abc', name: 'New Name', score: 100
})
// Item not found — pushes as new
app.items.listUpdate((item) => item.id, {
id: 'xyz', name: 'Brand New'
})
Returns the proxied item (existing or newly pushed).
listRemove(selector, value)
Remove an item by field match. Returns true if removed, false if not found:
app.items.listRemove((item) => item.id, 'abc') // true if found
To Do List Example
Each of the features described thus far, along with the features of the
elementCreator functions provided by the elements proxy
are designed to eliminate boilerplate, simplify your code, and reduce
the chance of making costly errors.
This example puts all of this together.
import { elements, tosi } from 'tosijs'
const { todos } = tosi({
todos: {
list: [],
newItem: ''
}
})
const { h3, div, label, input, button, template } = elements
const addItem = () => {
todos.list.push({
description: todos.newItem.value
})
todos.newItem.value = ''
}
preview.append(
h3('To do'),
div(
{
bindList: {
value: todos.list
}
},
template(
div({ bindText: '^.description' })
)
),
div(
input({
placeholder: 'task',
bindValue: todos.newItem,
onKeyup(event) {
if(event.key === 'Enter' && todos.newItem.value !== '') {
addItem()
}
}
}),
button('Add', {
bindEnabled: todos.newItem,
onClick: addItem
})
)
)