Skip to content

A lightweight reactive state library for Node.js. Brings signals to the server, enabling reactive state management with automatic dependency tracking and efficient updates for server-side applications.

License

Notifications You must be signed in to change notification settings

nerdalytics/beacon

Repository files navigation

Beacon A stylized lighthouse beacon with golden light against a dark blue background, representing the reactive state library

Lightweight reactive state management for Node.js backends

license:mit registry:npm:version

tech:nodejs language:typescript linter:biome

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

Features

  • đź“¶ 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+

Quick Start

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"

Core Concepts

Beacon is built around three core primitives:

  1. States: Mutable, reactive values
  2. Derived States: Read-only computed values that update automatically
  3. 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.

API Reference

Version Compatibility

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

Core Primitives

state<T>(initialValue: T, equalityFn?: (a: T, b: T) => boolean): State<T>

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 });

derive<T>(fn: () => T): ReadOnlyState<T>

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"

effect(fn: () => void): () => void

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

batch<T>(fn: () => T): T

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' }
}));

lens<T, K>(source: State<T>, accessor: (state: T) => K): State<K>

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

Access Control

Control who can read vs. write to your state.

readonlyState<T>(state: State<T>): ReadOnlyState<T>

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

Advanced Features

Beacon includes several advanced capabilities that help you build robust applications.

Infinite Loop Protection

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

Automatic Cleanup

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

Custom Equality Functions

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`);
});

Design Philosophy

Beacon follows these key principles:

  1. Simplicity: Minimal API surface with powerful primitives
  2. Fine-grained reactivity: Track dependencies at exactly the right level
  3. Predictability: State changes flow predictably through the system
  4. Performance: Optimize for server workloads and memory efficiency
  5. Type safety: Full TypeScript support with generics

Architecture

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

Dependency Tracking

When a state is read inside an effect, Beacon automatically records this dependency relationship and sets up a subscription.

Infinite Loop Prevention

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
});

Cyclic Dependencies

Beacon employs two complementary strategies for handling cyclical updates:

  1. 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.
  2. 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.

Development

# 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

FAQ

Why "Beacon" Instead of "Signal"?

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.

How does Beacon handle memory management?

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.

Can I use Beacon with Express or other frameworks?

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

Can Beacon be used in browser applications?

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.

License

This project is licensed under the MIT License. See the LICENSE file for details.

About

A lightweight reactive state library for Node.js. Brings signals to the server, enabling reactive state management with automatic dependency tracking and efficient updates for server-side applications.

Topics

Resources

License

Code of conduct

Stars

Watchers

Forks

Packages

No packages published