Building Reactive Objects
Introduction: From Logging to Reactivity
In our first lesson, we learned how to use the Proxy object and the Reflect API to create a logging tool. We saw how to watch an object and print a message every time someone accessed or changed a property. While logging is a great way to debug your code, we can take this concept much further.
The idea of "reactivity" is a core part of modern web development. You might have used frameworks like Vue or libraries that automatically update the screen whenever your data changes. This happens because the system is "reacting" to changes in your data. In this lesson, we are going to build our own reactive state system. Instead of just printing to the console, our proxy will trigger a specific callback function whenever a property is updated or deleted. This allows us to build applications that stay in sync with their data automatically.
The Basic Reactive Proxy Pattern
To build a reactive system, we need a function that takes an object and an onChange callback. Every time a change happens, the proxy should execute that callback. We focus on two main traps: set and deleteProperty. The set trap handles when a property is added or changed, and the deleteProperty trap handles when a property is removed.
In the code below, we use Reflect.set to update the object and then check whether the operation was successful. If it was successful, we call onChange with the property name, the old value, and the new value. This tells the rest of our program exactly what changed so that it can react accordingly.
This simple version works well for flat objects. If you change a top-level property, the callback runs. However, if your object has other objects inside it, this basic version will not notice changes deep inside those nested objects. Notice the deleteProperty trap first checks Reflect.has(obj, prop). Deleting a property that does not exist still succeeds (it returns true), so without this guard we would emit a misleading change event for a property that was never there.
Deep Reactivity for Nested Objects
Most real-world data is nested. For example, a user object might have a profile object inside it. If we only wrap the top-level user, changing user.profile.name will not trigger our set trap. This is because when we access user.profile, the proxy returns the original, unwrapped profile object. To fix this, we need "deep reactivity."
We can achieve this by adding a get trap. When a user tries to access a property, we check whether the value being returned is another object. If it is, we wrap that inner object in a proxy as well before returning it.
This creates a chain of proxies so that a set or deleteProperty at any depth will trigger the callback. Keep in mind, though, that our onChange only receives the leaf property name — not the full path to it — so the callback alone cannot tell you where in the nested structure the change occurred. This "on-demand" wrapping is efficient because we only create proxies for the parts of the object that the user actually touches.
One important caveat: the deep-wrapping approach shown here is designed for plain objects and arrays — the kind of data you would write as an object or array literal. Built-in objects such as Date, Map, Set, or class instances rely on internal mechanics (often called internal slots) that can break when accessed through a proxy, because their methods expect this to be the real object rather than a wrapper. DOM nodes have the same problem. A production reactive library adds special checks to skip or specially handle these types. In this lesson we keep the focus on plain objects and arrays so the core pattern stays clear.
Maintaining Referential Integrity with WeakMap
Adding deep reactivity introduces a new problem called "identity discontinuity." If you access state.user twice, our code might create two different proxies for the same user object. This means state.user === state.user would be false, which can break your program and cause unexpected bugs. We need a way to ensure that one object always maps to exactly one proxy.
We solve this by using a WeakMap to cache our proxies. Before creating a new proxy, we check the cache to see whether we have already made one for that specific object. If we have, we return the cached version.
There is a subtlety, though: the correct proxy for an object depends on which onChange it is bound to. If we cached by target alone, the first caller's callback would "win," and a later call wrapping the same object with a different callback would wrongly receive the original proxy. So we scope the cache per callback — one target maps to one proxy for a given onChange.
We use WeakMap instead of a regular Map because WeakMap allows the garbage collector to remove objects from memory if they are no longer being used elsewhere. This prevents "memory leaks," which is very important for long-running applications.
Respecting Proxy Invariants
JavaScript has strict internal rules called "invariants" to keep proxies safe. One of these rules involves properties that are "non-writable" and "non-configurable." If a property is locked so it cannot be changed or deleted, the Proxy is legally required to return the actual value of the target property. If your get trap tries to return a different value (like a new proxy) for a locked property, JavaScript will throw a TypeError.
To avoid this crash, we use Object.getOwnPropertyDescriptor inside our get trap. This function tells us the settings of a property. If we see that a property is both non-writable and non-configurable, we skip the proxy-wrapping step and return the original value directly.
This ensures our reactive system is stable and follows the official rules of the JavaScript engine.
Avoiding Common Pitfalls
When building advanced patterns, small details can lead to big bugs. One pitfall is how we detect changes. Usually, people use the !== operator, but this can fail with special values like NaN (which is not equal to itself). To be safe, we use Object.is(old, value). This method is more accurate for comparing values and ensures we only trigger the onChange callback when a real change has occurred.
Another subtle point involves the receiver argument in Reflect.set. As we saw in the first lesson, passing the receiver preserves correct this binding and proxy/prototype semantics, which is the right default for a faithful forwarder. There is a tradeoff, though. If the target object defines its own setter, passing the receiver (the proxy) means that setter runs with this pointing at the proxy. Any assignment that setter makes then re-enters our set trap, which can lead to infinite recursion.
There is no single rule that is always correct. The key is to be deliberate: pass the receiver when you need accurate prototype and this semantics, but be aware of setters that could re-enter your trap. In our reactive examples the targets are plain objects without custom setters, so we omit the receiver in the set trap to keep the forwarding simple and avoid any chance of re-entrancy. If your data used inherited setters, you would handle that case explicitly — for example, by detecting and short-circuiting re-entrant calls.
From One Callback to Many Subscribers
Summary and Practice Preparation
In this lesson, we moved beyond basic logging to create a robust reactive state system. You learned how to use the set and deleteProperty traps to trigger updates. We explored "deep reactivity" to handle nested objects and used a WeakMap to maintain referential integrity. We looked at how to handle Proxy invariants and reliable equality checks to prevent crashes and unnecessary updates. We also saw how to grow from a single callback to many subscribers using a Set and unsubscribe closures, a pattern we will formalize as the Observable in the final unit.
The outcome of all these steps is a function that can watch complex objects and arrays, alerting you whenever any part of the data changes while remaining efficient and stable. You are now ready to practice building these patterns. In the upcoming exercises, you will implement these traps and logic steps yourself to see how they come together to form a powerful data management tool.
