Library / SDK
fynyky/reactor.js avatar
fynyky/reactor.js

reactorjs adds reactive variables to plain JavaScript with two primitives

Simple reactive programming without a framework

804 stars52 forksJavaScriptMIT

At a glance

What is it?
reactorjs is a small MIT licensed library with exactly two ideas: Reactor objects that hold reactive state, and Observer functions that register themselves automatically when they read that state. It ships as a single ES module with one dependency, and it deliberately imposes no framework, no syntax and no project structure.
Who is it for?
Frontend developers who want reactivity in existing code without adopting a framework should try reactorjs, because it replaces a variable with a reactor and nothing else, and the dependency tracking works per property rather than invalidating whole views.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 41 days ago.
What is it written in?
Mainly JavaScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Two primitives and no framework

reactor.js is described as a simple library for reactive programming, and it provides exactly two things: Reactor objects that store reactive variables, and Observer functions that automatically track the reactive variables they use and retrigger when any of these variables are updated.

The quick example in the README is the whole API in four lines:

javascript
const reactor = new Reactor()
reactor.foo = 'bar'
const observer = new Observer(() => {
  console.log('foo is ', reactor.foo)
})
observer() // prints "foo is bar"
reactor.foo = 'moo' // prints "foo is moo"

There is no template language, no virtual DOM, no component base class and no scheduler. The design goal is stated as unobtrusive and unopinionated, and the three supporting points are specific. There is no special syntax to learn, everything is just plain JavaScript. There is no need to manually declare listeners or bindings, because the library keeps track of that itself. And it imposes no particular structure on your code, since any variable can be replaced with a reactive one without changing the rest of the codebase.

That last point is the one that decides whether the library is interesting to you. Most reactive libraries want to own the shape of your application. This one wants to be dropped into a codebase that already exists, where a variable becomes an object and a function that reads it becomes an observer.

The package is published on npm as reactorjs, is MIT licensed, and the repository is small enough to read in one sitting: an index.js, a package.json, a test directory and the README.

Reactors are Proxy objects, and the Proxy has edges

Reactors are implemented using Proxy objects, and the README is refreshingly honest about what that costs.

The first consequence is a typing surprise. Reactors created from scratch typecheck as Reactors, but reactors created from an existing object typecheck as the original object:

javascript
const baseReactor = new Reactor()
baseReactor instanceof Reactor // true
const mapReactor = new Reactor(new Map())
mapReactor instanceof Reactor // false
mapReactor instanceof Map // true

So wrapping an object gives you a proxy that passes instanceof checks for the wrapped type rather than for Reactor. Code that branches on the type of a wrapped value will see the original type, which is occasionally what you want and occasionally a surprise.

The second consequence is harder. Because proxies cannot access native or private properties, some methods will fail. The README's example is a Map:

javascript
// Native object example
const mapReactor = new Reactor(new Map())
Map.prototype.keys.apply(mapReactor) // throws an error
Map.prototype.keys.apply(shuck(mapReactor)) // works fine

The escape hatch is the shuck function, which returns a reactor's internal object. That is a useful thing to know about, and also a sign of the shape of the problem: any library method that reads internal slots, and every private class field, has to be called against the raw object.

This is the main cost of the design, and it is worth pricing in before you wrap anything that came from elsewhere. A plain configuration object is a safe target. A class instance with private fields is not.

Tracking is per property, so an unrelated write changes nothing

The dependency tracking is property-specific, and that is what makes the library cheap in practice.

The README demonstrates it with two observers watching two properties of the same reactor:

javascript
const reactor = new Reactor({
  foo: "bar",
  moo: "mar"
})

new Observer(() => {
  console.log("foo tracker is now", reactor.foo)
})() // prints "foo tracker is now bar"

reactor.foo = "bar2" // prints "foo tracker is now bar2"
reactor.moo = "mar2" // prints "moo tracker is now mar2"
reactor.goo = "goop" // does not trigger any observers

Adding a brand new property triggers nothing, and updating one property only wakes the observers that read it. In a system built on a coarser model, where a change to one field marks a whole view dirty, that difference is the difference between work proportional to what changed and work proportional to what could have changed.

The dependencies are also dynamic rather than fixed. Only the dependencies actually read in the last execution of an observer can trigger it again. The README's conditional example makes the consequence clear: an observer that reads reactor.b when reactor.a is true does not wake when reactor.c changes, because in its last run it never touched c.

That is the behaviour you want in a conditional interface, and it is also the behaviour that requires thought. An observer that reads different properties on different runs changes what it subscribes to each time, so a branch that is not taken leaves no trace and no trigger. It is a real design constraint rather than a bug, but it is the kind of thing to know about before you build a chain of observers on top of it.

Nested objects are wrapped on read, so deep tracking works

If reading a reactor's property returns an object, that object is recursively wrapped in a reactor before being returned. That single rule is what makes tracking work through nested structures.

javascript
const reactor = new Reactor({
  outer: {
    inner: "cake"
  }
})

new Observer(() => {
  console.log("inner value is ", reactor.outer.inner)
})() // prints "inner value is cake"

The observer reads reactor.outer, receives a wrapped copy of the nested object, and reads .inner on that wrapper. The dependency is recorded against the inner property, and the rule applies again at any depth.

The alternative would have been for you to wrap nested objects yourself, which is exactly the boilerplate the library claims to remove. Doing it on read means the wrapping cost is paid only for the branches an observer actually touches, which is a good match for how the tracking works.

The trade-off is that the wrapped object is not the object you put in. Reading reactor.outer gives you a proxy, not the original plain object, and identity comparisons against the original value will not hold. Combined with the instanceof behaviour and the shuck escape hatch for native methods, the practical rule is that reactors are for the state you own, and the edges of the system are where you will meet the Proxy.

Observers are plain functions that start when you call them

An Observer is like a normal function that you can define and call. When it reads from a Reactor, it automatically tracks that dependency, and when that reactor's property is updated, it automatically triggers the observer again.

The constructor takes a function:

javascript
const observer = new Observer(() => {
  console.log("hello world")
})
observer() // prints "hello world" and starts the observer

The comment on that last line is the important part: calling the observer starts it. For brevity the same thing can be written in one expression, and the README's own comment again says it starts the observer:

javascript
new Observer(() => {
  console.log("hello world")
})() // prints "hello world" and starts the observer

That is a small API decision with a large consequence. The tracking scope is the execution of the observer function, so the observer is a tracked region of code rather than a callback you register somewhere. Reading a reactor inside that function registers a dependency; changing the property re-runs the function.

The module's public surface is visible in the import statement the README recommends:

javascript
import { Reactor, Observer, hide, batch, shuck } from 'reactorjs'

Reactor, Observer and shuck are documented in the visible part of the README. hide and batch are exported but the visible excerpt does not explain them, so what they do has to be confirmed in the source or the issue tracker rather than assumed from their names.

Install path: one npm package, one ES module, one dependency

Installation is a single npm command:

bash
$ npm install reactorjs

and the import is an ES module import, which the package metadata confirms is the right form to use, since the package declares type as module and index.js as its main entry point with a files list containing only index.js.

The package is also usable directly from unpkg, and the README gives the import form for that:

javascript
import { Reactor, Observer, hide, batch, shuck } from 'https://unpkg.com/reactorjs'

The dependency list is remarkably short. There is one runtime dependency, weak-ref-collections, which points at how the library manages the observer collections behind the scenes. The only development dependency is standard, and the scripts section shows what that means in practice: lint runs standard with fix, test runs standard and then node --test test/test.js, and prepublishOnly runs the test script before publishing.

Using the built-in node test runner rather than a test framework is consistent with the whole approach: a library with one file and one dependency, tested with the tooling that ships with the runtime. The repository carries a .devcontainer directory and a .claude directory alongside the test directory, and the author field names Hongyi Li.

The consequence for an adopter is that there is very little to install and very little to configure. The consequence for a maintainer is that the whole library is one file, index.js, which you can read before you depend on it.

Where reactor.js is the wrong tool

The limits follow from having two primitives and nothing else.

There is no scheduler, no batching of updates beyond what the undocumented batch export provides, no error handling policy for observers that throw, and no built-in way to unsubscribe. The README's visible text does not describe how to stop an observer, and an observer that keeps running holds references that keep the reactor graph alive.

There is also no devtools integration and no time-travel debugging, because there is no application framework to attach to. If your problem is reactivity inside a framework you already use, this library is unlikely to help. If your problem is a piece of state that several parts of a legacy page need to agree on, it is exactly the right size.

The Proxy caveats from earlier are the second limit. Wrapping native objects means some methods fail and need shuck, and private class fields are inaccessible. That rules out wrapping a lot of third-party objects, which narrows the practical surface even though the API is unrestricted.

On versions, there is a discrepancy worth resolving before you install. The newest tagged release in the repository is v2.1.1 from 2023-07-28, while the package.json in the repository declares version 3.1.1 and the description reads simple reactive programming without frameworks, and the README calls the package reactorjs. So the npm version and the repository tag do not obviously match, and which one you get from npm is worth confirming, since hide and batch are exported in the README's import example without being explained in the visible documentation.

The last push was on 2026-09-13 and the repository is not archived, so the code is being worked on. The licence is MIT, with LICENSE.md at the root, which means the single file is free for commercial use and for modification.

Editorial conclusion

Frontend developers who want reactivity in existing code without adopting a framework should try reactorjs, because it replaces a variable with a reactor and nothing else, and the dependency tracking works per property rather than invalidating whole views. It is the wrong tool if you need a scheduler, a devtools timeline or a component model, since the library has two primitives and nothing else, and it is the wrong tool for code that runs native methods against wrapped objects without the shuck escape hatch. Before adopting, check the version you actually get, because the package.json in the repository declares 3.1.1 while the newest tagged release in the repository is v2.1.1 from 2023-07-28, and read the Proxy caveats before wrapping class instances.

Frequently asked questions

How do I install reactorjs and import it?

Run npm install reactorjs, then import the primitives with import { Reactor, Observer, hide, batch, shuck } from 'reactorjs'. The package is an ES module with index.js as its single published file, and it can also be imported directly from unpkg.

Do I have to call an Observer for it to start tracking?

Yes. The README's examples note that calling the observer prints the output and starts the observer, whether you call a stored observer or invoke the constructor result immediately. Reading a Reactor inside that function is what registers the dependency.

What is the shuck function for?

Reactors are implemented with Proxy objects, and proxies cannot access native or private properties, so some methods fail. The shuck function returns a reactor's internal object, which is how the README shows calling Map.prototype.keys against a wrapped Map successfully.

Does changing an unrelated property trigger every observer?

No. Tracking is property-specific, so an observer only wakes when a property it actually read is updated, and dependencies are determined dynamically from the last execution. The README shows that adding a new property triggers no observers at all.

Official sources

  1. fynyky/reactor.js on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/fynyky-reactor-js.svg)](https://hysenlabs.com/projects/fynyky-reactor-js)

Was this useful?