Reactivity and Rendering
How state changes reach the screen — and why there's more than one way to wire that up
Learning Objectives
By the end of this module you will be able to:
- Explain how the observer pattern drives automatic UI updates, and identify the memory leak risk it introduces.
- Compare runtime dependency tracking (Vue Proxy), compile-time inference (Svelte), and explicit arrays (React), and state when each has the edge.
- Articulate why unidirectional data flow was invented and what it makes possible — including time-travel debugging and predictable state changes.
- Identify scenarios where RxJS/FRP adds clear value versus scenarios where it adds unjustified complexity.
- Recognize stale closure bugs in React's
useEffectdependency arrays and explain how to prevent them.
Core Concepts
The observer pattern is the foundation
Every reactive UI framework is, at its core, an implementation of the observer pattern: a subject maintains a list of observers and notifies them when its state changes. What varies between frameworks is how subscriptions are registered, and how they are cleaned up.
The canonical GoF definition applies directly: one-to-many dependency, subject notifies observers without needing to know them individually. The DOM's own EventTarget.addEventListener is the most familiar version of this — and it is also the most familiar source of memory leaks.
Unsubscribed event listeners and observers maintain references to components and data structures even after those components are removed from the screen. Garbage collection cannot reclaim them. This is the frontend equivalent of a file descriptor leak: invisible, cumulative, and eventually catastrophic.
React requires explicit cleanup functions returned from useEffect. Vue 3 automatically cleans up watchers created synchronously in component setup. MobX and RxJS require manual unsubscription or rely on scope management utilities.
Three approaches to dependency tracking
All frameworks solve the same problem: which parts of the UI depend on which pieces of state? They differ in when and how they figure that out.
Vue 3 — runtime tracking via Proxy
Vue 3 wraps reactive objects in JavaScript Proxy. During a component's render pass, every property access is intercepted and registered as a dependency. When that property later changes, only the components that accessed it are re-rendered. No developer declaration needed. Vue 3 also handles deeply nested objects and arrays more robustly than Vue 2's Object.defineProperty-based system, which required manual setup for nested changes.
Svelte — compile-time static analysis
Svelte's compiler analyses your component source at build time to infer which variables affect which DOM nodes, then emits targeted imperative DOM update instructions. There is no virtual DOM and no runtime dependency graph. The tradeoff is that compile-time inference is inherently imperfect: extracting reactive assignments into functions can break reactivity silently, because the compiler loses the direct write-to-DOM signal chain.
React — explicit dependency arrays
React takes the opposite stance: you declare what a useEffect, useMemo, or useCallback depends on. The system trusts that declaration. If it is incomplete, the function captures a stale closure — a snapshot of the variable at setup time that never updates. This is the most common source of subtle bugs for engineers new to React.
Unidirectional data flow: why it was invented
Frontend MVC (a direct import from server-side patterns) allowed bidirectional dependencies between models and views. In a large application, a view update could change a model, which notified a second view, which updated a second model, producing cascading updates that were nearly impossible to trace.
Facebook built Flux explicitly to solve this: enforce a single direction — user action → dispatcher → stores → views. The consequence was not just cleaner code; it made discrete, serializable state transitions possible.
Redux extended this into time-travel debugging: because every state change is a discrete, serializable action, Redux DevTools can replay actions forward and backward to inspect every intermediate state. This capability is only practical in a unidirectional architecture. In a bidirectional system, state transitions are not discrete events; they are entangled mutations.
Time-travel debugging is not a novelty feature. It is a direct consequence of treating state changes as a log of serializable events — the same principle behind event sourcing.
Controlled components in React are the unidirectional pattern applied to form inputs: onChange updates state, value is set from state, never directly from the view. More boilerplate than Angular's ngModel or Vue's v-model, but the explicit handler is a place to intercept, validate, and transform input before it touches state.
Vue's v-model looks like bidirectional binding but is syntactic sugar over a unidirectional props + events pattern. The underlying flow remains unidirectional.
Functional Reactive Programming and RxJS
FRP treats events as first-class streams. Instead of callbacks, you compose operators over streams of values that arrive over time.
The clearest use cases are those where multiple async sources need coordination and where timing semantics matter:
- Type-ahead search:
debounceTime()waits for input to stabilize;switchMap()cancels the previous HTTP request when a new keystroke arrives. Both operators express timing intent that would require manualsetTimeoutmanagement and cancel flags in imperative code. - WebSocket streams: A single subscription can be split into separate derived streams by type using
filter()andmap(), without manual fan-out logic. - Multi-source coordination:
merge()combines independent event sources into a single stream, eliminating separate subscriptions and manual aggregation.
Observables differ from Promises in one structurally important way: they emit zero, one, or many values over time, rather than a single resolved or rejected value. This makes them the correct representation for data streams, not arrays of Promises.
RxJS also handles resource cleanup correctly: unsubscribing automatically tears down WebSockets, timers, and long-polls.
RxJS has a steep learning curve. Developers must master observable fundamentals, operator semantics (especially higher-order operators like switchMap vs concatMap vs mergeMap), subscription management, and concurrency mental models. For simple CRUD UIs, basic forms, and straightforward data display, this overhead is not justified. Apply it where event streams and timing genuinely need composing — not as a default abstraction.
Compare & Contrast
Runtime tracking vs compile-time inference vs explicit arrays
| Dimension | Vue 3 (Proxy) | Svelte (compile) | React (explicit) |
|---|---|---|---|
| Dependency registration | Automatic, at runtime | Automatic, at build time | Manual, via arrays |
| Missed dependency | Impossible | Possible (function extraction) | Common (stale closures) |
| Runtime overhead | Proxy interception on every access | None — emits imperative code | Virtual DOM diffing |
| Debugging reactive chains | Reactivity in devtools | Compile output to inspect | Dependency array linting |
| When it has the edge | Nested/dynamic data structures | Minimal runtime, compile-time guarantees | Explicit control, large ecosystem |
Unidirectional vs bidirectional
| Dimension | Unidirectional (React, Flux/Redux) | Bidirectional (Angular ngModel, Vue v-model) |
|---|---|---|
| Form boilerplate | High — explicit handlers required | Low — automatic model-view sync |
| Traceability | High — single pathway for all changes | Lower — changes can originate anywhere |
| Time-travel debugging | Practical | Impractical |
| Circular update risk | None by design | Possible; requires careful handling |
| Shared state at scale | Predictable (Redux, Pinia) | Can degrade with deep watchers |
RxJS vs async/await + Promises
| Dimension | RxJS / FRP | async/await + Promises |
|---|---|---|
| Multiple values over time | Native | Requires manual loops or AsyncIterator |
| Request cancellation | switchMap() | Requires AbortController manually |
| Debouncing/throttling | Operators built-in | Manual setTimeout / flags |
| Learning curve | High | Low |
| Right for CRUD forms | No | Yes |
| Right for live data streams | Yes | Awkward |
Common Misconceptions
"Vue's v-model is bidirectional binding that breaks unidirectional flow"
It is not. v-model is syntactic sugar over a prop passed down and an event emitted up. The underlying data flow remains strictly unidirectional. The convenience syntax hides the pattern, it does not replace it.
"React is fully functional reactive programming"
React was influenced by FRP ideas but is not a true FRP system. It uses a push-pull hybrid: state changes push an invalidation signal, then React pulls the updated UI by re-executing component functions and diffing the result. True FRP (and SolidJS more closely) uses fine-grained push where the system knows precisely which DOM nodes depend on which signals and updates only those. The semantic difference matters when reasoning about update propagation.
"Stale closures are a bug in React"
They are a consequence of explicit dependency arrays, not a defect. A useEffect callback captures the variables in scope at the time it is defined. If the dependency array is incomplete, the callback runs with the values from that earlier closure. The fix is to declare all used variables in the dependency array, or use the functional update form of useState (setState(prev => ...)) when only the previous value matters.
"Svelte's compile-time approach means no reactivity bugs"
Compile-time analysis is static. If reactive state is read inside a function called from a reactive block rather than directly in the reactive block, the compiler may not detect the dependency. Reactivity can be silently lost. This is a different failure mode than stale closures, but it is still a failure mode.
Worked Example
Scenario: type-ahead search with cancellation
The requirement is a search input that fires an API request after the user stops typing, cancels the previous request if a new keystroke arrives before it completes, and never shows stale results.
Imperative approach (before FRP)
let timeout;
let abortController;
input.addEventListener('input', (e) => {
clearTimeout(timeout);
abortController?.abort();
abortController = new AbortController();
timeout = setTimeout(async () => {
const results = await fetch(`/search?q=${e.target.value}`, {
signal: abortController.signal
}).then(r => r.json()).catch(() => []);
renderResults(results);
}, 300);
});
This works, but the timing logic and cancellation logic are manually threaded through the callback. Add error handling, loading states, and minimum character validation and the branching grows quickly.
RxJS approach
import { fromEvent } from 'rxjs';
import { debounceTime, filter, switchMap, map, catchError } from 'rxjs/operators';
import { ajax } from 'rxjs/ajax';
import { EMPTY } from 'rxjs';
fromEvent(input, 'input').pipe(
map(e => e.target.value),
filter(query => query.length >= 2),
debounceTime(300),
switchMap(query =>
ajax.getJSON(`/search?q=${query}`).pipe(
catchError(() => EMPTY)
)
)
).subscribe(results => renderResults(results));
Each operator states what the pipeline should do at that point. debounceTime handles the stabilization window. switchMap handles cancellation — when a new value arrives, it automatically unsubscribes from the previous inner observable, which cancels the in-flight request. Error handling is isolated inside the inner pipe so a failed request doesn't terminate the outer stream.
Decision point: If this is the only interactive element in the application, the RxJS version may not justify its learning overhead. If the application also manages WebSocket updates, polling, and multi-source coordination, establishing the FRP model once pays dividends across every async concern.
Boundary Conditions
Where Vue's Proxy tracking breaks
Proxy interception only works on reactive objects created through Vue's reactivity system. If you extract a primitive value from a reactive object (const { count } = reactive({ count: 0 })), the extracted count loses reactivity — it becomes a plain number. Vue's ref and toRefs exist precisely for this case. The framework cannot track reads on values that bypass the Proxy boundary.
Where Svelte's compile-time analysis falls short
Svelte's static analysis works on direct assignments in component scope. If reactive state is read or written inside a module-level utility function, the compiler cannot see the reactive dependency and will not generate update code. This means Svelte components that extract logic into shared files may silently lose reactivity. The pattern that works transparently in Vue or React can break quietly in Svelte.
Where React's explicit arrays become unmanageable
useEffect dependency arrays grow with every variable the effect uses. For effects with complex, interdependent logic, keeping the array complete while avoiding infinite re-render loops requires careful design. The common escape hatch — useRef to hold a mutable value without triggering re-renders — breaks the declarative contract: the component now has imperative, non-reactive state. This is a recognized boundary condition, not a misuse, but it signals that the component may need structural rethinking.
Where unidirectional flow adds friction without return
For isolated, low-stakes form inputs that touch no shared state, the explicit onChange + controlled value pattern in React adds boilerplate with no architectural benefit. Libraries like react-hook-form exist partly to recover the ergonomics of uncontrolled inputs for these cases while still integrating with controlled state where it matters.
Where RxJS creates more problems than it solves
RxJS operators like concatMap and exhaustMap behave differently from switchMap and mergeMap in ways that are not obvious until a race condition surfaces in production. Choosing the wrong higher-order operator is a category of bug that does not exist in imperative code. The complexity is justified when the coordination problem is genuinely complex; it is unjustified when the same outcome is achievable with an await and an AbortController.
Key Takeaways
- The observer pattern drives all UI reactivity. Frameworks differ in how they register dependencies (runtime Proxy, compile-time analysis, explicit arrays) and how they clean up subscriptions. Leaked subscriptions cause memory leaks with the same operational consequences as resource leaks in backend systems.
- Dependency tracking strategy determines the failure mode. Vue's Proxy tracking eliminates missed dependencies at the cost of runtime overhead. Svelte's compile-time inference eliminates runtime overhead but can silently lose reactivity when logic is extracted to functions. React's explicit arrays put the burden on the developer and produce stale closure bugs when dependency declarations are incomplete.
- Unidirectional data flow was invented to make state changes traceable and debuggable. Flux and Redux were direct responses to MVC's bidirectional dependency problems. Time-travel debugging is a consequence of the same principle behind event sourcing: state changes are a log of serializable events that can be replayed.
- RxJS excels at timing and multi-source coordination. Debounced inputs, request cancellation, WebSocket fan-out, and multi-source merging are its natural territory. For basic CRUD and simple forms, async/await is the right tool and RxJS adds unjustified complexity.
- React's controlled component pattern and Vue's v-model both implement unidirectional flow. v-model is syntactic sugar over props and events; it does not break the unidirectional contract. React's controlled components require more boilerplate but make every state transition explicit and interceptable.
Further Exploration
Observer pattern and reactivity fundamentals
- EventTarget.addEventListener — MDN — The DOM's native observer pattern implementation; the basis for understanding subscription and cleanup.
- Vue 3 Reactivity in Depth — How Proxy-based tracking works, including the dependency graph and computed values.
- Angular Signals RFC — Angular's approach to fine-grained reactivity, including how it avoids diamond glitch inconsistencies.
Unidirectional data flow and Flux
- Flux Architecture — Facebook — The original rationale for unidirectional flow and the problems with MVC bidirectionality.
- Redux DevTools and time-travel debugging — What serializable, discrete state transitions make possible.
- Vue v-model documentation — How v-model compiles to props and events, preserving unidirectionality.
RxJS and FRP
- RxJS: Observable — The structural difference between Observables and Promises, and why streams require a different model.
- RxJS operator decision tree — A practical guide to choosing between `switchMap`, `mergeMap`, `concatMap`, and `exhaustMap`.
- SolidJS reactivity overview — A closer approximation of true FRP in a UI framework, for contrast with React's push-pull hybrid.