DEV Community

Maksim Iakovlev
Maksim Iakovlev

Posted on • Originally published at blog.pungy.me

Introducing Fluid: Explicit and Robust Reactivity for Modern JavaScript

Motivation

Reactivity is one of the most influential concepts in modern front-end development. Applications are expected to update promptly, propagate changes through complex data relationships, and remain reliable as they evolve.

Most established reactive libraries—such as MobX, Vue's reactivity system, and Svelte—offer developers tremendous convenience and power. I have used them extensively, and most of them, like for example MobX, makes your values "magically" reactive with minimal boilerplate. The convenience of it is impressive! However...

While using them for the development of complex systems, I've encountered issues like layers of implicit behavior, syntax restrictions, and a lack of control. Other solutions, like RxJS, offer granular control but come with the steep learning curve of Functional-Reactive Programming, which developers must fully adopt. Improper use can often lead to chaotic, hard-to-debug code.

Fluid was created out of a desire to rebalance reactivity in favor of clarity, predictability, and a greater level of control, even if it comes at the cost of some convenience. It aims to reveal the entire path of state change, so that every mutation, every dependency, every notification, and the order of execution are plainly visible and subject to the developer’s intent.

Why would you need this? For real-time applications with large and complex codebases, the "simplicity" of tools like MobX can lead to difficult questions: "What is the full list of dependencies for this computed property?", "What
would happen if I make this getter a computed property?"
, or "How do I ensure the reaction C executes after reaction A but before reaction B?". In such cases, perceived simplicity becomes a bottleneck.


Overview

Fluid achieves its goals through a set of core principles:

  1. Every reactive object is a type-constructor(like a Promise).
  2. Everything is explicit.
  3. Everything can be controlled.
  4. Everything is synchronous.
  5. No Proxy is used; only plain objects and functions.
  6. Performance: due to lack of huge computation on side and plain data structures, Fluid is very fast and easy on memory.
const _name_ = Fluid.val("Alice")
const _surname_ = Fluid.val("Liddell")
const _fullName_ = Fluid.derive(
    [_name_, _surname_],
    (name, surname) => `${name} ${surname}`
)

// Explicit subscription:
Fluid.listen(
    _fullName_,
    name => console.log("User's full name:", name)
)

Fluid.write(_name_, "Jane") // User's full name: Jane Liddell
Fluid.read(_fullName_) // "Jane Liddell"
Enter fullscreen mode Exit fullscreen mode

The core concepts here will be familiar from other reactive systems:

  • Reactive Values: Fluid.val (Read-Write).
  • Derivations: Fluid.derive (Read-only).
  • Listeners: Fluid.listen (Fires an effect on change).
  • Reading: Fluid.read (Returns the current state of a reactive object).
  • Writing: Fluid.write (Sets a new state for a reactive value).

What Makes Fluid Unique

From the example above, one might think Fluid is just another syntax for the same concepts. However, it provides unique capabilities:

1. Controlling evaluation order

Priorities API

Typically, synchronous libraries build a dependency tree, detect and resolve circular dependencies, and rebalance the execution order automatically.

Let's imagine the following cart store, which calculates price, shipping fees, and tax to display a total. Code here is written with MobX library:

import { makeObservable, observable, computed } from 'mobx'

class Cart {
  price = 0

  constructor() {
    makeObservable(this, {
      price: observable,
      shipping: computed,
      tax: computed,
      showTotal: computed,
    })
  }

  get shipping() {
    return this.price > 50 ? 0 : 5.00
  }
  get tax() {
    return this.price * 0.08 // 8%
  }

  get showTotal() {
    const total = this.price + this.tax + this.shipping
    return `Final price: $${total.toFixed(2)} (incl. tax: $${this.tax.toFixed(2)}, shipping: $${this.shipping.toFixed(2)})`
  }
}

const cart = new Cart()

cart.price = 20
console.log(cart.showTotal) // Final price: $26.60 (incl. tax: $1.60, shipping: $5.00)
Enter fullscreen mode Exit fullscreen mode

Here, showTotal computed has three dependencies: price, tax, and shipping. shipping and tax depend on the single observable price, and it also understands that showTotal should be re-evaluated only after shipping and tax have been updated. An abstract dependency graph would looks like this:

   _price_
 /    |    \
⌄     |     ⌄
tax   |  shipping
 \    |    /
  ⌄   ⌄   ⌄
  showTotal
Enter fullscreen mode Exit fullscreen mode

Most importantly, after updating price, showTotal should be updated only once! If each dependency triggered its own update, showTotal would re-evaluate multiple times unnecessarily!

Does Fluid solve this diamond dependency problem automatically? No, it doesn't! But instead, it gives you a powerful tool to solve it yourself: priorities.

You declare these relationships explicitly:

const _price_ = Fluid.val(0)

const _tax_ = Fluid.derive(
  _price_,
  price => price * 0.08, // 8% tax
)
const _shipping_ = Fluid.derive(
  _price_,
  price => price > 50 ? 0 : 5.00, // free shipping over $50
)

const _totalSummary_ = Fluid.derive(
  _price_, // We only need to subscribe to the root dependency.
  (price) => {
    // We read the latest values of other derivations inside.
    const tax = Fluid.read(_tax_)
    const shipping = Fluid.read(_shipping_)
    const total = price + tax + shipping
    return `Final price: $${total.toFixed(2)} (incl. tax: $${tax.toFixed(2)}, shipping: $${shipping.toFixed(2)})`
  },
  // Set priority: execute this derivation *after* the base level.
  { priority: Fluid.priorities.after(Fluid.priorities.base) },
)

Fluid.write(_price_, 20.00)

console.log(Fluid.read(_totalSummary_)) // Final price: $26.60 (incl. tax: $1.60, shipping: $5.00)
Enter fullscreen mode Exit fullscreen mode

Resolved graph of dependencies above would build a following timeline of updates:

_price_
 |
 +---> _tax_
 +---> _shipping_
 |
 +---> _totalSummary_
Enter fullscreen mode Exit fullscreen mode

Every derive and listen accepts a priority option. This allows you to declare the order of execution. Here, _totalSummary_ depends on _price_, and its update is scheduled after the base priority pool, where dependencies like _tax_ and _shipping_ are placed by default.

Priority is simply a number. Fluid.priorities.after(Fluid.priorities.base) is a readable helper for -1. The execution order looks like this:

HIGHER
 0: [_tax_, _shipping_]
-1: [_totalSummary_]
LOWER
Enter fullscreen mode Exit fullscreen mode

And yeah, it is really just a number, the higher the number, the higher the priority:

const _msg_ = Fluid.val("")
const log = console.log

Fluid.listen(_msg_, (msg) => log("3: " + msg), { priority: 3 })
Fluid.listen(_msg_, (msg) => log("2: " + msg), { priority: 2 })
Fluid.listen(_msg_, (msg) => log("4: " + msg), { priority: 4 })
Fluid.listen(_msg_, (msg) => log("1: " + msg), { priority: 1 })

Fluid.write(_msg_, "Hi?")

// 4: Hi?
// 3: Hi?
// 2: Hi?
// 1: Hi?
Enter fullscreen mode Exit fullscreen mode

2. Transactions

Transactions API

Batching changes is a common feature in reactive systems. In Fluid, the approach might look different at first, but it is highly controllable and powerful.

Lazy write

A Fluid transaction is essentially a delayed write.

const _name_ = Fluid.val('Mike')

const transaction = Fluid.transaction.write(_name_, 'MIKE')
Fluid.read(_name_) // Mike

transaction.run()
Fluid.read(_name_) // MIKE
Enter fullscreen mode Exit fullscreen mode

Are there other differences from a standard write? Yes—we can also reject a write, preventing the state from changing.

const _counter_ = Fluid.val(1)

const inc = () => Fluid.transaction.write(
    _counter_,
    count => {
        if (count < 3)
            return Fluid.transaction.success(count + 1)
        else
            return Fluid.transaction.error() 
    },
)

let result = inc().run()
console.log(Fluid.read(_counter_), Fluid.transaction.isSuccess(result)) // 2, true

result = inc().run()
console.log(Fluid.read(_counter_), Fluid.transaction.isSuccess(result)) // 3, true

result = inc().run()
console.log(Fluid.read(_counter_), Fluid.transaction.isSuccess(result)) // 3, false
Enter fullscreen mode Exit fullscreen mode

This is an incredibly useful concept. You can pass transactions around as objects and operate on them without needing to know what they write to—you only need to deal with the transaction itself!

Here is an example with graphics redrawing on transaction success!

import { Fluid, ReactiveTransaction } from 'reactive-fluid'

class Graphics {
    // ...

    addObject(object) {
        this.objects.push({
            draw() {
                // ...
            }
        })
    }
    redraw() {
        this.ctx.clear()
        this.objects.forEach(object => object.draw())
    }
}
const graphics = new Graphics()

/**
 * Execute transaction and redraw graphics on success
 */
function update(transaction: ReactiveTransaction) {
    const res = transaction.run()
    if (Fluid.transaction.isSuccess(res)) {
        graphics.redraw();
    }
}

const player = Fluid.val({ x: 0, y: 0 })
const enemy = Fluid.val({ x: 10, y: 0 })

// register objects
graphics.addObject(player)
graphics.addObject(enemy)

const movePlayer = Fluid.transaction.write(_player_, player => {
    player.x += 10;
    return Fluid.transaction.success(player)
}

// if moving would be successful - scene would be redrawed!
update(movePlayer)
Enter fullscreen mode Exit fullscreen mode

Composing Writes

But the core of transaction is batching changes together! How does a lazy write help here? The answer is: we can compose multiple writes into a single, atomic transaction.

const _name_ = Fluid.val("Alice")
const _surname_ = Fluid.val("Liddell")
const _fullName_ = Fluid.derive(
    [_name_, _surname_],
    (name, surname) => `${name} ${surname}`
)

Fluid.listen(
    _fullName_,
    (full) => console.log(`The full name is: ${full}`),
)

const nameTransaction = Fluid.transaction.compose(
    Fluid.transaction.write(_name_, "Mark"),
    Fluid.transaction.write(_surname_, "Smith"),
)

nameTransaction.run()
// The full name is: Mark Smith
Enter fullscreen mode Exit fullscreen mode

If we would make this writes one by one, the listener would be triggered twice. With a composed transaction - only once!

Canceling Transactions

Another important aspect of transactions is atomicity: all changes are rejected if any single part of the transaction is an error. This prevents the application from entering a broken or inconsistent state. Fluid follows this principle with the error() state.

const _name_ = Fluid.val("Alice")
const _surname_ = Fluid.val("Liddell")
const _age_ = Fluid.val(22)
const _userinfo_ = Fluid.derive(
    [_name_, _surname_, _age_],
    (name, surname, age) => `${name} ${surname}, ${age}`
)

Fluid.listen(
    _userinfo_,
    (info) => console.log(`user info: ${info}`),
)

const update = Fluid.transaction.compose(
    Fluid.transaction.write(_name_, "Mark"),
    Fluid.transaction.write(_surname_, () => Fluid.transaction.error("NOT FOUND")),
    Fluid.transaction.write(_age_, 30),
)

update.run() // listener wasn't triggered
Fluid.read(_userinfo_) // "Alice Liddell, 22" (state remains unchanged)
Enter fullscreen mode Exit fullscreen mode

That's not all about transactions. Important questions might appear: how can I see updated value of previously succeeded transactions in composition list? How can I read updated state of dependended computed?

If you are interested in answers, you can find them in Composing Transactions section of documentation.

3. No Hidden Memoization

As you've seen, Fluid does nothing behind your back. If you call write, it writes the new value and notifies all its listeners, period. This also means you are not forced to use immutable data structures.

const _cart_ = Fluid.val<Array<number>>([])
const _cartSum_ = Fluid.derive(
    _cart_,
    cart => cart.reduce((acc, item) => acc + item, 0)
)

// This function mutates the array directly.
const pushToCart = (item: number) => {
    Fluid.write(_cart_, cart => {
        cart.push(item)
        // We must return the mutated array to signal a change.
        return cart
    })
}

pushToCart(5)
pushToCart(5)
pushToCart(5)

Fluid.read(_cartSum_) // 15
Enter fullscreen mode Exit fullscreen mode

4. Dynamic Dependencies

Because reactive objects in Fluid are first-class citizens, they can be nested inside one another. This powerful feature allows you to create dynamic dependencies, where a derived value can switch its underlying sources based on application state.

Imagine a scenario where a _son_ reactive value reflects different sources based on his _age_. When under 18, his "voice" is derived from his parents (_mommy_ and _daddy_). Once he turns 18, his voice becomes his own, represented by a separate, writable reactive source (_matureSon_).

const _mommy_ = Fluid.val("Eat your breakfast");
const _daddy_ = Fluid.val("Go to school");

const _age_ = Fluid.val(10);

const _matureSon_ = Fluid.val("...");
const _youngSon_ = Fluid.derive(
    [_mommy_, _daddy_],
    (mommy, daddy) => `Mommy said: "${mommy}", Daddy said: "${daddy}"`
);

// This derivation returns another reactive object, not a simple value.
const _son_ = Fluid.derive(
    _age_,
    age => (age >= 18 ? _matureSon_ : _youngSon_)
);

// To get the final value, you must 'unwrap' it twice:
// 1. Fluid.read(_son_) -> returns either _matureSon_ or _youngSon_
// 2. Fluid.read( ... ) -> reads the value from that inner object
console.log(Fluid.read(Fluid.read(_son_))); // Mommy said: "Eat your breakfast", Daddy said: "Go to school"

Fluid.write(_age_, 20);

// Now, _son_ points to _matureSon_
console.log(Fluid.read(Fluid.read(_son_))); // "..."

// We can now write directly to the new source
const currentSonSource = Fluid.read(_son_);
Fluid.write(currentSonSource, "I want to be a musician");

console.log(Fluid.read(Fluid.read(_son_))); // "I want to be a musician"
Enter fullscreen mode Exit fullscreen mode

While this example is intentionally simple, this pattern requires careful consideration in real-world applications. When a dependency is switched (e.g., from _youngSon_ to _matureSon_), the old source (_youngSon_) is no longer tracked by _son_, and all subscribes to old source is still active. So, for more complex objects, you should be mindful of memory management and may need to use tools like Fluid.destroy to properly unsubscribe and clean up unused reactive objects, preventing potential memory leaks.

Summary

If you are looking for a robust, low-overhead, and easy-to-adopt reactive library that isn't tightly coupled to a specific framework like React, Fluid might be a great fit for you. It trades implicit convenience for explicit control, giving you the power to build predictable and highly performant state management systems.

Examples?

Sure! Here is a demo using Fluid in React with help of one simple custom hook: codesandbox

Links

  • Source code and documentation: GitHub.
  • Use in your project: NPM.

GitHub logo PunGy / fluid

Library for creating reactive systems with maximum control.

Reactive Fluid

npm

Zero-dependency library for creating reactive systems with maximum control.

Content

Overview

You can read the overview article here: [https://blog.pungy.me/articles/fluid]

Every reactive system has defining characteristics that determine its behavior. Here is a list of them, along with how they are implemented in Fluid:

  • Execution flow: Synchronous
  • Change propagation: Push-Based
  • Update process: Dataflow
  • Dependency graph: Explicitly defined by the programmer (data flow differentiation)
  • Cycle dependencies: Not handled automatically
  • Transactions: Fully supported
  • Evaluation
  • Determinism…




Top comments (2)

Collapse
 
kosomuca profile image
Kosomuca

Best Introduction i've ever seen! 🤩

Collapse
 
__1dd86337d751 profile image
Роман Минкин

I completely agree with your assessment. Fluid offers a streamlined and independent approach to managing reactive data. I particularly appreciate how Fluid adopts reactivity principles from my preferred framework Vue. What do you consider to be the most compelling feature of this library?