CANT inc. Reactive State Engine
watch-state is a lightweight, high-performance reactive state engine designed to power UI frameworks — or replace them.
- Fast — One of the fastest reactive libraries (see benchmarks)
- Light — Less than 1 KB minzip
- Zero-dependency — No external packages required
- Code splitting by design — Decentralized state architecture, each page loads only the states it uses
- Auto-subscription — Dependencies tracked automatically, no manual subscriptions
- Dynamic subscriptions — Conditional watchers auto-subscribe/unsubscribe based on reactive conditions
- Type-safe — Full TypeScript support with type inference
- Memory-safe — Automatic cleanup on destroy
- Lazy computation — Compute executes only when accessed
- No Proxy — Supports old browsers (Firefox 45+, Safari 9+)
- Framework-agnostic — Business logic lives outside components, reusable across any framework or vanilla JS
Use it as the core state layer in your own framework, embed it in React components, or build a full UI — no JSX, no virtual DOM, no framework required.
Born while working on @innet/dom.
| 45+ | 49+ | 9+ | 36+ | 13+ |
| 87+ | 90+ | 9+ | 62+ |
You can transpile the code to support browsers older than listed above, but performance will decrease.
[ Install ]
[ Usage ] Simple example • Example Vanilla JS • Example React • Example Innet
[ Watch ] Force update of Watch • Destroy Watch • Deep/Nested Watchers
[ State ] Get or Set value • State.set • Force update of State • Raw value • Initial value • Reset value
[ Compute ] Lazy computation • Force update of Compute • Destroy Compute
[ Utils ] onDestroy • callEvent • createEvent • unwatch
[ Typescript ] State type inference • Compute type inference
[ Performance ]
npm
npm i watch-stateyarn
yarn add watch-statehtml
<script src="https://cdn.jsdelivr.net/npm/watch-state"></script>Simple example • Example Vanilla JS • Example React • Example Innet
The library is based on the core concepts of Observable (something that can be observed) and Observer (something that can observe). On top of these concepts, the core classes State, Compute, and Watch are built according to the following scheme:
┌────────────┐ ┌─────────────┐
│ Observable │ │ Observer │
│ (abstract) │ │ (interface) │
└──────┬─────┘ └──────┬──────┘
┌────┴─────┐ ┌──────┴───┐
┌────┴────┐ ┌───┴─┴───┐ ┌────┴────┐
│ State │ │ Compute │ │ Watch │
└─────────┘ └─────────┘ └─────────┘
You can create an instance of State and watch its value.
import { Watch, State } from 'watch-state'
const count = new State(0)
new Watch(() => console.log(count.value))
// logs: 0
count.value++
// logs: 1
count.value++
// logs: 2Simple reactive state without build tools or framework dependencies.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Counter</title>
<script src="https://cdn.jsdelivr.net/npm/watch-state"></script>
<script type="module">
const { State, Watch } = WatchState
const count = new State(0)
const button = document.createElement('button');
document.body.appendChild(button);
new Watch(() => {
button.innerText = count.value
})
button.addEventListener('click', () => {
count.value++
})
</script>
</head>
<body>
</body>
</html>@watch-state/react provides hooks that automatically subscribe React components to state changes and re-renders only when needed.
import { State } from 'watch-state'
import { useObservable } from '@watch-state/react'
const $count = new State(0)
const increase = () => {
$count.value++
}
export function CountButton () {
const count = useObservable($count)
return <button onClick={increase}>{count}</button>
}@innet/dom automatically watches accessed states and updates only changed DOM content — no full re-renders.
import { State } from 'watch-state'
const count = new State(0)
const increase = () => {
count.value++
}
export function CountButton () {
return <button onClick={increase}>{count}</button>
}Force update of Watch • Destroy Watch • Deep/Nested watchers
Watch accepts a reaction as its first argument and executes it when any accessed state changes.
State accessed inside a reaction is auto-subscribed — no manual registration needed.
const state = new State(0)
const reaction = () => {
console.log(state.value)
// auto-subscribes to state
}
new Watch(reaction)
// logs: 0
state.value = 1 // triggers reaction
// logs: 1You can run a reaction even when its states are not updated.
const count = new State(0)
const watcher = new Watch(() => {
console.log(count.value)
})
// logs: 0
watcher.update()
// logs: 0You can stop watching by destroy method of Watch.
const count = new State(0)
const watcher = new Watch(() => {
console.log(count.value)
})
// logs: 0
count.value++
// logs: 1
watcher.destroy()
count.value++
// nothing happensEach Watch independently tracks only states accessed within its reaction.
Nested watchers created inside parent watchers form a dependency tree with separate reactivity.
const watching = new State(true)
const state = new State(0)
new Watch(() => {
console.log('Root Render')
if (watching.value) {
new Watch(() => {
console.log(`Deep Render: ${state.value}`)
})
}
})
// logs: Root Render, Deep Render: 0
state.value++
// logs: Deep Render: 1 (only deep watcher reacts)
watching.value = false
// logs: Root Render (deep watcher destroyed)
state.value++
// nothing happens (no active deep watcher)Get or Set value • State.set • Force update of State • Raw value • Initial value • Reset value
Reactive primitive that holds a value and automatically notifies all subscribers when it changes.
Reading .value inside reaction auto-subscribes to changes. Writing .value triggers all reactions.
const count = new State(0)
new Watch(() => console.log(count.value))
// auto-subscribes and logs 0
count.value++ // triggers: logs 1State.set mirrors the behavior of the value setter but returns void.
It is useful as a shorthand in arrow functions: () => state.set(nextValue) instead of () => { state.value = nextValue }.
Note: state.set cannot be used as a standalone function; const set = state.set is not supported.
const count = new State(0)
// Subscribing
new Watch(() => console.log(count.value))
// logs: 0
count.set(1)
// logs: 1You can run reactions of a state with update method.
// Create state
const log = new State<number[]>([])
// Subscribe to changes
new Watch(() => console.log(log.value)) // logs: []
// Modify the array
log.value.push(1) // no logs
log.value.push(2) // no logs
// Update value
log.update() // logs: [1, 2]raw returns the current value but does not subscribe to changes — unlike value.
const foo = new State(0)
const bar = new State(0)
new Watch(() => console.log(foo.value, bar.raw))
// logs: 0, 0
foo.value++ // logs: 1, 0
bar.value++ // no logs
foo.value++ // logs: 2, 1initial stores the initial value passed to the constructor.
Useful for checking if the state has been modified by comparing state.initial === state.raw.
const count = new State(0)
console.log(count.initial)
// logs: 0
count.value = 5
console.log(count.initial === count.raw)
// logs: false
count.reset()
console.log(count.initial === count.raw)
// logs: truereset() restores the state to its initial value.
Triggers watchers only if the current value differs from the initial value.
const count = new State(0)
new Watch(() => console.log(count.value))
// logs: 0
count.value = 5
// logs: 5
count.reset()
// logs: 0
count.reset()
// no logs (value already 0)Lazy computation • Force update of Compute • Destroy Compute
Compute accepts a reaction as its first argument and represents a reactive value returned by the reaction.
It creates a derived state that automatically tracks dependencies and caches the result.
Compute doesn't execute immediately — waits for .value access.
Dependencies (State.value reads inside reaction) auto-subscribe like Watch.
const name = new State('Foo')
const surname = new State('Bar')
const fullName = new Compute(() => (
`${name.value} ${surname.value[0]}` // auto-subscribes to name+surname
))
// NO COMPUTATION YET — lazy!
new Watch(() => {
console.log(fullName.value) // FIRST ACCESS → computes!
})
// logs: 'Foo B'
surname.value = 'Baz' // surname[0] still "B"
// nothing happens
surname.value = 'Quux' // surname[0] = "Q"
// logs: 'Foo Q'You can run a reaction of a compute with update method.
const items = new State([])
const itemCount = new Compute(() => {
console.log('Recomputing length...')
return items.value.length
})
new Watch(() => console.log('Watcher sees:', itemCount.value))
// logs: Recomputing length...
// logs: Watcher sees: 0
items.value.push('apple')
// Array reference SAME → NO recompute!
itemCount.update()
// logs: Recomputing length...
// logs: Watcher sees: 1You can stop watching by destroy method of Compute.
const user = new State({ name: 'Alice', age: 30 })
const userName = new Compute(() => {
console.log('Computing')
return user.value.name.toUpperCase()
})
new Watch(() => console.log(userName.value))
// logs: Computing
// logs: ALICE
user.value = { name: 'Mike', age: 32 }
// logs: Computing
// logs: MIKE
userName.destroy()
user.value = { name: 'Bob', age: 31 }
// nothing happens — fully disconnected!onDestroy • callEvent • createEvent • unwatch
You can subscribe on destroy or update of watcher
const count = new State(0)
const watcher = new Watch(() => {
console.log('count', count.value)
// the order does not matter
onDestroy(() => console.log('destructor'))
})
// logs: 'count', 0
count.value++
// logs: 'destructor'
// logs: 'count', 1
watcher.destroy()
// logs: 'destructor'
count.value++
// nothing happensYou can immediately execute a reactive effect with callEvent.
callEvent batches all state updates inside the callback and triggers watchers only once at the end.
const a = new State(0)
const b = new State(0)
new Watch(() => {
console.log(a.value, b.value)
})
// logs: 0, 0
a.value = 1
// logs: 1, 0
b.value = 1
// logs: 1, 1
callEvent(() => {
a.value = 2
b.value = 2
})
// logs: 2, 2callEvent returns exactly what your callback returns — TypeScript infers the correct type automatically.
const count = new State(0)
new Watch(() => console.log(count.value))
// logs: 0
const prev = callEvent(() => count.value++)
// logs: 1
console.log(prev)
// logs: 0You can create a reusable event function with createEvent.
Like callEvent, it batches state updates and triggers watchers only once after execution.
import { State, createEvent } from 'watch-state'
const count = new State(0)
const increase = createEvent(() => count.value++)
new Watch(() => console.log(count.value))
// logs: 0
increase()
// logs: 1
increase()
// logs: 2You can disable automatic state subscriptions with unwatch.
import { State, Watch, unwatch } from 'watch-state'
const count = new State(0)
new Watch(() => {
console.log(unwatch(() => count.value++))
})
// logs: 0
count.value++
// logs: 1
console.log(count.value)
// logs: 2🏠︎ / Typescript / State type inference ↓
Type inference from initial value:
Type is automatically inferred from the initial value passed to the constructor — no generic needed.
const count = new State(0) // State<number>
count.value = 'str' // error: number expectedWithout initial value:
When using a generic without an initial value, initial is undefined, which may conflict with strict types.
const value = new State<string>()
// value.initial is undefined (not string)
// To allow undefined in type:
const maybe = new State<string | undefined>()State as a type annotation:
Without a generic, State defaults to State<unknown>, which accepts any value type.
const foo: State = new State(0)
foo.value = 'str' // ok (unknown allows any)
foo.value = true // ok
// Specify generic for type safety:
const bar: State<number> = new State(0)
bar.value = 'str' // error🏠︎ / Typescript / Compute type inference ↑
Type inferred from function return:
Type is automatically inferred from the function's return value — no generic needed.
const fullName = new Compute(() => `${firstName.value} ${lastName.value}`)
// Compute<string> — no generic needed
const length = new Compute(() => items.value.length)
// Compute<number>Explicit generic (usually not needed):
Explicit generics are rarely needed since types are inferred. Use only when you want to enforce a specific type.
new Compute<string>(() => false) // error: boolean not assignable to stringDestroyed Compute and undefined:
Compute.value is typed as the function return type, but if you access .value after destroy() (before any computation ran), it returns undefined.
const computed = new Compute(() => expensiveCalculation())
computed.destroy()
console.log(computed.value) // undefined (but typed as return type)This is intentional — accessing destroyed observers is rare and shouldn't require undefined checks in normal code.
You can check a performance test with MobX, Effector, Storeon, Nano Stores, Mazzard and Redux. Clone the repo, install packages and run this command
npm run speedYou can find more tools here
If you find a bug or have a suggestion, please file an issue on GitHub