JavaScript Reactivity (Part 2): Deep vs. Shallow Reactivity
Published 2026-05-22, About 14 minute read.
When you first start coding in Javascript, if you're starting with good ol' plain vanilla Javascript, you most likely learned about the MVC strategy to organize your code. It goes something like this:
- The controller uses the view to render the model.
- The controller also sets up event listeners.
- The listeners call methods on the controller to modify state (in the model).
- The methods that modify state also call methods to re-render the appropriate parts of the view.
- Voila! The view is synced to the state!
People started to notice that it was inefficient to manually and imperatively tell javascript what needs to be re-rendered when we changed the model. Should we have to call view changes every time related data changes? This is very manual, and is prone to buggy behavior. Plus, humans have to use a lot of extra brain space to make sure to only update parts of the view that need it. And there could be state from the model in lots of different places! That's a lot of different methods and event listeners and fragments of the view to track!
Can we somehow build a framework that is structured in such a way that all we do is change the data, and the view knows implicitly what to re-render? After all, the view knows what data it needs to render the html.
And thus reactivity was born.
The next natural question as you start to think of how your framework will react to data changes: How do we detect changes in the data?
Depth falls out of this discussion because of the way that Javascript stores values, and the nature of getters and setters.
The naivest of the naive approach 📎
What if we just rerendered everything whenever the model is touched? If we modify the model ever, let's just tell the view to re-render everything! We'll never miss anything, and state will be accurately shown.
If you're reading this you probably already understand the issues involved here. It's part of the reason people looked at React with suspicion in the beginning. Rendering can be expensive, and re-rendering everything is very expensive. We're not even getting into the peculiarities of DOM thrashing: the problems you encounter with focus state, accessibility, DOM-held state that gets lost in an unnecessary rerender...
There is an important distinction to make here. Re-rendering the entire view is not what tells us how deep the change detection is. The thing that determines the depth is at what level is the change understood by our framework?
The answer here is that any change at all will cause renders, so the depth of our thought experiment framework is very shallow.
Consider this analogy that most definitely has not happened to me with one of my children:
You have a toddler that wakes up in the middle of the night and steals food from the kitchen. You have two strategies to be alerted about the toddler's activities. We could put motion sensors throughout the house that go off wherever the toddler roams, and you can know the exact path and position of that thieving toddler, or we could set a single alarm on the toddler's bedroom door.
Setting one alarm at the single door is a shallow detection. It will trigger no matter how deeply the toddler ends up roaming in the house to steal ice cream from the freezer. If we were to set up many, many alarms, we would know how deep the toddler executed his thievery, and exactly the paths, and we might even know through cameras exactly what they are doing. This is deep detection.
Now what we do in response to the thieving toddler is separate. This is also the re-rendering part of our analogy. So in our MVC naive example, the trigger is extremely shallow, the rerendering response, though important, is something separate.
Why does this happen? The spectrum of depth 📎
This leads us to an interesting way to view the graph of reactivity in Javascript. We can flatten this graph to look only at depth. Here's one take on what that looks like:
If you were to graph MVC on this axis, it is probably the most shallow of the shallow reactivity strategies you can get. And there are frameworks that leverage this strategy- zone.js and my wc-services are two.
But the strategies of detection start to fill out a spectrum, and this is mostly because of the constraints that Javascript puts on us with references and values. You've probably seen this sort of topic in coding or technical interviews. When you store a primitive in a variable, that primitive's value is stored directly. Primitive values are immutable, and if you change the value you change the whole entity itself. Javascript does not keep some sort of reference to that memory for that value and swap the value internally.
However, with objects you share values by reference. If you save an object
let obj = { foo: "bar" }, and then later change the foo property
on that object, the "container" obj is not affected.
obj is a reference to the thing in memory, and if that thing gets
changed, the reference is happily unaware that such a thing has even occurred.
The level at which Javascript has visibility into what changed is a
fundamental issue that reactivity systems try to solve. This is why many
reactivity frameworks choose to create "containers", or require that watched
objects are not primitives themselves. This is why Vue has a distinction
between
ref() and reactive(). Even though they are closely
related and share the same underlying dependency tracking, there is a
difference in strategy if you're dealing with primitives or objects in
Javascript. Basically, if you are trying to make a reactive primitive, under
the hood you put it on an object so we can leverage proxies or
getters/setters! (And this also explains
limitations of reactivity in vue. For the most part, this is not unique to Vue, you see this everywhere.)
Here is the simplest sketch I can come up with that shows two primitives in a hypothetical reactivity framework. This example just shows how a reactive primitive shallowly detects value changes for primitives and objects.
Let's walk through that example piece by piece to see exactly where the shallow boundary lives.
makeReactive wraps an object in a Proxy with a
set trap. Any write to a property on that proxy gets
intercepted and we call a function. In practice, this would be something
like "schedule a rerender!" Here, we log so we can see that the set trap
fired.
Notice here that we're putting a nested object in this wrapper. Only the
top level fields will trigger if we set them. If we set
nested.v, the outer proxy has no idea!
Setting a value on myObject.foo goes through the proxy's
set trap. We see that our detection log fires.
myObject.nested.v first reads
myObject.nested (which returns the unwrapped inner object),
then writes v on that inner object. Because we're setting
on the nested object's properties, no trap gets triggered. We set on the
nested object and myObject has no idea.
Since primitives can't be proxied, we just re-use object proxying and
place the primitive inside a useful object with a common key:
value. The shape - an object with .value - is
exactly what useState, shallowRef(),
signal(), and friends all converge on. Ember cells do the
same thing, they just call the key current.
We added a little detail here. Not only did we create a useful object to
proxy, we added a quick API method set(value) which lets us
update the value via a method call if we wish. Either way, we are
triggering the same detection function.
Hopefully this example makes a few things clear:
-
Why primitive reactivity wrappers always have you access the value through
something like
.value. - Why objects sometimes get a "free pass."
- Why shallowness is sometimes a direct result of simply one layer of proxying.
How can we get deep detection? 📎
But we know many frameworks are somehow aware of deep changes. How do they do that?
The answer nowadays seems to be recursive proxies!
The idea is that you would create a proxy just like before, but you would pay attention to what you're setting or getting. If you're getting a nested object, you would wrap that object before you return it with a proxy which would function the same way but now on that nested level. This way, no matter how deep you went, you would be able to detect when a change occurs deeply!
Let's look at a toy demonstration:
It's a very small change, but extremely powerful: recursively wrapping and returning any objects you find as you descend down the access path within the wrapped object.
But there are other options! Let's go high level over what some other frameworks do:
Oldschool Vue (<=2) and Mobx 📎
These frameworks didn't do this work lazily. At the time of wrapping or setting up an object, they walked the object state tree and set up setters and getters at the beginning. This was nice, because we don't have that identity issue we just saw in the recursive proxy example above. But, there were issues if you changed the structure of the data, you had to notify the reactivity system or it would not be aware of the structure changes to the data. This method was mostly out of necessity since proxies didn't exist yet.
Solid.js and Preact Signals 📎
Solid signals are shallow. Users can then address depth by creating a tree of shallow signals in any data shape they choose. If any of those individual signal leaves changes, it's notified. It's shallow, technically. But the strategy feels deep. More recently, Solid has included "stores" which are deep because each property in the store at any depth is wrapped in a signal.
Preact has the ability to do this as well, but this is an opt-in thing. Preact straddles the worlds of signals and React, so sometimes Preact will follow virtual DOM diffing per component, and sometimes (when explicitly told to) it will have those shallow leaf notifications of changes. This, however, is about push vs. pull and not depth, so we'll save more discussion for that post!
Zone.js 📎
This one is pretty crazy. It's the shallowest of the bunch, because Zone.js notably has no idea what's changed. It hooks into and watches all async processes that could occur, and when they occur, it signals that "something might have changed." It's the same pattern we saw from our MVC thought experiment earlier. zone.js has an extremely shallow trigger. However, what Angular then does in response is very different.
Every system has an escape hatch 📎
Invariably, there are edge cases where data changes slip past the reactivity system. You see this in almost every framework.
And here's why: the traps weren't triggered. But you can force it with self-assignment. If you have a nested object in a signal, and need the signal to react, you can make the deep change and reassign the top layer. For example:
I don't know about you, but this feels very close to the mental model for React, Lit, and Svelte. These frameworks compare references to know what should be re-rendered. Re-assigning the reference is how they look for changes, and the reason is the same one that often keeps proxy depth at one layer.
Wc-services is meant to be as light and vanilla JavaScript as possible.
Because of this, we provide only one way to signal a change: explicitly
calling this.notify() after a service has changed data. This is
shallow- probably on par with zone.js in how unaware the system is of actually
what changed.
Other frameworks have this "notification method" that triggers reactivity.
They differ in the details of what they actually do, but they all share a
single goal: telling the system something's changed. In Lit, it's
this.requestUpdate(). In Vue 2 you have
Vue.set(...). In Vue 3, triggerRef(). In Backbone,
model.trigger('change')...
The moral of this story is that no matter how good your detection system is, there will always be edge cases your system doesn't cover.
What's next? 📎
Dang, that was a long post. If you're with me still, thank you. This is a lot of fun, and I hope you enjoy diving deep with me!
Next we will analyze the other axis that reactivity can be analyzed against: granularity. See you then!
Find me on Bluesky or Mastodon. I also have an RSS feed here