Feel the new experience of the streaming programming paradigm in code organization, code maintenance, code debugging, and page rendering for vue, and carry out reactive to the end!
Background
Frontend Business Model
In the field of web development, the core responsibility of backend services is to receive, process, and respond to requests. From a system modeling perspective, this is a typical single-input, single-output processing flow.
This characteristic makes backend service logic naturally suitable for abstraction into the Onion Model: outer middleware wraps inner middleware, requests penetrate each layer of middleware in sequence to reach the core business logic, and then responses are returned layer by layer. This programming paradigm aligns perfectly with the business model of backend services.
In contrast, the frontend business model is fundamentally different. Frontend applications run in the user's browser and are essentially complex reactive systems with multiple inputs and outputs. They directly interact with users, and input sources are extremely diverse: user operations (clicks, inputs, scrolling), system events (window resizing, timer triggers), network responses (API calls, WebSocket messages), etc. These events are unpredictable in time and have completely heterogeneous frequencies, forming a highly asynchronous, event-driven execution environment.
Meanwhile, the outputs of frontend systems are equally complex and diverse, no longer limited to one-time response returns, but continuously and dispersedly acting on multiple targets. Typical outputs include: DOM updates (reflecting state changes), visual feedback (animations, transitions), network communication (API requests, data submission), state synchronization (cache updates, local storage), etc.
The complex input and output methods of frontend systems stand in sharp contrast to the single-input, single-output nature of backend services. What should the frontend programming paradigm be?
Evolution of Frontend Frameworks
To cope with the highly asynchronous, event-driven, multi-input, multi-output complex environment in frontend applications, the frontend development community has gradually evolved framework-based programming paradigms, with MVVM (Model-View-ViewModel) being the most representative architectural pattern.
The core idea of this paradigm is: decouple the page's display logic from state logic, and enable automatic collaboration through reactive binding.
The greatest advantage of MVVM frameworks is that when the Model changes, the View updates automatically; conversely, when the user operates the View, changes are automatically reflected in the Model.
This "automatic synchronization" mechanism is essentially a form of declarative reactive programming. Developers no longer need to explicitly organize input events or manually update views, but focus on describing "how state maps to the interface," leaving the framework to handle specific event listening and DOM updates.
However, this reactivity only addresses half the problem—i.e., the response between VM and V. It does not solve the issue of how to organize the business model (i.e., the Model), which is instead uniformly encapsulated by components.
Layered Architecture for Complex Business
As business complexity grows exponentially, the component-based programming paradigm of mainstream frontend frameworks gradually exposes architectural limitations.
Mainstream frontend frameworks generally adopt a development model centered on components, with Hooks/Composition API as the logical granularity, encapsulating business logic within components. Code organization in Hooks/Composition follows a paradigm similar to OOP: abstracting into data + methods to modify data. However, unlike OOP, composition takes precedence over inheritance.
Frameworks also support reactive programming for logic (e.g., watch, useEffect), but the code-reading experience is often poor, so most developers still use imperative programming:
In this model, data fetching and business logic processing are concentrated within components. Business functions are organized through the hierarchical relationships of the component tree, with data and logic flows passing along component hierarchies, and asynchronous method callbacks scattered across components. This architecture works reasonably well for small to medium-sized applications, but in enterprise-level complex business scenarios, it leads to the following architectural issues:
- Bloated components: Data requests, data transformation, and data logic processing are all piled into components.
- Difficult code reading: Business logic is scattered across components, requiring tracing along component chains to understand the business.
- Complex communication: Components are deeply nested, making communication complicated and hard to understand.
- Troubleshooting difficulties: Locating issues requires debug along component chains, resulting in high costs.
- Poor reusability: Differences in views prevent data processing and business logic from being reused.
- Duplicate requests: Data requests within components make reusable data difficult to share.
- High complexity: Data flow presents spiral network calls, affecting everything when one thing changes.
From an architectural design perspective, the state of a frontend application is a dynamic combination of data and logic. Side-effect operations such as URL routing, user interactions, timed tasks, and HTTP requests continuously drive state changes, while the UI interface is essentially a snapshot of the application state at a specific moment.
Coupling state logic directly with the view layer is an architectural inversion. The correct approach is to decouple state management from the view layer, build an independent business model layer, and make the view a consumer of state:
After the business model is extracted from components, components become controlled components focused on data display and user interaction, with business logic handled by an independent model layer. The core problem then becomes: How to effectively organize and manage these business models outside the Vue/React component system?
Model-Driven and Streams
When constructing business models, it is common to extract highly cohesive logic into modules—the core of the business model, which carries the core data and logic of the business:
Theoretically, business models are encapsulations of highly cohesive data and logic, making traditional OOP (object-oriented programming) seem like a natural choice. However, frontend business models are highly asynchronous, event-driven, and multi-input/output, making traditional OOP encapsulation in such scenarios lead to numerous complex asynchronous call chains and callback nesting.
These call chains execute asynchronously and often span multiple business domains, significantly increasing the cost of understanding and maintaining code. Vue's reactive system, while powerful, may exacerbate issues in complex business scenarios: triggers for data modifications are hard to locate, propagation paths of side effects are unpredictable, and overall data flows become difficult to trace and debug.
In such cases, organizing these business models in a pipeline format—connecting core asynchronous workflows through pipelines—can effectively solve the above problems:
This pipeline structure is well-suited for construction using streams. Streams are high-level abstractions for declarative asynchronous programming. Through the combination of stream operators, complex asynchronous orchestration can be handled elegantly, fundamentally solving the complexity of traditional callbacks and asynchronous chain calls.
If streams, in addition to regular pipeline capabilities, can also carry logic and data, then the concepts of Data and Methods in traditional business models can be unified into stream nodes, achieving integrated management of data and logic:
Frontend business models built on streams perfectly align with the inherently asynchronous, event-driven, multi-input/output nature of modern frontend applications, providing more elegant and maintainable solutions for data management and business logic organization.
Streams
RxJS is a typical representative of stream programming. It is powerful and provides rich stream operators for handling complex asynchronous logic. However, RxJS has many concepts, a steep learning curve, and is relatively complex to use, making it unsuitable as an infrastructure for carrying business logic.
fluth adopts a Promise-like stream programming paradigm. Promises are the most commonly encountered asynchronous stream programming paradigm in frontend development. This Promise-like approach significantly lowers the barrier to stream programming, allowing streams to serve as the most basic logical unit in frontend development.
In addition to reducing the mental burden of stream programming, fluth also stores processed data for each stream node, enabling nodes to carry both logic and data—serving as a fundamental unit to replace reactive data like ref and reactive:
import { $ } from "fluth";
const userInfo$ = $({ name: "fluth", age: 18, role: "admin" });
const isAdult$ = userInfo$.thenImmediate((value) => value.age >= 18);
const isAdmin$ = userInfo$.thenImmediate((value) => value.role === "admin");
userInfo$.value; // { name: "fluth", age: 18, role: "admin" }
isAdult$.value; // true
isAdmin$.value; // true
userInfo$.set((value) => {
value.age = 17;
value.role = "user";
});
userInfo$.value; // { name: "fluth", age: 17, role: "user" }
isAdult$.value; // false
isAdmin$.value; // false
To enable large-scale use and integration into frameworks, fluth-vue enhances fluth with the following capabilities:
Reactive Capabilities
For the Vue framework, reactive data such as ref, reactive, and computed can be converted into fluth streams using the to$ method. To maintain the immutability of fluth streams, data is deep-cloned before being passed to fluth.
In fluth-vue, stream data is read-only reactive data, which can be used normally in templates, watch, and computed, or converted to computed reactive data using the toCompt method. The framework can directly consume stream data, and stream values can be viewed via vue-devtools.
Reactive Data
As shown below, $("fluth") is fully equivalent to ref("fluth") except for data modification:
<template>
<div>
<p>{{ name$ }}</p>
</div>
</template>
<script setup>
import { watch, computed } from "vue"
import { $ } from "fluth-vue";
const name$ = $("fluth");
const computedName = computed(() => name$.value);
watch(name$, (value) => {
console.log(value);
});
</script>
Reactive Updates
Data modification must use fluth's next and set methods:
import { $ } from "fluth-vue";
const stream$ = $({ obj: { name: "fluth", age: 0 } });
stream$.set((value) => (value.obj.age += 1));
After modifying data via next or set, not only will Vue's reactive updates be triggered, but the stream will also push data, and all subscribed nodes will receive the data.
A New Dimension of Programming
In fluth-vue, in addition to reactivity, streams represent a new programming dimension where frontend logic can be constructed. Compared to basic JavaScript capabilities like if-else, for, switch, or Vue's ref, reactive, computed, and watch, the rich operators provided by streams act like a higher-level language. Here’s an example:
A stream that needs to be audited by another stream—only after the auditing stream pushes data will the latest data of this stream be pushed—can be implemented with the audit operator:
After all streams complete a round of data pushing, the last data from each stream in this round is collected into an array and pushed.
This can be achieved with a promiseAll-like operator, which also handles pending states of nodes—a task that would require extensive code with traditional approaches.
Decoupling Data and Reactivity
With ref or reactive, data and reactivity are integrated: modifying data triggers reactivity. However, streams can decouple data from reactivity:
const wineList = $(["Red Wine", "White Wine", "Sparkling Wine", "Rosé Wine"]);
const age$ = $(0);
const availableWineList = age$
.pipe(filter((age) => age > 18))
.then(() => wineList.value);
Only when age is greater than 18 can the latest value of wineList be obtained. However, subsequent immutable modifications to wineList will not trigger recalculation or value changes of availableWineList—only changes to age will trigger re-fetching of availableWineList.
Immutable Data Capabilities
As data flows through nodes, each node processes the data and passes the result to the next node, with each node retaining its processed data.
To achieve this, fluth uses limu's immutability capabilities, ensuring isolation between data through immutability so that each node has uncontaminated data. fluth provides methods like set, thenSet, thenImmediateSet, thenOnceSet, and the set operator for immutable processing of nodes.
Debugging Capabilities
fluth offers rich debugging plugins:
Logging Plugins
The consoleNode plugin enables easy logging of stream node data:
import { $, consoleNode } from "fluth-vue";
const data$ = $().use(consoleNode());
data$.next(1); // Logs: resolve 1
data$.next(2); // Logs: resolve 2
data$.next(3); // Logs: resolve 3
data$.next(Promise.reject(4)); // Logs: reject 4
The consoleAll plugin allows viewing data from all stream nodes:
import { $, consoleAll } from "fluth-vue";
const data$ = $().use(consoleAll());
data$
.pipe(debounce(300))
.then((value) => {
throw new Error(value + 1);
})
.then(undefined, (error) => ({ current: error.message }));
data$.next(1)
// Logs: resolve 1
// Logs: reject Error: 2
// Logs: resolve {current: '2'}
Breakpoint Plugins
The debugNode plugin facilitates debugging of stream node data and allows viewing the call stack of stream nodes:
import { $, debugNode } from "fluth-vue";
const stream$ = $(0);
stream$.then((value) => value + 1).use(debugNode());
stream$.next(1);
// Triggers debugger breakpoint
Conditional Debugging
import { $ } from "fluth-vue";
import { debugAll } from "fluth-vue";
// Only trigger debugger for string types
const conditionFn = (value) => typeof value === "string";
const stream$ = $().use(debugNode(conditionFn));
stream$.next("hello"); // Triggers debugger
stream$.next(42); // Does not trigger debugger
The debugAll plugin enables debugging of all stream nodes and their call stacks:
import { $, debugAll } from "fluth-vue";
const data$ = $().use(debugAll());
data$.then((value) => value + 1).then((value) => value + 1);
const updateData$ = () => {
data$.next(data$.value + 1);
};
// Triggers debugger breakpoints at each node in the browser dev tools
// Three nodes in total, so three breakpoints are triggered
Logging and debugging plugins completely transform the experience of debugging complex Vue objects.
Streaming Rendering Capabilities
fluth-vue stream data is reactive and can be rendered normally in templates. Additionally, fluth-vue provides powerful streaming rendering via render$, enabling element-level or block-level rendering—similar to signal or block signal rendering.
Element-Level Rendering
import { defineComponent, onUpdated } from "vue";
import { $, effect$ } from "fluth-vue";
export default defineComponent(
() => {
const name$ = $("hello");
onUpdated(() => {
console.log("Example component updated");
});
return effect$(() => (
<div>
<div>
Name: {name$.render$()}
</div>
<button onClick={() => name$.set((v) => v + " world")}>Update</button>
</div>
));
},
{
name: "Example",
},
);
Clicking the button only modifies the content of name$.render$() within the div, without triggering the component's onUpdated lifecycle.
Block-Level Rendering
import { defineComponent, onUpdated, h } from "vue";
import { $, effect$ } from "fluth-vue";
export default defineComponent(
() => {
const user$ = $({ name: "", age: 0, address: "" });
const order$ = $({ item: "", price: 0, count: 0 });
return effect$(() => (
<div class="card-light">
<div> example component </div>
<div>render time: {Date.now()}</div>
<section style={{ display: "flex", justifyContent: "space-between" }}>
{/* use$ emit data only triggers render content update */}
{user$.render$((v) => (
<div key={Date.now()} class="card">
<div>user$ render</div>
<div>name:{v.name}</div>
<div>age:{v.age}</div>
<div>address:{v.address}</div>
<div>render time: {Date.now()}</div>
</div>
))}
{/* order$ emit data only triggers render content update */}
{order$.render$((v) => (
<div key={Date.now()} class="card">
<div>order$ render</div>
<div>item:{v.item}</div>
<div>price:{v.price}</div>
<div>count:{v.count}</div>
<div>render time: {Date.now()}</div>
</div>
))}
</section>
<div class="operator">
<button class="button" onClick={() => user$.set((v) => (v.age += 1))}>
update user$ age
</button>
<button
class="button"
onClick={() => order$.set((v) => (v.count += 1))}
>
update order$ count
</button>
</div>
</div>
));
},
{
name: "streamingRender",
},
);
After user$ or order$ streams update, only the content inside the render$ function will be updated, without triggering the component's virtual DOM diff or the update lifecycle.
Once streams can control rendering, there are numerous possibilities. For example, user$.pipe(debounce(300)).render$ 😋 – I won't elaborate further here.
Code Organization Capabilities
The stream programming paradigm aligns highly with frontend business models, and this is particularly evident in code organization.
Let’s use a simple example – an order form submission page – to demonstrate how streams are applied in business models:
Traditional frontend development uses an imperative programming model:
- After clicking the button, call the handleSubmit method.
- handleSubmit first calls the validateForm method; if validation fails, display an error.
- If validation passes, assemble the data required by the backend.
- Call the backend fetchAddOrderApi method.
- If the call succeeds, continue calling handleDataB and handleDataC.
- If it fails, display an error.
This is part of most frontend developers’ daily work, but "daily" doesn’t mean "ideal." This imperative model, which mixes synchronous logic with asynchronous operations, causes handleSubmit to grow bloated and less reusable as business complexity increases.
Let’s reimplement this using the declarative stream programming approach:
Following the business logic, the code is implemented with six streams: form$, trigger$, submit$, validate$, payload$, and addOrderApi$. Each stream carries independent logic, and their order follows the actual business flow. form$ and trigger$ convert user input into streams, while validate$ and addOrderApi$ pass processing results back to the user.
The code reveals the following advantages:
- Improved reusability: Logic is fully atomized with stream programming. Streams can be split or merged to easily combine these logical atoms, dramatically increasing code reusability.
- Enhanced maintainability: Code is organized in the order of actual business processes. While this may not seem obvious with a single handleSubmit method, organizing code by business sequence significantly improves readability and maintainability in complex scenarios.
- Stronger expressiveness: Operators like audit, debounce, and filter handle complex asynchronous logic (triggers, throttling, conditional filtering) declaratively, making code much more expressive.
- Inversion of control: Unlike the "pull" model of method calls, stream programming uses a "push" model. This allows data, data-modifying methods, and trigger actions to be placed in the same folder, eliminating the need to search globally for where module-internal data is modified.
Advantages in Reusability and Maintainability
In imperative programming, future iterations of handleSubmit may require scenario-based logic:
- Scenario A: After fetchAddOrderApi succeeds, only call handleDataB.
- Scenario B: After fetchAddOrderApi succeeds, only call handleDataC.
In this case, handleSubmit must use if-else with scenario parameters, causing the function to bloat as more branches are added. Stream programming solves this easily:
- If scenarios are streams, they can be combined directly:
// Scenario A stream
const caseA$ = $();
addOrderApi$.pipe(audit(caseA$)).then(handleDataB);
caseA$.next();
// Scenario B stream
const caseB$ = $();
addOrderApi$.pipe(audit(caseB$)).then(handleDataC);
caseB$.next();
- If scenarios are data-based, they can be handled with splitting or filtering:
// Scenario stream (either "A" or "B")
const case$ = $<"A" | "B">();
// Method 1: Split the stream
const [caseA$, caseB$] = partition(case$, (value) => value === "A");
addOrderApi$.pipe(audit(caseA$)).then(handleDataB);
addOrderApi$.pipe(audit(caseB$)).then(handleDataC);
// Method 2: Filter the stream
const caseAA$ = addOrderApi$
.pipe(filter(() => case$.value === "A"))
.then(handleDataB);
const caseBB$ = addOrderApi$
.pipe(filter(() => case$.value === "B"))
.then(handleDataC);
The atomicization of logical code, combined with stream splitting and merging, makes fluth-vue highly effective in code organization.
Refactoring Advantages
The example above is simple, but in complex business scenarios using traditional development models, a setup function might contain a dozen refs and dozens of methods. If setup were a class, it would have over a dozen properties, dozens of methods, and messy "hole-punching" watch logic – making reading and maintenance extremely costly.
While finer-grained component extraction and hooks help partially, the reality is that many existing business applications are still built with bloated setup functions. Once setup becomes this bloated "class," subsequent developers can only continue "hacking" on it.
Stream programming solves this. When developing with fluth-vue from the start, code grows with business iterations, but it remains organized declaratively in the order of actual business processes – like an extending thread. To extract logic, you simply cut the thread into segments and place them in hooks, with no mental burden. Even heavy business logic can be refactored in minutes.
Conclusion
Through developing and debugging with stream programming in real business scenarios, I’ve found that streams are severely underestimated in frontend development. Perhaps RxJS’s complexity led people to see streams as an "overpowered tool" only for complex asynchronous data flows. But even the simplest ref("string") can be replaced with $("string") for significant benefits.
fluth-vue truly brings stream programming to Vue developers: making streams the fundamental data form in frontend development, perfectly compatible with reactivity, and extending reactivity to its full potential – not just between data and views, but also for logically organizing business logic reactively.
From practical experience, stream programming is naturally aligned with the asynchronous, event-driven nature of frontend business and is an ideal choice for organizing frontend logic.
Finally, the project is open-source 🎉🎉🎉 – welcome to star it ⭐️⭐️⭐️!
https://github.com/fluthjs/fluth-vue
https://github.com/fluthjs/fluth
Top comments (0)