Tools
Signals vs Proxy vs Virtual DOM — What Actually Makes Them Different?
2025-12-22
0 views
admin
The Story Behind “UI = f(state)” ## 1997 — Functional Reactive Animation ## 2011 — Elm 0.1 made it practical ## 2013 — React popularized it ## And that’s why the ecosystem split into 3 paths ## Core Question: How do they track, schedule, and update? ## Signals — “should this recompute?” lives on the value ## 1. Dependency tracking ## 2. Scheduling ## 3. DOM updates ## Best for ## Proxy Reactivity — automate dependency tracking using language features ## 1. Dependency tracking ## 2. Scheduling ## 3. DOM updates ## Best for ## Virtual DOM — trade tracking for a diff ## 1. Dependency tracking ## 2. Scheduling ## 3. DOM updates ## Best for ## Side-by-side examples ## Solid (Signals) ## Vue (Proxy Reactive) ## React (Virtual DOM) ## When should you choose which? ## Summary ## Signal ## Proxy Reactive ## Virtual DOM ## Next Up ## References Frontend reactivity often splits into three paths: Signals, Proxy-based reactivity, and the Virtual DOM.
People usually ask: If you follow the slogan UI = f(state) all the way back to its origin, the answer becomes surprisingly clear. This article turns that slogan into a short story, breaks down the core differences between the three reactivity models, shows minimal code comparisons, and ends with a practical decision table—so you can reason about trade-offs and explain them to your team. At ICFP 1997, Conal Elliott introduced Functional Reactive Animation, proposing that a UI is fundamentally a pure function: The idea only made small academic waves, but it planted the seed that interfaces can be described as mathematical functions. Ten years later, Evan Czaplicki brought this idea into browsers via Elm 0.1: Model = state.
view = a function that returns UI. For the first time, frontend developers saw a real, runnable example of UI = f(state). At JSConf US 2013, Jordan Walke introduced React and put the slogan directly on the slides: Just give React the latest state and call render(); the framework will figure out how to update the DOM. The concept left academia, left Haskell, and became our everyday frontend language. After React, three different optimization branches emerged: Understanding this evolution explains why these systems look so different, yet all try to make the same f(state) easier and faster. For each reactivity model, we’ll compare: In Solid/Vue, only the computation using that field reruns.
In React, the entire function reruns → then diff decides the real DOM change. The real difference isn’t JSX—it’s who decides whether something should recompute: Encodes where to update inside the value itself.
Writes notify exactly the consumers that depend on it. Uses ES6 Proxy to turn any property into a trackable value automatically. Ignores dependency tracking and relies on diffing snapshots of f(state). If you understand how each model distributes cost across: you can choose the right reactivity strategy for any project. The next article in this series will dive deep into how Signals actually work, from dependency graphs to schedulers, and how to implement your own minimal version. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
view : Model -> Html msg Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
view : Model -> Html msg COMMAND_BLOCK:
view : Model -> Html msg COMMAND_BLOCK:
const [count, setCount] = createSignal(0) createEffect(() => { console.log('count changed ->', count())
}) setCount(1) // only effects using count re-run Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
const [count, setCount] = createSignal(0) createEffect(() => { console.log('count changed ->', count())
}) setCount(1) // only effects using count re-run COMMAND_BLOCK:
const [count, setCount] = createSignal(0) createEffect(() => { console.log('count changed ->', count())
}) setCount(1) // only effects using count re-run COMMAND_BLOCK:
const state = reactive({ count: 0 }) watchEffect(() => { console.log('count changed ->', state.count)
}) state.count++ // dependencies registered along getter chain Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
const state = reactive({ count: 0 }) watchEffect(() => { console.log('count changed ->', state.count)
}) state.count++ // dependencies registered along getter chain COMMAND_BLOCK:
const state = reactive({ count: 0 }) watchEffect(() => { console.log('count changed ->', state.count)
}) state.count++ // dependencies registered along getter chain COMMAND_BLOCK:
function Counter() { const [count, setCount] = useState(0) console.log('Counter render') return ( <button onClick={() => setCount(c => c + 1)}> {count} </button> )
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
function Counter() { const [count, setCount] = useState(0) console.log('Counter render') return ( <button onClick={() => setCount(c => c + 1)}> {count} </button> )
} COMMAND_BLOCK:
function Counter() { const [count, setCount] = useState(0) console.log('Counter render') return ( <button onClick={() => setCount(c => c + 1)}> {count} </button> )
} - Which one is faster?
- Which one is easier to maintain?
- Which one scales better? - Signals — break f into fine-grained pieces
2.Proxy-based reactivity — automate dependency tracking using language features
- Virtual DOM — use diff as the dependency boundary - How are dependencies tracked? (read-time / write-time, granularity)
- How are updates triggered & scheduled? (push/pull, batching, slicing)
- How does DOM get updated minimally? (node-level, attribute-level, tree diff) - Dependencies are recorded when you read a signal.
- When you write, only computations that actually used the value rerun.
- Granularity can be as fine as a single primitive value. - Mostly push-based.
- Common patterns: batch(), microtask queues, lazy memoization (hybrid push/pull). - Computations directly update their target DOM node or attribute.
- Almost no tree diffing.
- Extremely efficient for frequent, tiny updates. - Maps and markers
- Canvas / charts
- Massive tables with cell-level updates - Uses Proxy(get/set) interception.
- Track dependencies automatically for each accessed property.
- Deep objects register dependencies along the getter chain. - Push-based.
- Writes enqueue jobs in a microtask queue (Vue’s job queue, for example). - Compiler/runtime determines which patch to apply.
- Usually precise, but deep objects incur read-time overhead. - Complex forms
- Deeply nested JSON
- Situations where "just mutate objects" is preferred - Almost none.
- Rerender the component and diff the new tree vs old tree. - setState → component rerenders.
- Fiber Scheduler handles priority + time slicing → smoother interactions. - Diff produces a patch list.
- Even small changes require recomputing the component tree first. - Large React ecosystems
- Strong SSR/RSC needs
- Teams that rely on DX, tools, and existing patterns more than raw perf - Signals / Proxy: data decides (push)
- VDOM: the diff decides (pull) - dependency tracking
- DOM updates - Conal Elliott, Functional Reactive Animation, ICFP 1997
- Elm Guide — The Elm Architecture
- Jordan Walke, JSConf US 2013 React Talk
- UI is a function of state (2015)
- Dan Abramov — The Two Reacts (2024)
how-totutorialguidedev.toaimlnode