Skip to content

Prior Art and Design Rationale

How SynState Solved the Glitch explained how SynState achieves glitch-free propagation through a static dependency graph, in which each node’s place in the update order is fixed when it is constructed. This page examines the historical context of this approach and explains why SynState chose a static graph while most modern state management libraries use dynamic graphs.

SynState’s key insight is:

The dependency graph topology is static — it is determined when Observables are constructed and does not change between updates. This means each node’s depth, its place in the propagation order, can be computed once at graph construction time, rather than re-derived on each emission, and an update needs only one bucket per depth rather than a priority queue.

This is not a novel idea. Each of its components — static dependency graphs, topological ordering for glitch avoidance, and the observation that static graphs enable one-time scheduling — has been described in academic literature, with the earliest work dating back to 1987.

Static Scheduling of Dataflow Graphs (1987)

Section titled “Static Scheduling of Dataflow Graphs (1987)”

Lee & Messerschmitt’s “Static Scheduling of Synchronous Data Flow Programs” (IEEE Transactions on Computers, 1987) is the most direct precedent. In Synchronous Dataflow (SDF), where each node’s data production and consumption rates are specified a priori, “the scheduling of SDF nodes need not be done at runtime, but can be done at compile time (statically), so the run-time overhead evaporates.” This is exactly SynState’s insight, applied to signal processing nearly four decades ago.

Topological Sort for Glitch Avoidance (2006+)

Section titled “Topological Sort for Glitch Avoidance (2006+)”

The use of topological ordering to prevent glitches in reactive systems is well-established:

  • FrTime (Cooper & Krishnamurthi, ESOP 2006) assigns each node a height greater than its dependencies and propagates via a priority queue in height order. Because FrTime supports dynamic graphs, it must “dynamically recompute the sort order when the structure of some portion of the graph changes” — the converse proof that static graphs do not need re-computation.
  • Elm’s original FRP model (Czaplicki & Chong, PLDI 2013) used a static signal graph determined at program initialization, with the type system restricting reactive primitives to ensure a fixed graph structure.
  • Scala.React (Maier & Odersky, 2012) guarantees consistency through two-phase propagation cycles based on topological ordering.

Bainomugisha et al.’s “A Survey on Reactive Programming” (ACM Computing Surveys, 2013) explicitly distinguishes static and dynamic dependency graphs and documents that “topologically sorting expressions and updating values in topological order” is the standard technique for glitch-free propagation.

Several open-source projects have implemented static-graph approaches similar to SynState’s:

LibraryApproach
TopologicaDependencies declared at construction time; propagation uses topological sort to guarantee each node is set exactly once per update cycle
ReactiveModelPredecessor to Topologica; processes changes using an explicit topological sort algorithm on the dependency graph
Storm.NET“Simple Topologically Ordered Reactive Model” with comparison-based update skipping

Meanwhile, most modern UI-focused reactive libraries — Angular Signals, Preact Signals, SolidJS, MobX, Vue, and Jotai — use dynamic dependency graphs where the set of dependencies is determined at runtime and can change between evaluations.

Why Most Modern Libraries Use Dynamic Graphs

Section titled “Why Most Modern Libraries Use Dynamic Graphs”

A common claim is that dynamic dependency graphs are more expressive than static ones — that certain patterns require the ability to change dependencies at runtime. Examining the concrete examples typically cited reveals that, at least for global state management use cases, static graphs can achieve equivalent results.

In Jotai, a derived atom can conditionally read different atoms:

const temperatureAtom = atom((get) => {
const mode = get(modeAtom);
return mode === 'celsius' ? get(celsiusAtom) : get(fahrenheitAtom);
});

When modeAtom is 'celsius', changes to fahrenheitAtom do not trigger recalculation because the dynamic tracker knows fahrenheitAtom was not accessed.

In SynState, the same logic is expressed with a static graph:

const temperature$ = derive([mode$, celsius$, fahrenheit$], (mode, c, f) =>
mode === 'celsius' ? c : f,
);

When fahrenheit$ changes in celsius mode, the projection of derive returns the same value as before, and SynState’s equality comparison stops the update there. The “oversubscription” cost is one function call plus one equality comparison. Dynamic tracking avoids that call, and pays instead for keeping the dependency set up to date: every rerun of a derivation compares what it read with what it read last time, and a mode switch also removes subscriptions and adds new ones. Which of the two costs more depends on how often the inactive branch is written, compared with how often the active one is and how often the mode switches; the benchmark below measures the case where only the inactive branch is written.

Jotai’s atoms-in-atom pattern gives each list item its own atom, so updating one item only re-renders that item’s component:

import { atom, useAtom, type PrimitiveAtom } from 'jotai';
import * as React from 'react';
const todosAtom = atom([atom('Todo 1'), atom('Todo 2')]);
const TodoItem = ({
todoAtom,
}: Readonly<{
todoAtom: PrimitiveAtom<string>;
}>): React.JSX.Element => {
const [todo, setTodo] = useAtom(todoAtom);
return (
<input
value={todo}
onChange={(e) => {
setTodo(e.target.value);
}}
/>
);
};
const TodoList = (): React.JSX.Element => {
const [todos, setTodos] = useAtom(todosAtom);
const addTodo = React.useCallback((): void => {
// jotai's `SetStateAction` wants a mutable array, which the readonly result
// of `Arr.toPushed` does not satisfy.
// eslint-disable-next-line ts-data-forge/prefer-canonical-array-slicing
setTodos((prev) => [...prev, atom('')]);
}, [setTodos]);
return (
<div>
{todos.map((todoAtom, i) => (
<TodoItem key={i} todoAtom={todoAtom} />
))}
<button onClick={addTodo}>{'Add'}</button>
</div>
);
};

In SynState, the collection is a single Observable, and per-item derived Observables achieve the same granularity:

import * as React from 'react';
import { createState, map } from 'synstate';
import { useObservableValue } from 'synstate-react-hooks';
import { Arr } from 'ts-data-forge';
const [todos$, , { updateState: updateTodos }] = createState<readonly string[]>(
['Todo 1', 'Todo 2'],
);
const TodoItem = ({
index,
}: Readonly<{ index: number }>): React.JSX.Element => {
const todo = useObservableValue(
React.useMemo(
() => todos$.pipe(map((todos) => todos[index] ?? '')),
[index],
),
);
const handleChange = React.useCallback(
(e: React.ChangeEvent<HTMLInputElement>): void => {
updateTodos((prev) =>
prev.map((t, i) => (i === index ? e.target.value : t)),
);
},
[index],
);
return <input value={todo} onChange={handleChange} />;
};
const TodoList = (): React.JSX.Element => {
const todosLength = useObservableValue(
React.useMemo(() => todos$.pipe(map((todos) => todos.length)), []),
);
const addTodo = React.useCallback((): void => {
updateTodos(Arr.toPushed(''));
}, []);
return (
<div>
{Array.from({ length: todosLength }, (_, i) => (
<TodoItem key={i} index={i} />
))}
<button onClick={addTodo}>{'Add'}</button>
</div>
);
};

Each TodoItem subscribes to a derived Observable that extracts its item by index — an O(1) operation. Equality comparison ensures that editing one item does not re-render other items. TodoList subscribes only to todosLength, so editing existing items does not re-render the list itself. Adding and removing items simply updates the collection Observable; no dynamic graph reconstruction is needed.

In Jotai, an async atom only fetches when it is actually read:

const userDataAtom = atom(async (get) => fetchUser(get(authAtom).id));

SynState handles conditional async operations within the static graph using switchMap, fromAbortablePromise, and just:

const userData$ = auth$.pipe(
switchMap((auth) =>
auth
? fromAbortablePromise((signal) => fetchUser(auth.id, signal))
: just(Result.ok(guestData)),
),
);

When auth$ becomes null, the in-flight fetch is automatically aborted and guestData is emitted. The graph structure is fixed at construction time. The abort control is built into switchMap’s semantics, so the resource lifecycle is explicitly expressed in the code.

The Cost of Oversubscription: Benchmark Verification

Section titled “The Cost of Oversubscription: Benchmark Verification”

Michel Weststrate, the creator of MobX, states: “An important principle behind the design of MobX is that a minimal, consistent set of subscriptions can only be achieved if subscriptions are determined at run-time.”1

What does this mean concretely? In MobX and Jotai, a computed or derived atom only subscribes to observables that were actually accessed via .get() during its execution. When a conditional branch changes the execution path, the subscription set itself changes dynamically. In contrast, SynState’s derive subscribes to all sources at construction time, regardless of which branch is currently active.

This difference is most pronounced for updates to observables outside the subscription set. With dynamic subscriptions, no observer is registered on the inactive observable, so update notifications never occur. With static subscriptions, derive fires and incurs the cost of the projection + equality comparison.

To quantitatively verify this, we ran a Conditional Fan-Out benchmark. The results confirm that the oversubscription cost is real. In MobX, the inactive branch’s observer list is empty, making updates zero-cost. Written naively, with a derive over every branch, SynState holds all branches and its cost scales linearly with B; written with switchMap, which subscribes to the selected branch only, it stays flat.

The benchmark keeps the selector fixed, so it measures neither model’s cost of a switch. A dynamic-subscription library rebuilds its subscription set on each switch (unsubscribing from old dependencies and subscribing to new ones), and switchMap replaces its inner subscription.

React Integration Does Not Require Dynamic Graphs

Section titled “React Integration Does Not Require Dynamic Graphs”

It might appear that React’s dynamic component tree necessitates a dynamic dependency graph. SynState’s synstate-react-hooks demonstrates that this is not a requirement.

synstate-react-hooks uses useSyncExternalStore to subscribe to Observables from React components:

[Static graph (SynState)] → Observable
↓
[useSyncExternalStore] → Component subscribes/unsubscribes
↓
[React component tree] → Mount/unmount managed by React
  • The graph remains static — no constraints are imposed by the React integration.
  • Components subscribe and unsubscribe independently, absorbing the dynamic nature of the component tree entirely at the subscription layer.
  • No graph reconstruction occurs when components mount or unmount.

In other words, the state computation graph and UI subscription management can be separated, and making the state graph itself dynamic is not necessary for React integration.

React uses explicit dependency declaration — useMemo(() => ..., [dep1, dep2]). SynState’s pipe(map(...)) and derive([a$, b$], ...) share a similar structure with this declarative model:

ReactSynState
useMemo(() => count * 2, [count])count.pipe(map((n) => n * 2))
useMemo(() => a + b, [a, b])derive([a$, b$], (a, b) => a + b)
Explicit dependency arraysExplicit derive / pipe declarations

SynState extends the mental model React developers already know — “declare dependencies explicitly, and the system propagates automatically” — from component scope to global state. The key differences are that SynState Observables are independent of the component lifecycle, push-based (only affected values recompute), and composable with async operators like debounce, throttle, and switchMap.

Based on the analysis above, SynState’s design decision can be summarized as follows:

  • Expressiveness: Within the scope examined on this page, no global state management use case was found that requires a dynamic graph.
  • Oversubscription: The cost a static graph adds is a subscription to every branch a projection may read, which the Conditional Fan-Out benchmark measures. switchMap, which subscribes to the selected branch only, removes it, for a resubscription on each switch.
  • React integration: The useSyncExternalStore subscription layer is sufficient; making the graph dynamic was not necessary.
  • React mental model: SynState’s explicit dependency declarations share a similar structure with React’s useMemo model.

In libraries with dynamic graphs, dependencies are declared implicitly by calling get() inside a function. SynState’s explicit dependency declarations via derive / pipe, on the other hand, make dependencies visually apparent in the code — much like React’s useMemo dependency arrays. SynState builds on this explicit model: each node’s place in the update order is fixed when it is built, so propagation is glitch-free and visits each affected node once.

  • Lee, E.A. & Messerschmitt, D.G. (1987). Static Scheduling of Synchronous Data Flow Programs. IEEE Transactions on Computers, C-36(1), 24–35.
  • Bainomugisha, E. et al. (2013). A Survey on Reactive Programming. ACM Computing Surveys, 45(4), Article 52.
  • Cooper, G.H. & Krishnamurthi, S. (2006). Embedding Dynamic Dataflow in a Call-by-Value Language. ESOP 2006.
  • Czaplicki, E. & Chong, S. (2013). Asynchronous Functional Reactive Programming for GUIs. PLDI 2013.
  • Maier, I. & Odersky, M. (2012). Deprecating the Observer Pattern with Scala.React. EPFL Technical Report.
  • Salvaneschi, G. et al. (2014). REScala: Bridging Between Object-Oriented and Functional Style in Reactive Applications. OOPSLA/SPLASH 2014.
  • Burchett, K., Cooper, G.H. & Krishnamurthi, S. (2007). Lowering: A Static Optimization Technique for Transparent Functional Reactivity. PEPM 2007.
  • Weststrate, M. (2016). The Fundamental Principles Behind MobX. https://hackernoon.com/the-fundamental-principles-behind-mobx-7a725f71f3e8.
  • Weststrate, M. (2016). Becoming Fully Reactive: an In-Depth Explanation of MobX. https://hackernoon.com/becoming-fully-reactive-an-in-depth-explanation-of-mobservable-55995262a254.
  1. Weststrate, M. (2016). The Fundamental Principles Behind MobX. The same statement also appears in Becoming Fully Reactive: an In-Depth Explanation of MobX. ↩