Skip to content

d8corp/watch-state

Repository files navigation


watch-state logo by Mikhail Lysikov

watch-state

CANT inc. Reactive State Engine


watch-state fast
Fast
One of the fastest
watch-state Light
Light
Less than 1 KB gzip
watch-state smart
Smart
Steady architecture


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.

stars watchers

Browser Support

Desktop

Firefox Chrome Safari Opera Edge
45+ 49+ 9+ 36+ 13+

Mobile

Firefox Chrome Safari Opera
87+ 90+ 9+ 62+

You can transpile the code to support browsers older than listed above, but performance will decrease.

Index

[ Install ]
[ Usage ] Simple exampleExample Vanilla JSExample ReactExample Innet
[ Watch ] Force update of WatchDestroy WatchDeep/Nested Watchers
[ State ] Get or Set valueState.setForce update of StateRaw valueInitial valueReset value
[ Compute ] Lazy computationForce update of ComputeDestroy Compute
[ Utils ] onDestroycallEventcreateEventunwatch
[ Typescript ] State type inferenceCompute type inference
[ Performance ]

Install

🏠︎ / Install

npm

npm i watch-state

yarn

yarn add watch-state

html

<script src="https://cdn.jsdelivr.net/npm/watch-state"></script>

minified on GitHub

Usage

🏠︎ / Usage

Simple exampleExample Vanilla JSExample ReactExample 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  │
└─────────┘ └─────────┘ └─────────┘

Simple example

🏠︎ / Usage / Simple example

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: 2

Example Vanilla JS

🏠︎ / Usage / Example Vanilla JS

Simple 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>

Example React

🏠︎ / Usage / Example React

@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>
}

Example Innet

🏠︎ / Usage / Example Innet

@innet/dom automatically watches accessed states and updates only changed DOM contentno 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>
}

Watch

🏠︎ / Watch

Force update of WatchDestroy WatchDeep/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: 1

Force update of Watch

🏠︎ / Watch / Force update of Watch

You 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: 0

Destroy Watch

🏠︎ / Watch / Destroy Watch

You 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 happens

Deep/Nested Watchers

🏠︎ / Watch / Deep/Nested Watchers

Each 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)

State

🏠︎ / State

Get or Set valueState.setForce update of StateRaw valueInitial valueReset value

Reactive primitive that holds a value and automatically notifies all subscribers when it changes.

Get or Set value

🏠︎ / State / Get or Set value

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 1

State.set

🏠︎ / State / State.set

State.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: 1

Force update of State

🏠︎ / State / Force update of State

You 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 value

🏠︎ / State / Raw value

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, 1

Initial value

🏠︎ / State / Initial value

initial 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: true

Reset value

🏠︎ / State / Reset value

reset() 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)

Compute

🏠︎ / Compute

Lazy computationForce update of ComputeDestroy 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.

Lazy computation

🏠︎ / Compute / Lazy computation

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'

Force update of Compute

🏠︎ / Compute / Force update of Compute

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: 1

Destroy Compute

🏠︎ / Compute / Destroy Compute

You 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!

Utils

🏠︎ / Utils

onDestroycallEventcreateEventunwatch

onDestroy

🏠︎ / Utils / onDestroy

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 happens

callEvent

🏠︎ / Utils / callEvent

You 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, 2

callEvent 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: 0

createEvent

🏠︎ / Utils / createEvent

You 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: 2

unwatch

🏠︎ / Utils / unwatch

You 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

🏠︎ / Typescript

State type inference

🏠︎ / 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 expected

Without 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

Compute type inference

🏠︎ / 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 string

Destroyed 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.

Performance

🏠︎ / Performance

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 speed

Links

You can find more tools here

Issues

If you find a bug or have a suggestion, please file an issue on GitHub

issues

About

CANT inc. Reactive State Engine

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors