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.