react-tosijs
v1.2.1
Published
Insanely simple state management for React — and an off-ramp from React. Take your pick. tosijs state as a useState-shaped hook, plus web-component interop for incremental migration
Maintainers
Readme
react-tosijs
github | npm | tosijs | discord
Insanely simple state management for React — and an off-ramp from React. Take your pick.
If you just want the state management: useTosi has useState's shape and works way
better — there are no implicit execution zones (state isn't trapped in component
lifecycles, closure captures, or hook rules), and you can talk to state from outside
the React tree: a timer, a socket handler, the browser console, code that's never heard
of React. No reducers, no providers, no prop-drilling. If you want the off-ramp
(one that otherwise basically doesn't exist): most "React state management" libraries
deepen your commitment to React, but here your state lives in tosijs
— plain observable objects with no framework attached — and React becomes just one way
of looking at it. Build new functionality on a vastly superior state management system,
and migrate away from React as fast or as slowly as makes sense.
It provides two things:
useTosi— auseState-shaped hook bound to a tosijs path. State can be created, read, and mutated outside React (vanilla JS, web components, the browser console) and React views just follow. Built onuseSyncExternalStore, so it's concurrent-rendering safe.reactWebComponents— a proxy that turns any custom element into a React component (reactWebComponents.fooBarrenders<foo-bar>), so web components and React components can coexist on the same page, bound to the same state.
The off-ramp, step by step
- Move state and logic out of components into a tosijs proxy. This works inside your existing React 18/19 app — no rewrite, no adapters.
- Components become pure views via
useTosi. Reducers, context plumbing, and prop-drilling simply stop being necessary. - Build new UI as web components (tosijs-ui, or your own via
tosijs
Component), rendered inside React withreactWebComponents. They bind to the same paths as your React views — both stay in sync automatically, because neither owns the state. - Replace remaining React views at your own pace. When the last one goes, delete
reactandreact-tosijsfrom package.json. Your state, logic, and new components are untouched — they never depended on React in the first place.
There is no step where you maintain two sources of truth, write a sync layer, or do a big-bang rewrite.
Worth noting: tosijs does change detection at the DOM-update seam (an unchanged bound value is a no-op at write time), while React requires answering "did it change?" at the subscription source. The bridge's internal machinery exists to pay React's toll on React's behalf — deleting React deletes the toll.
useTosi in two minutes
Pass any object to xinProxy, then access it exactly like you would via useState
except using useTosi('path.to.value'). E.g.
import { xinProxy } from 'tosijs'
import { useTosi } from 'react-tosijs'
const { clock } = xinProxy({
clock: {
time: new Date().toLocaleTimeString(),
},
})
setInterval(() => {
clock.time = new Date().toLocaleTimeString()
}, 1000)
const Clock = () => {
const [time] = useTosi('clock.time')
return <div>{time}</div>
}Note that useTosi returns [value, setValue] just as useState does — but here the
state is updated outside React and it just works. (You could write a more complex
self-contained <Clock> that sets up and tears down its own interval, but nothing about
that would be less code or faster.)
One difference from useState: setValue takes the next value, not an updater
function — setCount(c => c + 1) would store the function itself (tosijs state
legitimately holds functions, so they are never auto-invoked).
Todo List Example
Here's the good old React "to do list" example rewritten with xin
and only pure components.
- Fewer lines of code,
- Clean separation between logic and presentation,
- Better behavior, and
- Cleaner screen redraws (thanks to pure components)
Better, faster, cheaper. You can have all three.
import { xinProxy } from 'tosijs'
import { useTosi } from 'react-tosijs'
const { app } = xinProxy({
app: {
itemText: '',
todos: [],
addItem(event) {
event.preventDefault() // forms reload the page by default!
if (!app.itemText) return
app.todos.push({
id: crypto.randomUUID(),
text: app.itemText,
})
app.itemText = ''
},
},
})
const Editor = () => {
const [itemText, setItemText] = useTosi('app.itemText')
return (
<form onSubmit={app.addItem}>
<input value={itemText} onInput={(event) => setItemText(event.target.value)} />
<button disabled={!itemText} onClick={app.addItem}>
Add Item
</button>
</form>
)
}
const List = () => {
const [todos] = useTosi('app.todos')
return (
<ul>
{todos.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
)
}
const TodoApp = () => (
<div className="TodoApp" role="main">
<h1>To Do</h1>
<List />
<Editor />
</div>
)
root.render(<TodoApp />)Notice app is a plain observable object: addItem is testable without rendering
anything, the console can poke app.todos directly, and if <List /> were replaced
with a web component tomorrow, app wouldn't change at all. That's the off-ramp.
reactWebComponents
reactWebComponents.fooBar gives you a React functional component that renders
<foo-bar> custom elements, so you can use web components (e.g. from
tosijs-ui) in React without writing wrappers:
import 'tosijs-ui'
import { reactWebComponents } from 'react-tosijs'
const Markdown = reactWebComponents.xinMd
const Doc = () => <Markdown class="doc" src="/README.md" />Note: on React 18, pass
class(notclassName) to web components — React 18 sets props on custom elements as attributes verbatim. React 19 handlesclassNameon custom elements natively.
Typed paths
Paths are serializable, loggable, and framework-free — and with a state shape they're also compile-time checked:
import { typedTosi } from 'react-tosijs'
type AppState = {
app: { count: number; todos: { id: string; text: string }[] }
}
const { useTosi } = typedTosi<AppState>()
const [text] = useTosi('app.todos[0].text') // text: string
const [oops] = useTosi('app.cuont') // compile errorTosiPath<S> and TosiPathValue<S, P> are exported for building your own typed helpers.
Persistence
persist hydrates a path from storage and writes it back on every change. It's
framework-free — it works identically whether the path is rendered by React, web
components, or nothing at all:
import { persist } from 'react-tosijs'
const stop = persist('app.todos') // localStorage, key "tosijs:app.todos"Writes are coalesced (one serialize + write per change flush). Durable state outlives
code: if the shape of a persisted value changes between releases, bump the key.
Redux DevTools
connectDevTools streams path touches to the Redux DevTools extension — each touched
path becomes an action (the path string is the action type) with a raw-value snapshot
of the roots you name:
import { connectDevTools } from 'react-tosijs'
const disconnect = connectDevTools({ roots: ['app'] })It's a debugging tap, not a time-travel store; it no-ops when the extension is absent.
Compatibility
- React
^18.2.0 || ^19.0.0(the hook is built onuseSyncExternalStore). - tosijs
^1.0.6— the library usestosiPathwhen available (tosijs ≥ 1.1) and falls back toxinPathon older versions. - SSR: tosijs needs DOM globals to load, so server rendering means a DOM-shimmed
pipeline (happy-dom, jsdom) —
renderToStringworks there.
Development
bun start— build, watch, and serve the demo at http://localhost:8016bun run build— one-shot build ofdist/(library) anddocs/(demo site)bun test— run the test suite
