Lightweight reactive state management for Node.js backends
A lightweight reactive state library for Node.js backends. Enables reactive state management with automatic dependency tracking and efficient updates for server-side applications.
Table of Contents
- đź“¶ Reactive state - Create reactive values that automatically track dependencies
- đź§® Computed values - Derive values from other states with automatic updates
- 🔍 Fine-grained reactivity - Dependencies are tracked precisely at the state level
- 🏎️ Efficient updates - Only recompute values when dependencies change
- 📦 Batched updates - Group multiple updates for performance
- 🎯 Targeted subscriptions - Select and subscribe to specific parts of state objects
- đź§ą Automatic cleanup - Effects and computations automatically clean up dependencies
- ♻️ Cycle handling - Safely manages cyclic dependencies without crashing
- 🚨 Infinite loop detection - Automatically detects and prevents infinite update loops
- 🛠️ TypeScript-first - Full TypeScript support with generics
- 🪶 Lightweight - Zero dependencies
- âś… Node.js compatibility - Works with Node.js LTS v20+ and v22+
npm install @nerdalytics/beacon --save-exact
import { state, derive, effect } from '@nerdalytics/beacon';
// Create reactive state
const count = state(0);
// Create a derived value
const doubled = derive(() => count() * 2);
// Set up an effect
effect(() => {
console.log(`Count: ${count()}, Doubled: ${doubled()}`);
});
// => "Count: 0, Doubled: 0"
// Update the state - effect runs automatically
count.set(5);
// => "Count: 5, Doubled: 10"
Beacon is built around three core primitives:
- States: Mutable, reactive values
- Derived States: Read-only computed values that update automatically
- Effects: Side effects that run automatically when dependencies change
The library handles all the dependency tracking and updates automatically, so you can focus on your business logic.
The table below tracks when features were introduced and when function signatures were changed.
API | Introduced | Last Updated | Notes |
---|---|---|---|
state |
v1.0.0 | v1000.2.0 | Added equalityFn parameter |
derive |
v1.0.0 | v1000.0.0 | Renamed derived → derive |
effect |
v1.0.0 | - | - |
batch |
v1.0.0 | - | - |
select |
v1000.0.0 | - | - |
lens |
v1000.1.0 | - | - |
readonlyState |
v1000.0.0 | - | - |
protectedState |
v1000.0.0 | v1000.2.0 | Added equalityFn parameter |
Since v1.0.0
The foundation of Beacon's reactivity system. Create with state()
and use like a function.
import { state } from '@nerdalytics/beacon';
const counter = state(0);
// Read current value
console.log(counter()); // => 0
// Update value
counter.set(5);
console.log(counter()); // => 5
// Update with a function
counter.update(n => n + 1);
console.log(counter()); // => 6
// With custom equality function
const deepCounter = state({ value: 0 }, (a, b) => {
// Deep equality check
return a.value === b.value;
});
// This won't trigger effects because values are deeply equal
deepCounter.set({ value: 0 });
Since v1.0.0
Calculate values based on other states. Updates automatically when dependencies change.
import { state, derive } from '@nerdalytics/beacon';
const firstName = state('John');
const lastName = state('Doe');
const fullName = derive(() => `${firstName()} ${lastName()}`);
console.log(fullName()); // => "John Doe"
firstName.set('Jane');
console.log(fullName()); // => "Jane Doe"
Since v1.0.0
Run side effects when reactive values change.
import { state, effect } from '@nerdalytics/beacon';
const user = state({ name: 'Alice', loggedIn: false });
const cleanup = effect(() => {
console.log(`User ${user().name} is ${user().loggedIn ? 'online' : 'offline'}`);
});
// => "User Alice is offline" (effect runs immediately when created)
user.update(u => ({ ...u, loggedIn: true }));
// => "User Alice is online"
// Stop the effect and clean up all subscriptions
cleanup();
Since v1.0.0
Group multiple updates to trigger effects only once.
import { state, effect, batch } from "@nerdalytics/beacon";
const count = state(0);
effect(() => {
console.log(`Count is ${count()}`);
});
// => "Count is 0" (effect runs immediately)
// Without batching, effects run after each update
count.set(1);
// => "Count is 1"
count.set(2);
// => "Count is 2"
// Batch updates (only triggers effects once at the end)
batch(() => {
count.set(10);
count.set(20);
count.set(30);
});
// => "Count is 30" (only once)
select<T, R>(source: ReadOnlyState<T>, selectorFn: (state: T) => R, equalityFn?: (a: R, b: R) => boolean): ReadOnlyState<R>
Since v1000.0.0
Subscribe to specific parts of a state object.
import { state, select, effect } from '@nerdalytics/beacon';
const user = state({
profile: { name: 'Alice' },
preferences: { theme: 'dark' }
});
// Only triggers when name changes
const nameState = select(user, u => u.profile.name);
effect(() => {
console.log(`Name: ${nameState()}`);
});
// => "Name: Alice"
// This triggers the effect
user.update(u => ({
...u,
profile: { ...u.profile, name: 'Bob' }
}));
// => "Name: Bob"
// This doesn't trigger the effect (theme changed, not name)
user.update(u => ({
...u,
preferences: { ...u.preferences, theme: 'light' }
}));
Since v1000.1.0
Two-way binding to deeply nested properties.
import { state, lens, effect } from "@nerdalytics/beacon";
const nested = state({
user: {
profile: {
settings: {
theme: "dark",
notifications: true
}
}
}
});
// Create a lens focused on a deeply nested property
const themeLens = lens(nested, n => n.user.profile.settings.theme);
// Read the focused value
console.log(themeLens()); // => "dark"
// Update the focused value directly (maintains referential integrity)
themeLens.set("light");
console.log(themeLens()); // => "light"
console.log(nested().user.profile.settings.theme); // => "light"
// The entire object is updated with proper referential integrity
// This makes it easy to detect changes throughout the object tree
Control who can read vs. write to your state.
Since v1000.0.0
Creates a read-only view of a state, hiding mutation methods. Useful when you want to expose state to other parts of your application without allowing direct mutations.
import { state, readonlyState } from "@nerdalytics/beacon";
const counter = state(0);
const readonlyCounter = readonlyState(counter);
// Reading works
console.log(readonlyCounter()); // => 0
// Updating the original state reflects in the readonly view
counter.set(5);
console.log(readonlyCounter()); // => 5
// This would cause a TypeScript error since readonlyCounter has no set method
// readonlyCounter.set(10); // Error: Property 'set' does not exist
protectedState<T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean): [ReadOnlyState<T>, WriteableState<T>]
Since v1000.0.0
Creates a state with separated read and write capabilities, returning a tuple of reader and writer. This pattern allows you to expose only the reading capability to consuming code while keeping the writing capability private.
import { protectedState } from "@nerdalytics/beacon";
// Create a state with separated read and write capabilities
const [getUser, setUser] = protectedState({ name: 'Alice' });
// Read the state
console.log(getUser()); // => { name: 'Alice' }
// Update the state
setUser.set({ name: 'Bob' });
console.log(getUser()); // => { name: 'Bob' }
// This is useful for exposing only read access to outside consumers
function createProtectedCounter() {
const [getCount, setCount] = protectedState(0);
return {
value: getCount,
increment: () => setCount.update(n => n + 1),
decrement: () => setCount.update(n => n - 1)
};
}
const counter = createProtectedCounter();
console.log(counter.value()); // => 0
counter.increment();
console.log(counter.value()); // => 1
Beacon includes several advanced capabilities that help you build robust applications.
Beacon prevents common mistakes that could cause infinite loops:
import { state, effect } from '@nerdalytics/beacon';
const counter = state(0);
// This would throw an error
effect(() => {
const value = counter();
counter.set(value + 1); // Error: Infinite loop detected!
});
// Instead, use proper patterns like:
const increment = () => counter.update(n => n + 1);
All subscriptions are automatically cleaned up when effects are unsubscribed:
import { state, effect } from '@nerdalytics/beacon';
const data = state({ loading: true, items: [] });
// Effect with nested effect
const cleanup = effect(() => {
if (data().loading) {
console.log('Loading...');
} else {
// This nested effect is automatically cleaned up when the parent is
effect(() => {
console.log(`${data().items.length} items loaded`);
});
}
});
// Unsubscribe cleans up everything, including nested effects
cleanup();
Control when subscribers are notified with custom equality checks. You can provide custom equality functions to state
, select
, and protectedState
:
import { state, select, effect, protectedState } from '@nerdalytics/beacon';
// Custom equality function for state
// Only trigger updates when references are different (useful for logging)
const logMessages = state([], (a, b) => a === b); // Reference equality
// Add logs - each call triggers effects even with identical content
logMessages.set(['System started']); // Triggers effects
logMessages.set(['System started']); // Triggers effects again
// Protected state with custom equality function
const [getConfig, setConfig] = protectedState({ theme: 'dark' }, (a, b) => {
// Only consider configs equal if all properties match
return a.theme === b.theme;
});
// Custom equality with select
const list = state([1, 2, 3]);
// Only notify when array length changes, not on content changes
const listLengthState = select(
list,
arr => arr.length,
(a, b) => a === b
);
effect(() => {
console.log(`List has ${listLengthState()} items`);
});
Beacon follows these key principles:
- Simplicity: Minimal API surface with powerful primitives
- Fine-grained reactivity: Track dependencies at exactly the right level
- Predictability: State changes flow predictably through the system
- Performance: Optimize for server workloads and memory efficiency
- Type safety: Full TypeScript support with generics
Beacon is built around a centralized reactivity system with fine-grained dependency tracking. Here's how it works:
- Automatic Dependency Collection: When a state is read inside an effect, Beacon automatically records this dependency
- WeakMap-based Tracking: Uses WeakMaps for automatic garbage collection
- Topological Updates: Updates flow through the dependency graph in the correct order
- Memory-Efficient: Designed for long-running Node.js processes
When a state is read inside an effect, Beacon automatically records this dependency relationship and sets up a subscription.
Beacon actively detects when an effect tries to update a state it depends on, preventing common infinite update cycles:
// This would throw: "Infinite loop detected"
effect(() => {
const value = counter();
counter.set(value + 1); // Error! Updating a state the effect depends on
});
Beacon employs two complementary strategies for handling cyclical updates:
- Active Detection: The system tracks which states an effect reads from and writes to. If an effect attempts to directly update a state it depends on, Beacon throws a clear error.
- Safe Cycles: For indirect cycles and safe update patterns, Beacon uses a queue-based update system that won't crash even with cyclical dependencies. When states form a cycle where values eventually stabilize, the system handles these updates efficiently without stack overflows.
# Install dependencies
npm install
# Run tests
npm test
Key Differences vs TC39 Proposal
Aspect | @nerdalytics/beacon | TC39 Proposal |
---|---|---|
API Style | Functional approach (state() , derive() ) |
Class-based design (Signal.State , Signal.Computed ) |
Reading/Writing Pattern | Function call for reading (count() ), methods for writing (count.set(5) ) |
Method-based access (get() /set() ) |
Framework Support | High-level abstractions like effect() and batch() |
Lower-level primitives (Signal.subtle.Watcher ) that frameworks build upon |
Advanced Features | Focused on core reactivity | Includes introspection capabilities, watched/unwatched callbacks, and Signal.subtle namespace |
Scope and Purpose | Practical Node.js use cases with minimal API surface | Standardization with robust interoperability between frameworks |
Beacon represents how the library broadcasts notifications when state changes—just like a lighthouse guides ships. The name avoids confusion with the TC39 proposal and similar libraries while accurately describing the core functionality.
Beacon uses WeakMaps for dependency tracking, ensuring that unused states and effects can be garbage collected. When you unsubscribe an effect, all its internal subscriptions are automatically cleaned up.
Yes! Beacon works well as a state management solution in any Node.js application:
import express from 'express';
import { state, effect } from '@nerdalytics/beacon';
const app = express();
const stats = state({ requests: 0, errors: 0 });
// Update stats on each request
app.use((req, res, next) => {
stats.update(s => ({ ...s, requests: s.requests + 1 }));
next();
});
// Log stats every minute
effect(() => {
console.log(`Stats: ${stats().requests} requests, ${stats().errors} errors`);
});
app.listen(3000);
While Beacon is optimized for Node.js server-side applications, its core principles would work in browser environments. However, the library is specifically designed for backend use cases and hasn't been optimized for browser bundle sizes or DOM integration patterns.
This project is licensed under the MIT License. See the LICENSE file for details.