fluidstate
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.
At the heart of JavaScript are JavaScript objects, arguably the most important value type1 in the language. fluidstate
embraces this by acting as a versatile, ergonomic wrapper that utilizes an underlying foundational reactive layer (such as MobX or Alien Signals) to supercharge your plain JavaScript objects, arrays, Sets, and Maps with deep fine-grained reactivity.
import { createReactive } from "fluidstate"; const visionary = createReactive({ name: "Ada", lastName: "Lovelace", get fullName() { return `${this.name} ${this.lastName}`; }, }); console.log(`${visionary.fullName} was a visionary`); // LOGS: Ada Lovelace was a visionary
Reactive objects preserve the full ergonomics and flexibility of regular JavaScript objects, but add reactivity, i.e., the ability to run any code whenever parts of the object change.
import { createReactive, createReaction, runAction } from "fluidstate"; const visionary = createReactive({ name: "Ada", lastName: "Lovelace", get fullName() { return `${this.name} ${this.lastName}`; }, }); createReaction(() => { console.log(`${visionary.fullName} was a visionary`); }); // LOGS: Ada Lovelace was a visionary runAction(() => { visionary.name = "Luis"; visionary.lastName = "Alvarez"; }); // LOGS: Luis Alvarez was a visionary
createReactive
and createReaction
are your primary tools for full-featured reactivity. fluidstate
is designed to eliminate boilerplate out of state management while providing a powerful and flexible system.
This overview will give you a sufficient understanding of fluidstate
. Other pages of this documentation will go in depth on how reactivity works, what reactive objects and reactions are, how they work under the hood, and how to build reactive apps in a modular and clean way.
Use reactivity for state!
State management is one of the most diverse aspects of app development. Popular examples include Redux, Recoil, Jotai, Zustand, Xstate, MobX, as well as framework-specific state paradigms such as Solid Signals, Vue Reactivity API and Svelte. This is because state management is a non-trivial yet important problem.
It is a reasonable observation that any large enough app benefits from a well-organized shared state management. However, once you pick a library, experience shows that:
A big portion of the app's business logic becomes tied and entirely dependent on the concepts of your state management library.
All state management libraries have their own quirks, often causing the codebase to acquire workarounds and become unruly.
Interfaces of state management libraries come with their own, often significant, boilerplate and restrictions, adding a multiplier to the time of implementation.
fluidstate
's goal is and will always be to reduce these problems by:
Advocating for use of simple JavaScript objects: your state is always going to consist of objects you create and mutate yourself, the way you want.
Being flexible and unopinionated: it is up to you which underlying reactive layer to use, when to create the reactive state and how to split it.
Having a small, language-grounded interface with fewer to no restrictions on how you use it:
fluidstate
's job is to simply provide a clear, clean and minimal interface and give recommendations on how to use it.
And most importantly, fluidstate
uses the concept of fine-grained reactivity and automatic dependency tracking, built on top of battle-tested reactive layers. This concept is congruent with splitting code into smaller, loosely dependent pieces of data and primarily declarative logic. In practice, it makes writing and reading code significantly simpler. Here's the gist of what it's like to create and manage state with fluidstate
(using React as an example):
import { createReactive } from "fluidstate"; import { withReactive } from "fluidstate-react"; import { useState } from "react"; // Creating your own data const createTodoData = () => ({ todos: [], }); // Creating your own actions const createTodoActions = (todoData) => ({ addTodo(text) { todoData.todos.push({ text, isDone: false }); }, removeTodo(index) { todoData.todos.splice(index, 1); }, toggleTodoCompleted(index) { todoData.todos[index].isDone = !todoData.todos[index].isDone; }, }); // Wrap your functional components with `withReactive` HOC or use `useReactive` hook export const App = withReactive(() => { // Wrap your own objects / actions with `createReactive`. To share state with // downstream components, simply put your reactive objects into React Context const [todoData] = useState(() => createReactive(createTodoData())); const [todoActions] = useState(() => createReactive(createTodoActions(todoData)) ); return ( <ul> <li> Add To-Do:{" "} <input type="text" onKeyDown={(e) => { if (e.key === "Enter") { todoActions.addTodo(e.currentTarget.value); e.currentTarget.value = ""; } }} /> </li> {todoData.todos.map((todo, i) => ( <li key={i}> <input type="checkbox" id={`todo-${i}`} checked={todo.isDone} onChange={() => todoActions.toggleTodoCompleted(i)} style={{ verticalAlign: "middle" }} /> <label htmlFor={`todo-${i}`} style={{ textDecoration: todo.isDone ? "line-through" : "none", verticalAlign: "middle", margin: "0px 4px", }} > {todo.text} </label> <button onClick={() => todoActions.removeTodo(i)} style={{ verticalAlign: "middle" }} > Remove </button> </li> ))} </ul> ); });
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-mobx
Core Concepts & Usage
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).
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.
Official Reactive Layers:
Alien Signals:
fluidstate-alien
uses Alien Signals, one of the fastest and most efficient reactivity system implementations.MobX:
fluidstate-mobx
uses MobX, one of the most popular, mature, and battle-tested libraries for signals-based reactivity.Preact Signals:
fluidstate-preact/reactive-layer
uses Preact Signals, a highly efficient reactive engine that powers fast and performant Preact applications.
Example: using Alien Signals-based reactive layer:
import { provideReactiveLayer, createReactive, createReaction, runAction, } from "fluidstate"; import { getReactiveLayer } from "fluidstate-alien"; // Get the reactive layer instance from the provider const reactiveLayer = getReactiveLayer(); // Provide it to fluidstate provideReactiveLayer(reactiveLayer); // Afterwards, your application can use reactivity: const object = createReactive({ value: 100 }); createReaction(() => console.log(object.value)); // LOGS: 100 runAction(() => { object.value = 200; }); // LOGS: 200
Integration with UI Libraries
To bridge the gap between reactive state management and popular UI libraries, fluidstate
provides official tools to easily connect fluidstate
-based reactive state with UI components. At the moment, fluidstate
offers official integrations with:
React:
fluidstate-react
provides essential hooks, higher-order components (HOCs), and other utilities to connectfluidstate
to your React components.Preact:
fluidstate-preact
offers a similar toolkit specifically designed for Preact, extended with efficient@preact/signals
support, 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.
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"; const user = createReactive({ 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) { this.hobbies.push(hobby); }, updateName(firstName, lastName) { 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.fullName
is automatically a memoized computed value.Methods become actions:
user.addHobby
anduser.updateName
are automatically wrapped in actions, batching their internal changes.
Reactive objects
Reactive objects can be any combination of plain JS objects, arrays, sets and maps. Their deeply nested reactivity is fully supported.
Sets:
import { createReactive, createReaction, runAction } from "fluidstate"; const veggiePizzas = createReactive(new Set(["Margherita", "Mediterranean"])); createReaction(() => console.log( veggiePizzas.has("Garden") ? "We've got Garden pizza!" : "Sorry, no Garden pizza yet" ) ); // LOGS: Sorry, no Garden pizza yet runAction(() => { veggiePizzas.add("Garden"); }); // LOGS: We've got Garden pizza!
Maps:
import { createReactive, createReaction, runAction } from "fluidstate"; const gameScores = createReactive( new Map([ ["Eugene", 123], ["Baradun", 345], ["Bodger", 234], ]) ); createReaction(() => console.log( !gameScores.has("Baelin") ? "No score yet!" : `Baelin got: ${gameScores.get("Baelin")}` ) ); // LOGS: No score yet! runAction(() => { gameScores.set("Baelin", 600); }); // LOGS: Baelin got: 600
Arrays:
import { createReactive, createReaction, runAction } from "fluidstate"; const nolanFilmsInStock = createReactive([ "Interstellar", "Inception", "The Dark Knight", ]); createReaction(() => console.log(`We have ${nolanFilmsInStock.length} Nolan's films`) ); // LOGS: We have 3 Nolan's films runAction(() => { nolanFilmsInStock.push("Tenet"); }); // LOGS: We have 4 Nolan's films
Plain objects
Plain reactive objects are a little special. Generic objects can include three different kinds of properties:
Reactive properties: simple, independent values of any type
Computed reactive properties: values derived from other reactive values
Actions: functions that may modify any number of reactive properties in one go
Here's an example that contains all of them, but we will go into more detail on each of them in a moment:
import { createReactive, createReaction } from "fluidstate"; const person = createReactive({ // Reactive properties: name: "Nicola Geiger", children: ["Andrea Geiger", "Vanessa Fabrega"], grandchildren: [], // Computed reactive property: get descendantsCount() { return this.children.length + this.grandchildren.length; }, // Action: addGrandchild(name) { this.grandchildren.push(name); }, }); createReaction(() => console.log(`${person.name} has ${person.descendantsCount} descendants`) ); // LOGS: Nicola Geiger has 2 descendants person.addGrandchild("Alexa Fabrega-Geiger"); // LOGS: Nicola Geiger has 3 descendants
Reactive properties
Reactive properties are at the root of reactivity. Changes to reactive properties are propagated to computed properties and reactions that depend on them.
import { createReactive, createReaction, runAction } from "fluidstate"; const rectangle = createReactive({ // Reactive properties: color: "blue", width: 20, height: 30, // Computed reactive property: get area() { console.log("Area is calculated"); return this.width * this.height; }, }); createReaction(() => console.log(`Rectangle area is ${rectangle.area}`)); // LOGS: Area is calculated // Rectangle area is 600 // Change to an reactive property will propagate // first to computed property, then to the reaction runAction(() => { rectangle.width = 17; }); // LOGS: Area is calculated // Rectangle area is 510 // Note: even though "color" is a reactive property, // no reaction or computed property depends on it. Therefore, // nothing is logged when it is changed: runAction(() => { rectangle.color = "red"; }); console.log("Rectangle color is", rectangle.color); // LOGS: Rectangle color is red
Computed properties
As seen in the createReactive
example, getters on reactive objects automatically become memoized computed reactive properties. They efficiently recalculate only when their underlying reactive dependencies change and they are being observed by a reaction.
import { createReactive, createReaction, runAction } from "fluidstate"; const mother = createReactive({ income: 55_000, get tax() { return this.income * 0.2; }, }); const father = createReactive({ income: 34_000, get tax() { return this.income * 0.1; }, }); const son = createReactive({ income: 15_000, get tax() { return this.income * 0.05; }, }); const family = createReactive({ isTaxable: true, get income() { console.log("Calculating total income"); return mother.income + father.income + son.income; }, get tax() { console.log("Calculating total tax"); return this.isTaxable ? mother.tax + father.tax + son.tax : 0; }, }); createReaction(() => console.log("Total family tax:", family.tax)); // LOGS: Calculating total tax // Total family tax: 15150 // Changing any family member's "income" will re-trigger the reaction runAction(() => { mother.income = 65_000; }); // LOGS: Calculating total tax // Total family tax: 17150 console.log("Mother's new tax is", mother.tax); // LOGS: Mother's new tax is 13000 // However, if we change "isTaxable" flag, the total family tax will // no longer depend on any family member's properties runAction(() => { family.isTaxable = false; }); // LOGS: Calculating total tax // Total family tax: 0 console.log("Is taxable:", family.isTaxable); // LOGS: Is taxable: false // Now, changing "income" of a family member will log nothing: runAction(() => { mother.income = 65_000; });
For standalone computed values not tied to an object's getter, see createComputedAtom
in the API Documentation.
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, increaseCounterTwice() { this.value++; this.value++; console.log("Inside action method, all changes batched."); }, }); 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) // Equivalently, methods wrapped in `createReaction` are batched: counter.increaseCounterTwice(); // LOGS: Inside action method, all changes batched. // LOGS: Counter value: 4 (reaction runs once after action completes)
runTransaction
is similar to runAction
but is typically used for lower-level or more complex batching scenarios. runAction
is the preferred API for most user-level batching.
It is strongly recommended to use actions to mutate reactive objects, since it is going to be more performant in many cases. Some reactive layers may log a warning or throw an error if mutations are not performed inside reactions.
Reactive objects are proxies
Under the hood, reactive objects are JS proxy-based wrappers around original objects that add access and mutation tracking to organize reactivity. Changes to reactive objects propagate to the original inert objects.
import { createReactive, runAction } from "fluidstate"; const inertUser = { user: "princess1981", isAdmin: false, }; const reactiveUser = createReactive(inertUser); runAction(() => { reactiveUser.isAdmin = true; }); console.log(`inertUser.isAdmin = ${inertUser.isAdmin} after setting reactive`); // LOGS: inertUser.isAdmin = true after setting reactive
Reactions
Reactions are functions that automatically track any reactive properties accessed during their execution and re-run when those properties change. They are used to subscribe to parts of reactive objects and perform side-effects as a result of these changes.
Reactions run immediately upon creation.
import { createReactive, createReaction, runAction } from "fluidstate"; const person = createReactive({ firstName: "John", lastName: "Smith", }); createReaction( // The function automatically tracks `person.firstName` and `person.lastName` () => console.log(`${person.firstName} ${person.lastName}`) ); // LOGS: John Smith // Since the reaction is subscribed to `person.firstName` and `person.lastName`, // changes to either will re-trigger it. runAction(() => { person.firstName = "Kevin"; }); // LOGS: Kevin Smith
If a reactive property is accessed but you do not want the reaction to re-trigger when that specific property changes, you should wrap the access in the untrack
function.
import { createReactive, createReaction, untrack, runAction } from "fluidstate"; const person = createReactive({ firstName: "John", lastName: "Smith", }); createReaction( // The reaction tracks `person.lastName` because it's accessed directly. // `person.firstName` is accessed inside `untrack`, so it's not a tracked dependency. () => console.log(`${untrack(() => person.firstName)} ${person.lastName}`) ); // LOGS: John Smith // Changing `person.firstName` will NOT re-trigger the reaction because it was untracked. // Therefore, nothing else is logged by the reaction here: runAction(() => { person.firstName = "Kevin"; }); console.log("Reaction not run"); // LOGS: Reaction not run // Changing `person.lastName` WILL re-trigger the reaction runAction(() => { person.lastName = "Doe"; }); // LOGS: Kevin Doe
Reaction disposal
createReaction
returns a reaction object, and its stop
method may be called to stop and dispose of the reaction.
import { createReactive, createReaction, runAction } from "fluidstate"; const reactive = createReactive({ a: 100 }); const reaction = createReaction(() => { console.log("reactive.a =", reactive.a); }); // LOGS: reactive.a = 100 reaction.stop(); runAction(() => { reactive.a = 200; }); // (nothing is logged because the reaction is cleaned up)
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.
Reaction cleanup
Reactions may perform side-effects, and it may be valuable to perform certain cleanup before the reaction is re-triggered again. This may be done by calling createCleanup
inside createReaction
.
import { createReactive, createReaction, runAction, createCleanup, } from "fluidstate"; const reactive = createReactive({ a: 100 }); const reaction = createReaction(() => { const value = reactive.a; const timeout = setTimeout(() => { console.log("reactive.a =", value); }, 100); createCleanup(() => { console.log("Clearing timeout"); clearTimeout(timeout); }); }); // The reaction was called and sets the timeout setTimeout(() => { // In 50 ms, the reaction is re-triggered, clears the previous timeout // and sets the new timeout: runAction(() => { reactive.a = 200; }); // LOGS: Clearing timeout // After 100 ms, LOGS: reactive.a = 200 setTimeout(() => { // 50 ms after the log message, the reaction is triggered again, // and the new timeout is set: runAction(() => { reactive.a = 300; }); // Even though the previous timeout has already expired, LOGS: Clearing timeout // This time, the timeout will be interrupted by the disposal of the reaction: setTimeout(() => { reaction.stop(); // LOGS: Clearing timeout // (nothing further is logged) }, 50); }, 150); }, 50);
The previous cleanup functions will be called right before the new reaction runs, and right before the reaction is stopped altogether.
Reaction scheduling
fluidstate
allows you to control the timing of reactions by providing a scheduler
function to the reactions:
import { createReactive, createReaction, runAction } from "fluidstate"; const professor = createReactive({ firstName: "David", lastName: "Brailsford", age: 70, }); // A function that we'll be able to manually call to trigger reactions let triggerReactions; createReaction( () => { console.log( `Professor ${professor.firstName} ${professor.lastName} is ${professor.age} years old` ); }, { scheduler: (fn) => { triggerReactions = fn; }, } ); // (nothing is logged yet) console.log("Reaction not run yet - 1"); // LOGS: Reaction not run yet - 1 triggerReactions(); // LOGS: Professor David Brailsford is 70 years old // Updating properties: runAction(() => { professor.firstName = "Maryam"; professor.lastName = "Mirzakhani"; }); // This schedules a reaction, but does not trigger it yet console.log("Reaction not run yet - 2"); // LOGS: Reaction not run yet - 2 triggerReactions(); // LOGS: Professor Maryam Mirzakhani is 70 years old runAction(() => { professor.age = 40; }); // This schedules a reaction again console.log("Reaction not run yet - 3"); // LOGS: Reaction not run yet - 3 triggerReactions(); // LOGS: Professor Maryam Mirzakhani is 40 years old
It is recommended to run all scheduled reactions rather than omitting calling some of them, since it may be required by the underlying reactive layer (e.g. MobX).
API Documentation
Core Reactive Primitives & Creation
These are fundamental for creating and managing reactive state.
createReactive<T>(inertObject: T, options?: ReactiveOptions<T>): T
Takes a plain JavaScript object, array, Set, Map or function and returns a deeply reactive version (or action).
inertObject: T
: The initial value to make reactive.options?: ReactiveOptions<T>
: Configuration options.deep?: boolean
: (Default:true
) Whether to make the object deeply reactive. Iffalse
, only direct properties/elements are reactive, not nested structures.reactiveList?: Array<keyof T>
: If provided, only these properties will be reactive.nonReactiveList?: Array<keyof T>
: 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.
import { createReactive } from "fluidstate"; 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: Promise.resolve("hello") }); // Wrapping Promise console.log(user.name); // LOGS: Alice console.log(items[0], items.length); // LOGS: 1 3 console.log(dataSet.has(10), dataSet.size); // LOGS: true 2 console.log(dataMap.get("key"), dataMap.size); // LOGS: value 1 console.log(promiseHolder.data); // LOGS: {}
createReaction(effect: () => void, options?: ReactionOptions): Reaction
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 viacreateCleanup
that will be executed before the next run or when the reaction is stopped.options?: ReactionOptions
:scheduler?: (callback: () => void) => void
: Custom scheduler to control when the reaction runs. If provided, the reaction might not run immediately. The default scheduler runs the reaction synchronously.
import { createReactive, createReaction, runAction } from "fluidstate"; 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();
createCleanup(cleanupFn: () => void)
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"; // Mock a button element const createMockButton = (name) => { return { addEventListener: (event) => console.log(`Button ${name} - Added ${event} listener`), removeEventListener: (event) => console.log(`Button ${name} - Removed ${event} listener`), }; }; const appState = createReactive({ button: createMockButton("AA"), 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); createCleanup(() => { button.removeEventListener("click", handleButtonClick); }); }); // Initial run // LOGS: Button AA - Added click listener // Simulate changing state that causes re-run and cleanup runAction(() => { appState.button = createMockButton("BB"); // This will trigger a re-run (due to object identity change) }); // LOGS: Button AA - Removed click listener // Button BB - Added click listener // Simulate deactivation runAction(() => { appState.isActive = false; // This will trigger a re-run }); // LOGS: Button BB - Removed click listener // Feature is inactive // Finally, stop the reaction explicitly reaction.stop(); // LOGS: Cleanup after feature becoming inactive
untrack<T>(fn: () => T): T
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.
import { createReactive, createReaction, untrack, runAction } from "fluidstate"; 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.
import { createReactive, createReaction, untrack, runAction } from "fluidstate"; const state = createReactive({ object: { nested: 100, }, latestValue: 200, }); createReaction(() => { // The reaction becomes dependent on `state.latestValue` but not // on `state.object.nested` because `state.object` is accessed // in `untrack` and because property mutations such as `.nested = ...` // do not track property access untrack(() => state.object).nested = state.latestValue; }); console.log("state.object.nested =", state.object.nested); // LOGS: state.object.nested = 200 runAction(() => { state.latestValue = 300; }); console.log("state.object.nested =", state.object.nested); // LOGS: state.object.nested = 300 runAction(() => { state.object = { nested: 400 }; }); // (reaction does not run) console.log("state.object.nested =", state.object.nested); // LOGS: state.object.nested = 400
State Inspection & Manipulation
isReactive<T>(value: T): boolean
Returns true
if the given value is a reactive object created by fluidstate
, false
otherwise.
import { createReactive, isReactive } from "fluidstate"; const plain = {}; const reactiveObj = createReactive(plain); console.log(`isReactive(plain) =`, isReactive(plain)); // LOGS: isReactive(plain) = false console.log(`isReactive(reactiveObj) =`, isReactive(reactiveObj)); // LOGS: isReactive(reactiveObj) = true
getInert<T extends object>(reactiveValue: T): T | null
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
.
import { createReactive, getInert, isReactive, createReaction, runAction, } from "fluidstate"; const inertUser = { user: "princess1981", isAdmin: false, avatar: { src: "https://fluidstate.gitlab.io/fluidstate-100.png", size: [100, 100], }, }; const reactiveUser = createReactive(inertUser); const inertUserFromReactive = getInert(reactiveUser); console.log(`isReactive(reactiveUser) =`, isReactive(reactiveUser)); // LOGS: isReactive(reactiveUser) = true console.log( `isReactive(inertUserFromReactive) =`, isReactive(inertUserFromReactive) ); // LOGS: isReactive(inertUserFromReactive) = false console.log(`inertUserFromReactive.user =`, inertUserFromReactive.user); // LOGS: inertUserFromReactive.user = princess1981 createReaction(() => console.log("reactiveUser.avatar.size =", reactiveUser.avatar.size) ); // LOGS: reactiveUser.avatar.size = [100,100] runAction(() => { reactiveUser.avatar.size = [200, 100]; }); // LOGS: reactiveUser.avatar.size = [200,100] console.log("inertUser.avatar.size =", inertUser.avatar.size); // LOGS: inertUser.avatar.size = [200,100] // Reactive objects are deep proxies over the original objects, // which are not equal to the inert objects themselves console.log( reactiveUser.avatar.size !== inertUser.avatar.size ? "Reactive objects are not strictly equal to their inert counterparts" : "" ); // If we don't have access to original inert objects, we can still get them // by using `getInert` console.log( getInert(reactiveUser).avatar.size === inertUser.avatar.size ? "getInert(reactiveUser).avatar.size === inertUser.avatar.size" : "" ); // It does not matter what `getInert` is called on console.log( getInert(reactiveUser.avatar).size === inertUser.avatar.size ? "getInert(reactiveUser.avatar).size === inertUser.avatar.size" : "" ); console.log( getInert(reactiveUser.avatar.size) === inertUser.avatar.size ? "getInert(reactiveUser.avatar.size) === inertUser.avatar.size" : "" );
ensureInert<T>(value: T): T
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.
import { createReactive, ensureInert } from "fluidstate"; 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 } console.log(itemToStore === original); // LOGS: true console.log(itemToStore); // LOGS: { id: 1 } console.log(plainItemToStore); // LOGS: { id: 2 }
getReactive<T extends object>(inertValue: T): T | null
If inertValue
is the inert target of an existing reactive proxy, returns that proxy. Otherwise, returns null
.
import { createReactive, getReactive } from "fluidstate"; const inertObject = {}; console.log( getReactive(inertObject) === null ? "Currently there's no reactive" : "" ); // LOGS: Currently there's no reactive const reactiveObject = createReactive(inertObject); console.log( getReactive(inertObject) === reactiveObject ? "Now getting reactive works" : "" ); // LOGS: Now getting reactive works
getComputedKeys<T>(reactiveObject: T): Set<string | symbol>
Returns a set of keys that are getters (and thus, computed properties) on the reactive object.
import { createReactive, getComputedKeys } from "fluidstate"; const rectangle = createReactive({ width: 12, height: 13, get area() { return this.width * this.height; }, get diagonal() { return Math.sqrt(this.width ** 2 + this.height ** 2); }, }); console.log("Computed properties:", [...getComputedKeys(rectangle)]); // LOGS: 'Computed properties: ["area","diagonal"]'
deepObserve<T>(reactiveTarget: T)
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, or an object containing those.
import { createReactive, createReaction, deepObserve, runAction, untrack, } 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 observing
cloneInert<T>(reactiveSource: T, options?: CloneInertOptions): T
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 the cloned, inert version of reactiveSource
.
import { createReactive, cloneInert, isReactive } from "fluidstate"; 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)); // LOGS: false console.log(isReactive(inertSnapshot.posts[0])); // LOGS: false console.log(inertSnapshot.upperName); // LOGS: DAVE inertSnapshot.user.name = "David"; // (this doesn't affect the original reactive 'state') console.log(state.user.name); // LOGS: Dave // Deep clone excluding computed values const inertSnapshotNoComputed = cloneInert(state, { excludeComputed: true }); console.log("upperName" in inertSnapshotNoComputed); // LOGS: false
Actions & Transactions
These ensure that multiple state changes trigger reactions only once, after all changes are complete.
createAction<T extends Function>(fn: T): T
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: T
: The function to wrap.
import { createReactive, createAction, createReaction } from "fluidstate"; const counter = createReactive({ value: 0 }); createReaction(() => console.log(`Counter value (from reaction): ${counter.value}`) ); // LOGS: Counter value (from reaction): 0 const incrementTwiceBy = createAction((amount) => { counter.value += amount; counter.value += amount; // Another modification }); incrementTwiceBy(2); // LOGS: Counter value (from reaction): 4
runAction<T>(action: () => T): T
Executes the function action
within an action context, batching all state changes made directly within action
.
action: () => T
: The function to execute.
import { createReactive, createReaction, runAction } from "fluidstate"; const counter = createReactive({ value: 0 }); createReaction(() => console.log(`Counter value (from reaction): ${counter.value}`) ); // LOGS: Counter value (from reaction): 0 runAction(() => { counter.value = 5; counter.value = 10; console.log("Inside action, all changes batched."); }); // Reaction depending on counter.value runs once with value 10. // LOGS: Inside action, all changes batched. // LOGS: Counter value (from reaction): 10
runTransaction<T>(action: () => T): T
Similar to runAction
, but typically used for lower-level or more complex batching scenarios. For most application code, runAction
is preferred.
action: () => T
: The function to execute.
Promises
fluidstate
provides utilities to observe the state of Promises within its reactive system. For that purpose, getResult
can be used within reactions or computed values to react to a lifecycle of any promise.
PromiseStatus
An enum (or plain object with constant values in JS) representing the current state of a promise:
export enum PromiseStatus { /** * The promise is currently in flight. */ Loading, /** * The promise has resolved successfully. */ Success, /** * The promise has been rejected with an error. */ Error, }
PromiseResult
A discriminated union representing the outcome of a reactive promise:
export type PromiseResult<T> = | { status: PromiseStatus.Success; result: T; } | { status: PromiseStatus.Error; error: unknown; } | { status: PromiseStatus.Loading; };
getResult<T>(promise: Promise<T>): PromiseResult<T>
Given a promise, returns a regular JS object describing the current status of the promise (Loading
, Success
, Error
) and its resolved value or rejection reason. This works with any promise, not just those wrapped in a reactive structure. The getResult
function 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>
object containing the promise status and its result or rejection reason.
Example:
import { createReactive, createReaction, getResult, runAction, PromiseStatus, } from "fluidstate"; const fetchData = async (id, succeed) => { 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.log("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);
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 }); // Initial value let changeCount = 0; createReaction(() => { // Access state.data to establish dependency 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: 2
The 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: unknown, b: unknown) => boolean }
getDefaultReactiveValueOptions(): { equals?: (a: unknown, b: unknown) => boolean }
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: unknown, b: unknown) => boolean }
getDefaultComputedOptions(): { equals?: (a: unknown, b: unknown) => boolean }
Gets the current default options for ComputedAtoms.
configureDefaultReactionOptions(options)
Sets default options for Reactions (used by createReaction
).
options: { scheduler?: (callback: () => void) => void }
(seecreateReaction
for more context).
getDefaultReactionOptions(): { scheduler?: (callback: () => void) => void }
Gets the current default options for Reactions.
Example of configuration
import { configureDefaultComputedOptions, createReactive, createReaction, runAction, CHANGED, // Import CHANGED } from "fluidstate"; // Custom equals for computed properties: consider numbers close enough if difference is small const fuzzyEquals = (oldValue, newValue) => { 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.01
Advanced Primitives & Utilities
These are lower-level primitives or utilities, often used for more fine-grained control or by library authors.
createAtom(name: string, options?: AtomOptions): Atom
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?: AtomOptions
: Atom-specific options. The underlying reactive layer must support them.onBecomeObservedListener?: () => void
- listener called when the atom first becomes observed by a reaction.onBecomeUnobservedListener?: () => void
- listener called when the atom stops being observed by all reactions.
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: Updated
createComputedAtom<T>(name: string, calculate: () => T, options?: ComputedAtomOptions)
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?: ComputedAtomOptions
: Computed-specific options.onBecomeObservedListener?: () => void
- listener called when the atom first becomes observed by a reaction.onBecomeUnobservedListener?: () => void
- listener called when the atom stops being observed by all reactions.equals?: (a: unknown, b: unknown) => 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.00
isTracking(): boolean
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.
import { createReactive, createReaction, isTracking, runAction, } from "fluidstate"; const state = createReactive({ value: 10 }); createReaction(() => { console.log(`Inside reaction, isTracking: ${isTracking()}`); // true console.log(state.value); }); // LOGS: Inside reaction, isTracking: true // LOGS: 10 runAction(() => { console.log(`Inside action, isTracking: ${isTracking()}`); // false (actions are not tracking contexts by default) state.value = 20; }); // LOGS: Inside action, isTracking: false // LOGS: Inside reaction, isTracking: true // LOGS: 20
Advanced: Specifying Deep-by-Default Classes
By default, aside from arrays, Sets and Maps, the only objects that are deeply reactive by default are those created with an object literal {}
. These objects are considered "simple" and, therefore, automatically become candidates for deep reactivity. If an object is a member of a class (including built-in classes such as HTMLElement
), it will not become reactive when it is a part of a larger reactive structure.
import { createReactive, createReaction, runAction } from "fluidstate"; class MyClass { constructor(a, b) { this.a = a; this.b = b; } get c() { return this.a + this.b; } } const object = createReactive({ nestedObject: new MyClass(100, 200), }); createReaction(() => { console.log( `${object.nestedObject.a} + ${object.nestedObject.b} = ${object.nestedObject.c}` ); }); // LOGS: 100 + 200 = 300 runAction(() => { object.nestedObject.a = 300; }); // (does not log anything because `nestedObject` is not reactive) // However, replacing `nestedObject` itself will re-trigger reactions: runAction(() => { object.nestedObject = new MyClass(300, 400); }); // LOGS: 300 + 400 = 700
However, there is a way to alter which prototypes are considered "simple" and, hence, automatically deeply reactive.
addSimplePrototype(objectPrototype: object)
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.
import { createReactive, createReaction, runAction, addSimplePrototype, } from "fluidstate"; class MyClass { constructor(a, b) { this.a = a; this.b = b; } get c() { return this.a + this.b; } } addSimplePrototype(MyClass.prototype); const object = createReactive({ nestedObject: new MyClass(100, 200), }); createReaction(() => { console.log( `${object.nestedObject.a} + ${object.nestedObject.b} = ${object.nestedObject.c}` ); }); // LOGS: 100 + 200 = 300 runAction(() => { object.nestedObject.a = 300; }); // LOGS: 300 + 200 = 500 // Replacing `nestedObject` will also re-trigger reactions: runAction(() => { object.nestedObject = new MyClass(300, 400); }); // LOGS: 300 + 400 = 700
removeSimplePrototype(objectPrototype: object)
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 after being removed.
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: ReactiveLayer)
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 theReactiveLayer
interface (which is a subset ofReactiveInstance
interface), which defines methods likecreateAtom
,createComputedAtom
,createReaction
, etc.
getReactiveInstance(): ReactiveInstance
Returns the ReactiveInstance
of fluidstate
itself. This can be useful for integrating with other systems or for creating "Reactive Remotes".
addReactiveRemote(remoteInstance: ReactiveInstance, options?: ReactiveRemoteOptions): ReactiveInstance
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?: ReactiveRemoteOptions
:scheduler?: (callback: () => void) => void
: A function that the local instance will use to schedule the execution of reactions from the remote instance.
Returns a handle (ReactiveInstance
) 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(remoteInstance: ReactiveInstance)
Removes a previously added reactive remote.
remote: ReactiveInstance
: The handle returned byaddReactiveRemote
.
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 ofReactiveChange
objects 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 ofReactiveChange
objects, 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)
handles the mapping of local and remote reactive objects, performs network communication and request queueing, and getUserProfile()
retrieves the initial user profile information from the server.
import { createReactive, runAction } from "fluidstate"; // --- Hypothetical external functions (mocked for this example) --- // In a real app, these would interact with your backend const syncChangesToServer = async (changes) => { console.log( "MOCK: Sending changes to server:", JSON.stringify(changes, null, 2) ); console.log("MOCK: Changes sent.\n"); }; const getUserProfile = async () => { console.log("MOCK: Fetching user profile from server..."); return { username: "Jack Black", email: "jack.b@example.com", preferences: { theme: "dark", notificationsEnabled: true, }, tags: ["Intermediate Developer", "JavaScript Enthusiast"], }; }; // --- Plugin Example --- const ServerSyncPlugin = { afterChange: (changes) => { console.log("[Server Sync Plugin] Sending change sync request"); syncChangesToServer(changes); }, }; // Create a reactive user profile and attach the synchronization plugin. // Note: by default, `createReactive` applies plugins deeply to nested objects (async () => { const userProfile = createReactive(await getUserProfile(), { plugins: [ServerSyncPlugin], }); // MOCK: Fetching user profile from server... runAction(() => { userProfile.username = "Jane Smith"; // Change on root object userProfile.preferences.theme = "light"; // Change on nested object }); // [Server Sync Plugin] Sending change sync request // MOCK: Sending changes to server: [...] (for username change) // MOCK: Changes sent. // [Server Sync Plugin] Sending change sync request // MOCK: Sending changes to server: [...] (for preferences.theme change) // MOCK: Changes sent. runAction(() => { userProfile.tags.push("Reactivity"); }); // [Server Sync Plugin] Sending change sync request // MOCK: Sending changes to server: [...] (push to the userProfile.tags array) // MOCK: Changes sent. runAction(() => { userProfile.tags.splice(0, 1, "Senior Developer"); // Replaces 1 element }); // [Server Sync Plugin] Sending change sync request // MOCK: Sending changes to server: [...] (splice of the userProfile.tags array) // MOCK: Changes sent. })();
Further Reading
To deepen your understanding of reactivity and related concepts:
MobX Documentation:
fluidstate
can use MobX as its reactive layer, and its concepts are very relevant.SolidJS Documentation & Articles: SolidJS is a pioneer in fine-grained reactivity and has excellent explanations.
S.js - Simple Signals: A foundational signals library that inspired many others.
MDN Proxy Documentation:
fluidstate
uses Proxies extensively for itscreateReactive
functionality.