fluidstate
v1.0.0
Published
Library for fine-grained reactivity state management
Maintainers
Readme
fluidstate
View interactive documentation on the official website.
fluidstate is a JavaScript library for fine-grained, signals-based reactive state management. It provides a powerful and flexible way to create applications where data changes automatically propagate through your system, ensuring that your UI or other side-effects are always synchronized with the application state.
import { createReactive, createReaction, runAction } from "fluidstate";
// Turn your reactive objects into reactive proxies
const businessState = createReactive({
// Properties become reactive:
itemsMade: 100,
itemCost: 5,
itemsSold: 80,
itemPrice: 7,
// Getters become computed reactive properties, cached until
// their dependencies change:
get totalCost() {
return businessState.itemsMade * businessState.itemCost;
},
get totalRevenue() {
return businessState.itemsSold * businessState.itemPrice;
},
get profit() {
return businessState.totalRevenue - businessState.totalCost;
},
// Methods become batched actions that can mutate reactive data:
setItemsSold(itemsSold) {
businessState.itemsSold = itemsSold;
},
});
createReaction(() => {
console.log(`Total cost: $${businessState.totalCost}`);
});
// LOGS: Total cost: $500
createReaction(() => {
console.log(`Total revenue: $${businessState.totalRevenue}`);
});
// LOGS: Total revenue: $560
createReaction(() => {
console.log(`Total profit: $${businessState.profit}`);
});
// LOGS: Total profit: $60
// Use reactive methods (like `setItemsSold`) or `runAction` to mutate reactive data:
runAction(() => {
businessState.itemsSold = 81;
});
// LOGS: Total revenue: $567
// LOGS: Total profit: $67
// (does not log or recalculate total cost)Table of Contents
- What is Signals-Based Reactivity?
- What
fluidstateProvides - Getting Started
- Core Concepts & Usage
- API Documentation
- Further Reading
What is Signals-Based Reactivity?
Signals-based reactivity is a programming paradigm that simplifies state management by creating a declarative and automatic data flow. At its core, it involves:
- Reactive Values (Signals/Atoms): Pieces of state that, when changed, can notify dependents.
- Computed Values (Derived Signals): Values that are derived from other reactive values. They automatically update when their dependencies change and memoize their result.
- Reactions (Effects): Functions that run in response to changes in reactive values they depend on. These are typically used for side effects, like updating the DOM.
When a reactive value changes, the system automatically identifies and re-executes only the specific computations and reactions that depend on it. This fine-grained approach leads to efficient updates and makes it easier to reason about data flow. Libraries like SolidJS and MobX are prominent examples of this paradigm. fluidstate aims to provide a similar level of power and ergonomics, acting as a versatile wrapper that utilizes a foundational reactive layer (such as MobX).
What fluidstate Provides
fluidstate offers a beautiful and ergonomic API to supercharge your dynamic applications by adding deep fine-grained reactivity to your objects, arrays, Sets, and Maps. Work with your data naturally by wrapping your objects in createReactive, seamlessly transforming them: properties become reactive values, getters become efficiently memoized computed values, and methods - automatically batched actions.
This comprehensive toolkit lets you:
Make your objects reactive: Use
createReactiveto transform your objects into reactive proxies.React effortlessly to changes: Use
createReactionto subscribe to changes in your reactive objects via automatic dependency tracking.Handle async operations reactively: Use
getResultto turn Promises into trackable reactive state.Gain granular control: Create inert snapshots of reactive data with
cloneInert, control scheduling of side-effects by providing your ownscheduler, and customize the equality handling by specifyingequalsfunction.Integrate complex systems: Synchronize state across distinct reactive systems using "Reactive Remotes".
Extend functionality: Hook into reactive object lifecycle with a powerful plugin system for logging, validation, versioning or synchronization.
fluidstate simplifies state management, helping you build responsive and efficient applications where data flow is clear and automatic.
Getting Started
Installation
You can install fluidstate and a reactive layer provider (e.g., fluidstate-mobx or fluidstate-alien) using npm or yarn:
npm install fluidstate fluidstate-mobx
# or
yarn add fluidstate fluidstate-mobxAvailable Reactive Layers
fluidstate provides reactive functionality on top of a reactive layer of your choosing. The benefit of this approach is the flexibility to swap out one reactive layer for another at any point while keeping the convenience and ergonomics of the fluidstate reactivity API. You may choose one of the officially supported reactive layers, find an unofficial one, or create your own.
Official Reactive Layers:
fluidstate-alien- Uses Alien Signals, one of the fastest and most efficient reactivity system implementations.fluidstate-mobx- Uses MobX, one of the most popular, mature, and battle-tested libraries for signals-based reactivity.fluidstate-preact- Uses Preact Signals, a highly efficient reactive engine that powers fast and performant Preact applications.
Providing the Reactive Layer
Before using fluidstate, you must provide your chosen reactive layer by calling provideReactiveLayer. This is only done once at your application's entry point.
import { provideReactiveLayer } from "fluidstate";
import { getReactiveLayer } from "fluidstate-mobx"; // Or your chosen provider
// Note: for Preact Signals-based reactive layer, import as follows:
// import { getReactiveLayer } from "fluidstate-preact/reactive-layer";
// Get the reactive layer instance from the provider
const reactiveLayer = getReactiveLayer();
// Provide it to fluidstate
provideReactiveLayer(reactiveLayer);
// Now you can use fluidstate's API throughout your application:
// import { createReactive, createReaction } from "fluidstate";
// ...Integration with UI Libraries
To bridge the gap between reactive state management and popular UI libraries, we aims to provide official tools to easily connect the fluidstate-based reactive state with UI components. At the moment, fluidstate provides official integrations with:
fluidstate-react: Provides essential hooks, higher-order components (HOCs), and other utilities to connectfluidstateto your React components.fluidstate-preact: Offers a similar toolkit specifically designed for Preact, extended with efficient@preact/signalssupport, making it easy to build reactive and performant UIs.
These libraries handle the subscription and re-rendering logic, allowing your components to reactively update whenever the underlying fluidstate-based reactive data changes.
Core Concepts & Usage
Creating Reactive State: createReactive
The createReactive function is your primary tool for making data reactive. It deeply converts objects, arrays, Sets, and Maps.
import { createReactive, createReaction } from "fluidstate";
type UserProfile = {
firstName: string;
lastName: string;
hobbies: string[];
get fullName(): string;
addHobby(hobby: string): void;
updateName(firstName: string, lastName: string): void;
};
const user = createReactive<UserProfile>({
firstName: "Jane",
lastName: "Doe",
hobbies: ["coding", "reading"],
get fullName() {
// This getter becomes a memoized computed value.
// It only recalculates if firstName or lastName changes and it's being observed.
console.log("Calculating fullName...");
return `${this.firstName} ${this.lastName}`;
},
// Methods are automatically wrapped in actions.
// Changes within them are batched, and reactions run only once after completion.
addHobby(hobby: string) {
this.hobbies.push(hobby);
},
updateName(firstName: string, lastName: string) {
this.firstName = firstName; // Change 1
this.lastName = lastName; // Change 2
// Reactions depending on fullName or firstName/lastName will run once.
},
});
createReaction(() => {
console.log(`User: ${user.fullName}`);
});
// LOGS: Calculating fullName...
// LOGS: User: Jane Doe
createReaction(() => {
console.log(`Hobbies: ${user.hobbies.join(", ")}`);
});
// LOGS: Hobbies: coding, reading
user.addHobby("hiking");
// LOGS: Hobbies: coding, reading, hiking
// (fullName reaction doesn't re-run as its dependencies didn't change)
user.updateName("John", "Smith");
// LOGS: Calculating fullName...
// LOGS: User: John Smith
// (addHobby reaction doesn't re-run)- Deep reactivity: Objects, arrays, maps and sets nested within a reactive structure also become reactive.
- Getters become computed values:
user.fullNameis automatically a memoized computed value. - Methods become actions:
user.addHobbyanduser.updateNameare automatically wrapped in actions, batching their internal changes.
Reacting to Changes: createReaction
Reactions are side effects that run in response to state changes. createReaction automatically tracks dependencies accessed during its execution.
import { createReactive, createReaction, runAction } from "fluidstate";
const settings = createReactive({
theme: "dark",
fontSize: 14,
});
// This reaction runs immediately and whenever theme or fontSize changes.
const settingsReaction = createReaction(() => {
console.log(`Theme: ${settings.theme}, Font size: ${settings.fontSize}px`);
// You can perform any side effect here, like updating UI.
});
// LOGS: Theme: dark, Font size: 14px
runAction(() => {
settings.theme = "light";
});
// LOGS: Theme: light, Font size: 14px
// To stop a reaction and clean up its observers:
settingsReaction.stop();
runAction(() => {
settings.fontSize = 16; // This change is no longer observed by settingsReaction
});
// No new logs from settingsReaction.A reaction can specify one or more cleanup functions via createCleanup.
Important Note: Every reaction created MUST be explicitly stopped using its stop() method when it is no longer needed. Failure to do so can lead to memory leaks, especially if the reactive data the reaction depends on is also not garbage collected. Reactions hold references to their dependencies, and if a reaction is not stopped, it may prevent those dependencies (and potentially large parts of your application state) from being cleaned up by the garbage collector.
Computed Values (via Getters)
As seen in the createReactive example, getters on reactive objects automatically become memoized computed values. They efficiently recalculate only when their underlying reactive dependencies change and they are being observed by a reaction or another computed value.
const stats = createReactive({
valueA: 10,
valueB: 20,
get sum() {
console.log("Recalculating sum...");
return this.valueA + this.valueB;
},
});
createReaction(() => {
console.log(`Sum is: ${stats.sum}`);
});
// LOGS: Recalculating sum...
// LOGS: Sum is: 30
runAction(() => {
stats.valueA = 15;
});
// LOGS: Recalculating sum...
// LOGS: Sum is: 35
console.log(stats.sum); // Accessing outside an active reaction
// LOGS: Sum is: 35 (No "Recalculating sum..." because it's memoized and dependencies haven't changed since last calculation by an active reaction)
runAction(() => {
stats.valueB = 25;
});
// If the reaction is still active:
// LOGS: Recalculating sum...
// LOGS: Sum is: 40
// If the reaction observing `stats.sum` were stopped (or if `stats.sum` wasn't observed by any active reaction),
// `stats.sum` would still be accessible. It would then recalculate its value lazily (only upon access)
// if its dependencies (`valueA` or `valueB`) had changed since its last computation.For standalone computed values not tied to an object's getter, see createComputedAtom in the advanced API.
Actions and Transactions
To ensure atomicity and prevent reactions from running multiple times during a sequence of changes, use runAction or runTransaction. Methods on reactive objects created with createReactive are automatically wrapped in actions.
import { createReactive, createReaction, runAction } from "fluidstate";
const counter = createReactive({ value: 0 });
createReaction(() => {
console.log(`Counter value: ${counter.value}`);
});
// LOGS: Counter value: 0
// Without action, reaction would run for each increment if they were separate statements:
// counter.value++; // Reaction runs
// counter.value++; // Reaction runs again
// With runAction, all changes are batched, and the reaction runs once.
runAction(() => {
counter.value++; // Change 1
counter.value++; // Change 2
console.log("Inside action, all changes batched.");
});
// LOGS: Inside action, all changes batched.
// LOGS: Counter value: 2 (reaction runs once after action completes)runTransaction is similar to runAction but is typically used by library internals or for more complex batching scenarios. runAction is the preferred API for most user-level batching.
API Documentation
Core Reactive Primitives & Creation
These are fundamental for creating and managing reactive state.
createReactive(initialValue, options?)
Takes a plain JavaScript object, array, Set, Map, or Promise and returns a deeply reactive version.
initialValue: T: The initial value to make reactive.options?: ReactiveOptions: Configuration options.deep?: boolean: (Default:true) Whether to make the object deeply reactive. Iffalse, only direct properties/elements are reactive, not nested structures.reactiveList?: (string | symbol)[]: If provided, only these properties will be reactive.nonReactiveList?: (string | symbol)[]: If provided, these properties will not be reactive.plugins?: ReactivePlugin[]: An array of plugins to apply. See Plugin system.name?: string: A debug name for the reactive object.
const user = createReactive({ name: "Alice", age: 30 }); // Reactive object
const items = createReactive([1, 2, 3]); // Reactive array
const dataSet = createReactive(new Set([10, 20])); // Reactive Set
const dataMap = createReactive(new Map([["key", "value"]])); // Reactive Map
const promiseHolder = createReactive({ data: fetch("/api/data") }); // Wrapping PromisecreateReaction(effect, options?)
Creates a reaction that automatically tracks its dependencies and re-runs when they change. It returns a reaction object which has a stop() method that must be eventually called to dispose of the reaction.
effect: () => void: The function to run as a reaction. While running, it tracks access to any reactive properties read inside and will re-run (or be re-scheduled) when any of those reactive properties change. The function can optionally create any cleanups viacreateCleanupthat will be executed before the next run or when the reaction is stopped.options?: object:scheduler?: (fn) => void: Custom scheduler to control when the reaction runs. If provided, the reaction might not run immediately. The default scheduler runs the reaction synchronously.
const data = createReactive({ value: 0 });
const reaction = createReaction(() => {
console.log(`Data value: ${data.value}`);
});
// LOGS: Data value: 0
runAction(() => {
data.value = 1;
});
// LOGS: Data value: 1
reaction.stop();Important Note: Every reaction created MUST be explicitly stopped using its stop() method when it is no longer needed. Failure to do so can lead to memory leaks, especially if the reactive data the reaction depends on is also not garbage collected. Reactions hold references to their dependencies, and if a reaction is not stopped, it may prevent those dependencies (and potentially large parts of your application state) from being cleaned up by the garbage collector.
createCleanup(cleanupFn)
A utility to be used within a createReaction's effect function to register cleanup logic. The cleanupFn is called when the reaction is stopped or before it re-runs. This is helpful for managing multiple distinct cleanup operations within a single reaction effect.
import {
createReactive,
createReaction,
createCleanup,
runAction,
} from "fluidstate";
const appState = createReactive({
button: document.createElement("button"),
isActive: true,
});
const reaction = createReaction(() => {
if (!appState.isActive) {
console.log("Feature is inactive");
createCleanup(() => {
console.log("Cleanup after feature becoming inactive");
});
return;
}
const handleButtonClick = () => {
console.log(`Element clicked!`);
};
const button = appState.button;
button.addEventListener("click", handleButtonClick);
console.log(`Added click listener to button`);
createCleanup(() => {
button.removeEventListener("click", handleButtonClick);
console.log(`Removed click listener from button`);
});
});
// Initial run
// LOGS: Added click listener to button
// Simulate changing state that causes re-run and cleanup
runAction(() => {
appState.button = document.createElement("div"); // This will trigger a re-run
});
// LOGS: Removed click listener from button
// Simulate deactivation
runAction(() => {
appState.isActive = false; // This will trigger a re-run
});
// LOGS: Removed click listener from button
// LOGS: Feature is inactive
// Finally, stop the reaction explicitly
reaction.stop();
// LOGS: Cleanup after feature becoming inactiveuntrack(fn)
Executes the function fn without tracking any reactive dependencies accessed within it. Any reactive values read inside fn will not cause the outer reaction or computed value to re-evaluate if those specific untracked values change.
const state = createReactive({ trackedValue: 1, untrackedInfo: 100 });
createReaction(() => {
const info = untrack(() => {
// state.untrackedInfo is accessed here but won't be a dependency of the reaction
return state.untrackedInfo * 2;
});
console.log(`Tracked: ${state.trackedValue}, Info: ${info}`);
});
// LOGS: Tracked: 1, Info: 200
runAction(() => {
state.trackedValue = 2;
});
// LOGS: Tracked: 2, Info: 200 (reaction re-runs due to state.trackedValue)
runAction(() => {
state.untrackedInfo = 150;
});
// (no log: Reaction does NOT re-run because state.untrackedInfo was accessed in untrack())This is particularly useful when a reaction needs to modify a reactive value and it is important for the reaction not to depend on it. If a reaction directly modifies a value it reads, it can create a cycle where the modification triggers the reaction, which then modifies the value again, leading to an infinite loop or unexpected behavior. By wrapping the reading of the modification path in untrack, you can prevent the reaction from subscribing to changes in that specific part of the state, while still allowing it to react to other dependencies.
const state = createReactive({
object: {
nested: 100,
},
latestValue: 200,
});
createReaction(() => {
// The reaction becomes dependent on `state.latestValue` but not
// on `state.object.nested` because it `state.object` is accessed
// in `untrack` and because property mutations such as `.nested = ...`
// do not track property access
untrack(() => state.object).nested = state.latestValue;
});State Inspection & Manipulation
isReactive(value)
Returns true if the given value is a reactive object created by fluidstate, false otherwise.
const plain = {};
const reactiveObj = createReactive({});
console.log(isReactive(plain)); // false
console.log(isReactive(reactiveObj)); // truegetInert(reactiveValue)
If reactiveValue is a reactive proxy, returns its underlying inert (plain JavaScript) target. Otherwise, returns the value as is. This is a shallow operation. For a deep inert copy, see cloneInert.
const reactiveUser = createReactive({ name: "Bob" });
const inertUser = getInert(reactiveUser);
console.log(isReactive(reactiveUser)); // true
console.log(isReactive(inertUser)); // false
console.log(inertUser.name); // "Bob"ensureInert(value)
Ensures the returned value is not reactive. If value is reactive, it returns its inert target (similar to getInert). Otherwise, returns value itself. Useful when you need to store a value that might be reactive but you want to store its plain form.
const original = { id: 1 };
const reactiveCopy = createReactive(original);
const itemToStore = ensureInert(reactiveCopy); // itemToStore is { id: 1 } (plain object, same as original)
const plainItemToStore = ensureInert({ id: 2 }); // plainItemToStore is { id: 2 }getReactive(inertValue)
If inertValue is the inert target of an existing reactive proxy, returns that proxy. Otherwise, returns null.
const inert = { id: 1 };
const reactiveProxy = createReactive(inert);
const foundReactive = getReactive(inert);
console.log(foundReactive === reactiveProxy); // true
const anotherInert = { id: 2 };
console.log(getReactive(anotherInert)); // nullgetComputedKeys(reactiveObject)
Returns an array of keys (strings or symbols) that are getters (and thus, computed properties) on the reactive object.
const store = createReactive({
data: 123,
get computedData() {
return this.data * 2;
},
normalMethod() {},
});
console.log(getComputedKeys(store)); // ['computedData']deepObserve(reactiveTarget)
When used inside a reaction's effect function, deepObserve(reactiveTarget) tells the reaction to subscribe to all changes within the reactiveTarget, no matter how deeply nested they are.
reactiveTarget: T: The reactive object, array, Set, or Map to observe deeply.
import {
createReactive,
createReaction,
deepObserve,
runAction,
} from "fluidstate";
const user = createReactive({
name: "Carol",
address: { city: "New York", zip: "10001" },
hobbies: ["skiing", "coding"],
});
const deepReaction = createReaction(() => {
// This makes the reaction sensitive to any change within the user object.
deepObserve(user);
untrack(() => {
// Using untrack to prevent JSON.stringify from creating unintended dependencies
console.log("User data changed:", JSON.stringify(user));
});
});
// LOGS: User data changed: {"name":"Carol","address":{"city":"New York","zip":"10001"},"hobbies":["skiing","coding"]}
runAction(() => {
user.name = "Charles";
});
// LOGS: User data changed: {"name":"Charles","address":{"city":"New York","zip":"10001"},"hobbies":["skiing","coding"]}
runAction(() => {
user.address.city = "London";
});
// LOGS: User data changed: {"name":"Charles","address":{"city":"London","zip":"10001"},"hobbies":["skiing","coding"]}
runAction(() => {
user.hobbies.push("swimming");
});
// LOGS: User data changed: {"name":"Charles","address":{"city":"London","zip":"10001"},"hobbies":["skiing","coding","swimming"]}
deepReaction.stop(); // Stop observingcloneInert(reactiveSource, options?)
Creates a non-reactive (inert) clone of a reactive object, array, Set, or Map. By default, the clone is deep.
reactiveSource: T: The reactive data structure to clone.options?: CloneInertOptions:deep?: boolean: (Default:true) Whether to perform a deep clone. Iffalse, it's a shallow clone (nested reactive objects/arrays will remain reactive proxies in the clone).excludeComputed?: boolean: (Default:false) Whether computed property values (getters) should be excluded from the clone. Iftrue, the property will not exist on the cloned object. Iffalse(default), the computed value at the time of cloning is included as a static value.
Returns CloneInertResult<T>, which is the cloned, inert version of reactiveSource.
const state = createReactive({
user: { name: "Dave", settings: { theme: "dark" } },
posts: [{ id: 1, title: "First Post" }],
get upperName() {
return this.user.name.toUpperCase();
},
});
// Deep clone including computed values
const inertSnapshot = cloneInert(state);
console.log(isReactive(inertSnapshot.user)); // false
console.log(isReactive(inertSnapshot.posts[0])); // false
console.log(inertSnapshot.upperName); // "DAVE"
inertSnapshot.user.name = "David"; // This doesn't affect the original reactive 'state'
console.log(state.user.name); // "Dave"
// Deep clone excluding computed values
const inertSnapshotNoComputed = cloneInert(state, { excludeComputed: true });
console.log("upperName" in inertSnapshotNoComputed); // falseActions & Transactions
These ensure that multiple state changes trigger reactions only once, after all changes are complete.
createAction(fn)
Wraps a function fn into an action. When the wrapped function is called, all state modifications within it are batched. Methods on objects created with createReactive are automatically wrapped like this.
fn: (...args: any[]) => T: The function to wrap.
const counter = createReactive({ value: 0 });
createReaction(() => console.log(counter.value)); // LOGS: 0
const incrementTwiceBy = createAction((amount: number) => {
counter.value += amount;
counter.value += amount; // Another modification
});
incrementTwiceBy(2);
// LOGS: 4runAction(fn)
Executes the function fn within an action context, batching all state changes made directly within fn.
fn: () => T: The function to execute.
const counter = createReactive({ value: 0 }); // Assuming counter from previous example or re-declared
createReaction(() => console.log(`Counter: ${counter.value}`)); // LOGS: Counter: 0
runAction(() => {
counter.value = 5;
counter.value = 10;
}); // Reaction depending on counter.value runs once with value 10.
// LOGS: Counter: 10runTransaction(fn)
Similar to runAction, but typically used for lower-level or more complex batching scenarios. For most application code, runAction is preferred.
fn: () => T: The function to execute.
Reactive Options Configuration
You can configure default options globally for how fluidstate handles equality checks (which affects when reactions re-run or computed values re-calculate) and reaction scheduling.
CHANGED (Symbol)
fluidstate exports a special symbol CHANGED. You can assign this symbol as the new value to a reactive property or variable to force propagation of a change, effectively bypassing the standard equality check. This is useful when the specific new value itself is not important, but you need to signal that a change occurred.
import { createReactive, createReaction, CHANGED, runAction } from "fluidstate";
const state = createReactive({ data: CHANGED });
let changeCount = 0;
createReaction(() => {
// Access state.data.value to establish dependency
const currentValue = state.data;
console.log(`Data changed. Change count: ${changeCount++}`);
});
// LOGS: Data changed. Change count: 0
runAction(() => {
// Signaling a change by re-assigning the value to `CHANGED`
state.data = CHANGED;
});
// LOGS: Data changed. Change count: 1
runAction(() => {
// Signaling a change again
state.data = CHANGED;
});
// LOGS: Data changed. Change count: 2The default equals function (used by reactive values and computed atoms) will consider a value unequal to its previous one if the new value is CHANGED. If you provide a custom equals function, you should typically handle CHANGED as well, usually by returning false (not equal) if newValue === CHANGED. (See example under configureDefaultComputedOptions).
configureDefaultReactiveValueOptions(options)
Sets default options for Atoms (these underlie properties in createReactive).
options: { equals?: (a: any, b: any) => boolean }
getDefaultReactiveValueOptions()
Gets the current default options for Atoms.
configureDefaultComputedOptions(options)
Sets default options for ComputedAtoms (these underlie getters in createReactive and values from createComputedAtom).
options: { equals?: (a: any, b: any) => boolean }
getDefaultComputedOptions()
Gets the current default options for ComputedAtoms.
configureDefaultReactionOptions(options)
Sets default options for Reactions (used by createReaction).
options: { scheduler?: (callback: () => void) => void }(seecreateReactionfor more context).
getDefaultReactionOptions()
Gets the current default options for Reactions.
Example of configuration
import {
configureDefaultComputedOptions,
createReactive,
createReaction,
runAction,
CHANGED,
} from "fluidstate";
// Custom equals for computed properties: consider numbers close enough if difference is small
const fuzzyEquals = (oldValue: unknown, newValue: unknown) => {
if (newValue === CHANGED) {
// Essential for compatibility with CHANGED symbol
return false;
}
if (typeof oldValue === "number" && typeof newValue === "number") {
return Math.abs(oldValue - newValue) < 0.001;
}
return Object.is(oldValue, newValue);
};
configureDefaultComputedOptions({
equals: fuzzyEquals,
});
const store = createReactive({
x: 1.0,
y: 2.0,
get sum() {
console.log("Calculating sum...");
return this.x + this.y;
},
});
createReaction(() => {
console.log(`Sum: ${store.sum}`);
});
// LOGS: Calculating sum...
// LOGS: Sum: 3
runAction(() => {
store.x = 1.0001; // This change is small enough according to fuzzyEquals
});
// LOGS: Calculating sum...
// (does not log "Sum: ..." because fuzzyEquals considers 3 and 3.0001 equal)
runAction(() => {
store.x = 1.01; // This change is significant
});
// LOGS: Calculating sum...
// LOGS: Sum: 3.01Advanced Primitives & Utilities
These are lower-level primitives or utilities, often used for more fine-grained control or by library authors.
createAtom(name?, options?)
Creates a basic reactive unit (an Atom). Atoms don't hold values themselves but are used to signal observation (reportObserved()) and changes (reportChanged()). This is typically used for building custom reactive data structures or integrating with non-Proxy-based state.
name?: string: A debug name for the atom.options?: Atom-specific options (e.g.,onBecomeObservedListener,onBecomeUnobservedListener- the underlying reactive layer must support them, and they are passed through byfluidstate).
import { createAtom, createReaction, runAction } from "fluidstate";
const nameAtom = createAtom("customNameAtom");
let nameValue = "Initial";
createReaction(() => {
nameAtom.reportObserved(); // Must report observation within a tracking context
console.log(nameValue);
});
// LOGS: Initial
runAction(() => {
nameValue = "Updated";
nameAtom.reportChanged(); // Must report change to trigger dependents
});
// LOGS: UpdatedcreateComputedAtom(name, calculate, options?)
Creates a standalone memoized, reactive value derived from other reactive sources. Getters on objects made with createReactive are a more common way to achieve computed values.
name: string: A debug name for the computed atom.calculate: () => T: The function to calculate the value. Dependencies (other atoms or reactive properties) accessed within this function are automatically tracked.options?: Computed-specific options:onBecomeObservedListener?,onBecomeUnobservedListener?(the underlying reactive layer must support them).equals?: (oldValue: T, newValue: T) => boolean: Custom equality check (see Reactive Options Configuration).
import {
createReactive,
createComputedAtom,
createReaction,
runAction,
} from "fluidstate";
const product = createReactive({ price: 100, taxRate: 0.07 });
const totalPrice = createComputedAtom(
"totalPrice",
() => {
console.log("Calculating totalPrice...");
return product.price * (1 + product.taxRate);
},
{ equals: (a, b) => Math.abs(a - b) < 0.01 } // Custom equals
);
createReaction(() => {
console.log(`Total: $${totalPrice.get().toFixed(2)}`);
});
// LOGS: Calculating totalPrice...
// LOGS: Total: $107.00
runAction(() => {
product.price = 200;
});
// LOGS: Calculating totalPrice...
// LOGS: Total: $214.00isTracking()
Returns true if the current code execution is within a reactive tracking context (e.g., inside a createReaction's effect or a createComputedAtom's calculate function), false otherwise.
Advanced: Reactive Remotes & Instance Management
These APIs are for advanced use cases, particularly when integrating multiple reactive systems or customizing the reactive layer.
provideReactiveLayer(layer)
As shown in Getting Started, this function is used to supply fluidstate with its core reactive primitives (atoms, computeds, reactions).
layer: ReactiveLayer: An object conforming to theReactiveLayerinterface (which is a subset ofReactiveInstanceinterface), which defines methods likecreateAtom,createComputedAtom,createReaction, etc.
getReactiveInstance()
Returns the ReactiveInstance of fluidstate itself. This can be useful for integrating with other systems or for creating "Reactive Remotes".
addReactiveRemote(remoteInstance, options)
Integrates a "remote" ReactiveInstance with the current (local) ReactiveInstance. This allows reactions in the remote instance that depend on data from the local instance to have their execution scheduled by the local instance.
remoteInstance: ReactiveInstance: The reactive instance to add as a remote.options?: { scheduler?: (fn: () => void) => void }:scheduler: A function that the local instance will use to schedule the execution of reactions from the remote instance.
Returns a handle (ReactiveRemote) that can be used with removeReactiveRemote.
// --- In a hypothetical Game Engine (Local System) ---
import {
getReactiveInstance,
addReactiveRemote,
createReactive,
ReactiveInstance,
} from "fluidstate";
export const createGameEngine = () => {
let remote: ReactiveInstance | null = null;
const engineState = createReactive({ gameTime: 0 });
const connectExternalUI = (uiReactiveInstance: ReactiveInstance) => {
remote = addReactiveRemote(uiReactiveInstance, {
scheduler: (fnToRun) => engineScheduler.scheduleToEndOfFrame(fnToRun),
});
};
return {
engineState,
connectExternalUI,
destroyEngine: () => {
if (remote) {
removeReactiveRemote(remote);
}
},
};
};
// --- In an External UI System (Remote System) ---
import { getReactiveInstance, createReaction } from "fluidstate";
const uiReactiveInstance = getReactiveInstance();
gameEngine.connectExternalUI(uiReactiveInstance);
createReaction(() => {
// This reaction reads from engineState (local to game engine)
// Its execution will be scheduled by the game engine's scheduler
updateGameTimeDisplay(gameEngine.engineState.gameTime);
});removeReactiveRemote(remote)
Removes a previously added reactive remote.
remote: ReactiveRemote: The handle returned byaddReactiveRemote.
Utilities for Extending Prototypes
These utilities are generally for advanced scenarios where you might need to augment the behavior of objects wrapped by createReactive.
addSimplePrototype(objectPrototype)
Registers a prototype object to be considered "simple". Objects whose prototype is in this list will be treated as candidates for deep reactivity by default. This allows custom classes or objects with specific prototypes to be automatically wrapped in reactive proxies when encountered within a larger reactive structure, assuming deep reactivity is enabled.
removeSimplePrototype(objectPrototype)
Unregisters a prototype object, so it's no longer considered "simple". This is the counterpart to addSimplePrototype. Objects whose prototype was previously registered will no longer be identified as "simple" by default by isSimpleObject after being removed.
Promises
fluidstate provides utilities to observe the state of Promises within its reactive system. When a Promise is wrapped by createReactive (e.g., as a property of a reactive object or directly), getResult can be used within reactions or computed values to react to its lifecycle.
PromiseStatus
An enum representing the state of a Promise:
PromiseStatus.LoadingPromiseStatus.SuccessPromiseStatus.Error
getResult(promise)
Given a Promise (especially one managed within a reactive structure), returns a regular JS object describing the current status of the promise (Loading, Success, Error) and its resolved value or rejection reason. The function itself returns a regular, non-reactive JS object, but when used inside a reaction or a computed, that reaction or computed becomes subscribed to the promise and gets re-triggered when the promise resolves or throws.
promise: Promise<T>: The promise to inspect.
Returns: PromiseResult<T> containing the promise status and its result or rejection reason.
Example:
import {
createReactive,
createReaction,
getResult,
PromiseStatus,
runAction,
} from "fluidstate";
const fetchData = async (id: number, succeed: boolean): Promise<string> => {
console.log(`Fetching data for ID: ${id}...`);
await new Promise((resolve) => setTimeout(resolve, 100)); // Simulate network delay
if (succeed) {
return `Data for ID ${id} fetched successfully!`;
} else {
throw new Error(`Failed to fetch data for ID ${id}.`);
}
};
const store = createReactive({
itemId: 1,
simulateSuccess: true,
// This getter returns a new promise whenever itemId or simulateSuccess changes.
// createReactive makes the getter itself a computed value.
get currentDataPromise() {
return fetchData(store.itemId, store.simulateSuccess);
},
});
createReaction(() => {
// Accessing store.currentDataPromise makes the reaction depend on this computed getter.
// `getResult` then subscribes the reaction to the result of the promise.
const result = getResult(store.currentDataPromise);
switch (result.status) {
case PromiseStatus.Loading:
console.log("Loading data...");
break;
case PromiseStatus.Success:
console.log("Success:", result.result);
break;
case PromiseStatus.Error:
console.error("Error:", String(result.error));
break;
}
});
// Initially:
// LOGS: Fetching data for ID: 1...
// LOGS: Loading data...
// After ~100ms:
// LOGS: Success: Data for ID 1 fetched successfully!
// Example of triggering a new fetch by changing dependencies of currentDataPromise getter
setTimeout(() => {
runAction(() => {
store.itemId = 2;
store.simulateSuccess = false;
});
// This causes currentDataPromise getter to produce a new promise instance.
// The reaction re-runs because the getter (a dependency) changed.
// LOGS: Fetching data for ID: 2...
// LOGS: Loading data... (reaction re-runs, getResult sees new promise as pending)
// After ~100ms (for the new fetch):
// LOGS: Error: Error: Failed to fetch data for ID 2.
}, 500);Plugin system
The plugin system in fluidstate allows you to hook into the lifecycle of changes within reactive objects, arrays, maps, and sets. This enables a wide range of use cases, such as logging, validation, synchronization with external systems, or implementing undo/redo functionality.
Plugins are defined as objects that can have two optional methods: beforeChange and afterChange.
beforeChange(changes: ReactiveChange[]): This method is called just before a set of changes is applied to a reactive data structure. It receives an array ofReactiveChangeobjects detailing what is about to change. You could use this hook for validation, potentially throwing an error to prevent the change.afterChange(changes: ReactiveChange[]): This method is called immediately after a set of changes has been successfully applied. It also receives an array ofReactiveChangeobjects, describing what has changed. This is typically used for side effects like logging, updating other systems, or triggering further reactive updates.
ReactiveChange
Each ReactiveChange object in the array provides detailed information about a specific modification. The structure of this object varies depending on the type of data structure being modified (object, array, map, or set) and the nature of the change (add, update, delete). For precise details on the properties available for each type of change, refer to the specific *Change type definitions (e.g., ObjectChange, ArrayChange, MapChange, SetChange) exported by fluidstate.
Example: Server Synchronization Plugin
The following is a simple plugin that sends changes to a server for synchronization. This plugin will use the afterChange hook to report modifications.
For this example, we assume that an external function syncChangesToServer(changes: ReactiveChange[]): Promise<void> handles the mapping of local and remote reactive objects, performs network communication and request queueing, and getUserProfile(): Promise<UserProfile> retrieves the initial user profile information from the server.
import {
ReactivePlugin,
ReactiveChange,
createReactive,
runAction,
} from "fluidstate";
// --- Hypothetical external functions ---
declare function syncChangesToServer(changes: ReactiveChange[]): Promise<void>;
declare function getUserProfile(): Promise<UserProfile>;
// --- Plugin Example ---
const ServerSyncPlugin: ReactivePlugin = {
afterChange: (changes: ReactiveChange[]) => {
console.log("[Server Sync Plugin] Sending change sync request", changes);
syncChangesToServer(changes).catch((error) => {
console.error("[Server Sync Plugin] Sync operation failed:", error);
});
},
};
// --- Usage Example ---
// Define types for our reactive data
type UserPreferences = {
theme: string;
notificationsEnabled: boolean;
};
type UserProfile = {
username: string;
email: string;
preferences: UserPreferences;
tags: string[];
};
// Create a reactive user profile and attach the synchronization plugin.
// Note: by default, `createReactive` applies plugins deeply to nested objects
const userProfile = createReactive<UserProfile>(await getUserProfile(), {
plugins: [ServerSyncPlugin],
});
runAction(() => {
userProfile.username = "Jane Smith"; // Change on root object
userProfile.preferences.theme = "light"; // Change on nested object
});
// Expected: Two calls to `syncChangesToServer`, one for 'userProfile' and one for 'userProfile.preferences':
// - LOG 1:
// [Server Sync Plugin] Sending change sync request, [{
// type: ReactiveChangeType.Object,
// object: userProfile,
// objectChange: {
// type: ObjectChangeType.SetProperty,
// property: "username",
// previousValue: "Jack Black", // <- assuming initial `username` was `Jack Black`
// nextValue: "Jane Smith",
// }
// }]
// - LOG 2:
// [Server Sync Plugin] Sending change sync request, [{
// type: ReactiveChangeType.Object,
// object: userProfile.preferences,
// objectChange: {
// type: ObjectChangeType.SetProperty,
// property: "theme",
// previousValue: "dark", // <- assuming initial `theme` was `dark`
// nextValue: "light",
// }
// }]
runAction(() => {
userProfile.tags.push("Reactivity");
});
// Expected: One call to `syncChangesToServer` for `userProfile.tags`:
// - LOG:
// [Server Sync Plugin] Sending change sync request, [{
// type: ReactiveChangeType.Array,
// array: userProfile.tags,
// arrayChange: {
// type: ArrayChangeType.PushValue,
// index: 2, // <- assuming initial `tags` had 2 elements
// nextValue: "Reactivity",
// }
// }]
runAction(() => {
userProfile.tags.splice(0, 1, "Senior Developer"); // Replaces 1 element
});
// Expected: One call to `syncChangesToServer` for `userProfile.tags`:
// - LOG:
// [Server Sync Plugin] Sending change sync request, [{
// type: ReactiveChangeType.Array,
// array: userProfile.tags,
// arrayChange: {
// type: ArrayChangeType.SetValue,
// index: 0,
// previousValue: "Intermediate Developer", // <- assuming 0th element of `tags` was `Intermediate Developer`
// nextValue: "Senior Developer",
// }
// }]Further Reading
To deepen your understanding of reactivity and related concepts:
- MobX Documentation: (https://mobx.js.org/README.html) -
fluidstatecan use MobX as its reactive core, and its concepts are very relevant. - SolidJS Documentation & Articles: (https://www.solidjs.com/guides/reactivity) - SolidJS is a pioneer in fine-grained reactivity and has excellent explanations.
- S.js - Simple Signals: (https://github.com/adamhaile/s) - A foundational signals library that inspired many others.
- MDN Proxy Documentation: (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) -
fluidstateuses Proxies extensively for itscreateReactivefunctionality.
