Skip to main content
Version: v1.0

Reactivity Model

Overview

use-less-react implements a reactivity model based on subscriptions and notifications. Reactive values notify their subscribers when they change, allowing React (and potentially other systems) to react to those changes.

Core architecture

AbstractReactiveValue: the building block

AbstractReactiveValue is the abstract base class for all reactive values. It wraps a value and provides:

  • Storage: Holds the current value
  • Subscription: A notification system for changes
  • Change Detection: Detects when the value actually changes

Note: ReactiveValue is a concrete implementation of AbstractReactiveValue for primitive values. All reactive classes (ReactiveObject, ReactiveArray, ReactiveSet, ReactiveMap, ComputedValue, ReactiveReference) extend AbstractReactiveValue directly.

const count = new ReactiveValue(0);

// Subscribe to changes
const unsubscribe = count.subscribe((newValue, prevValue) => {
console.log(`Changed from ${prevValue} to ${newValue}`);
});

// Modify the value (triggers notification)
count.set(10); // Log: "Changed from 0 to 10"

Note: you may never need to use subscribe explicitly in a React app, as subscriptions are managed under the hood. This is only to explain the core mechanism use-less-react is built upon.

Types of reactive values

1. ReactiveValue (primitives)

For primitive values: number, string, boolean, null, undefined.

const count = new ReactiveValue(0);
const name = new ReactiveValue("John");
const isActive = new ReactiveValue(true);
const userId = new ReactiveValue<string | null>(null);

2. ReactiveObject (objects)

For plain objects. Uses Immer by default for immutable updates, but you can opt-out and handle this manually if you need.

const user = new ReactiveObject({
name: "John",
age: 30,
});

// With Immer (default)
user.set((draft) => {
draft.age = 31;
});

// Without Immer
const user = new ReactiveObject(
{
name: "John",
age: 30,
},
{
useImmer: false,
},
);

user.set((current) => ({ ...current, age: 31 }));

3. ReactiveArray (arrays)

For arrays of values.

// with Immer
const items = new ReactiveArray([1, 2, 3]);
items.set((draft) => {
draft.push(4);
});

// without Immer
const items = new ReactiveArray([1, 2, 3], { useImmer: false });
items.set((current) => [...current, 4]);

4. ReactiveSet / ReactiveMap (collections)

For Set and Map.

const tags = new ReactiveSet(["admin", "user"]);
const metadata = new ReactiveMap([["key", "value"]]);

5. ComputedValue (derived values)

Values automatically calculated from other reactive values.

const count = new ReactiveValue(10);
const double = new ComputedValue(() => count.get() * 2, [count]);

double.get(); // 20
count.set(15);
double.get(); // 30 (recomputed automatically)

6. ReactiveReference (references)

A reference to another reactive value (any AbstractReactiveValue). Allows sharing values between different scopes.

const source = new ReactiveValue(0);
const ref = new ReactiveReference(source);

// ref.get() always returns source.get()
source.set(10);
ref.get(); // 10

ReactiveStore: coordinating values

ReactiveStore is a container that coordinates multiple reactive value instances and provides:

  • Batching: groups multiple notifications together
  • Rollback: support for transactions with rollback
  • Subscription: subscribe to changes in specific keys
const store = new ReactiveStore({
count: new ReactiveValue(0),
name: new ReactiveValue("John"),
});

// Batching: all changes are notified together
await store.batchNotifications(({ count, name }) => {
count.set(10);
name.set("Jane");
// Single notification at the end
});

// Rollback: restores state on error
await store.rollbackBlock(({ count }) => {
count.set(20);
if (someCondition) {
throw new Error(); // Automatic rollback
}
});

Notification flow

1. Value modification

const count = new ReactiveValue(0);

// When you call set()
count.set(10);

2. Change detection

The system checks if the value actually changed using Object.is() for primitives or deep equality for objects (but you can define custom equality functions if you need - see more in the dedicated docs section).

// Internally
if (!Object.is(this._value, newValue)) {
// Value changed, proceed with notification
}

3. Notify subscribers

All registered subscribers are notified.

// Internally
this._subscribers.forEach((subscriber) => {
subscriber(newValue, prevValue);
});

4. Propagation

If the value is in a ReactiveStore, the notification is propagated to the store.

// The store notifies its subscribers
store.subscribe((changedKeys) => {
console.log("Changed keys:", changedKeys);
});

React integration

The connection between reactive values and React is handled by two hooks:

useReactiveValues

Hook to subscribe to multiple reactive value instances.

function Component() {
const { count, name } = useReactiveValues({
count: store.values.count,
name: store.values.name
});

// Component re-renders when count or name changes
return <div>{count} - {name}</div>;
}

useReactiveStoreValues

Hook to subscribe to specific keys from a ReactiveStore.

function Component() {
const values = useReactiveStoreValues(store, ["count", "name"]);

// Component re-renders when any subscribed value changes
return <div>{values.count} - {values.name}</div>;
}

Sharing instances with Context

To share instances across components, use createGenericContext with useDisposable:

import { createGenericContext, useDisposable } from '@dxbox/use-less-react/client';
import { AutoDispose } from '@dxbox/use-less-react/classes';

@AutoDispose
class UserManager implements Disposable {
count = new ReactiveValue(0);
// count is automatically detected and disposed (has Symbol.dispose)

[Symbol.dispose](): void {
// Custom cleanup logic if needed
}

increment() {
this.count.set(c => c + 1);
}
}

const [ManagerProvider, useManager] = createGenericContext<UserManager>();

function App() {
const manager = useDisposable(() => new UserManager());

return (
<ManagerProvider value={manager}>
<ChildComponent />
</ManagerProvider>
);
}

function ChildComponent() {
const manager = useManager();
return <button onClick={() => manager.increment()}>+</button>;
}

These hooks use React's useSyncExternalStore under the hood, which ensures proper synchronization and avoids tearing issues.

Batching and optimization

Automatic batching

Notifications are automatically grouped during batch operations.

await store.batchNotifications(({ count, name }) => {
count.set(10);
count.set(20);
name.set("Jane");
// Only one notification at the end with ["count", "name"]
});

ComputedValue caching

ComputedValue can use memoization to avoid unnecessary recalculations.

const expensive = new ComputedValue(
() => {
// Expensive computation
return heavyComputation();
},
[dependency],
{ memo: true }, // Cache the result
);

Lifecycle and cleanup

Subscription cleanup

Subscriptions should be cleaned up to avoid memory leaks. Use the cleanup function of useEffect to unsubscribe, if you're using subscribe inside a React Component.

const unsubscribe = value.subscribe(() => {});

// In React
useEffect(() => {
return unsubscribe; // Automatic cleanup
}, []);

The hooks (useReactiveValues, useReactiveStoreValues) handle cleanup automatically.

Store dispose

A ReactiveStore automatically manages cleanup of its values.

const store = new ReactiveStore({
/* ... */
});

// When no longer needed
store[Symbol.dispose](); // Cleans up all subscriptions

Type safety

All reactive types are fully typed.

// Automatic type inference
const count = new ReactiveValue(0); // ReactiveValue<number>
const user = new ReactiveObject({ name: "", age: 0 }); // ReactiveObject<{name: string, age: number}>

// Type safety in stores
const store = new ReactiveStore({
count: new ReactiveValue(0),
user: new ReactiveObject({ name: "", age: 0 }),
});

// TypeScript knows the types
const { count, user } = store.values;
count.set(10); // OK
user.set({ name: "John", age: 30 }); // OK
count.set("invalid"); // Type error

Summary

The reactivity model of use-less-react is:

  • Simple: direct subscriptions and notifications
  • Efficient: enables batching for optimizations
  • Type-safe: fully typed with TypeScript
  • Flexible: works with React but doesn't depend on it
  • Composable: reactive values can be combined in complex ways

The key insight is that reactivity doesn't need to be tied to a framework. By making reactive values framework-agnostic, you get code that works everywhere, with React integration as an optional layer on top.