A Practical Pattern for Reactive Objects

When I started building complex UIs, I found myself manually wiring change listeners to state objects. Every time a property was updated, I had to emit events, trigger re‑renders, or persist the change. The boilerplate was noisy and error‑prone. I eventually reached for JavaScript Proxy—a built‑in language feature that lets us intercept property access and assignment. With a Proxy in place, we can turn a plain object into a reactive data store that automatically notifies listeners, validates input, and even logs changes. This article walks through a real‑world scenario, shows a production‑ready implementation, and explains why Proxy is a game‑changer for modern JavaScript applications.

Why Proxy Beats Manual Observables

Proxy objects act as a wrapper around target objects, exposing a handler that defines traps for operations like get, set, deleteProperty, and more. The beauty is that the traps fire automatically whenever the JavaScript engine reads or writes a property. Compared to libraries that rely on getters/setters or mutation‑tracking algorithms, Proxy reduces the mental overhead: we write the logic once, and the engine handles the rest.

In a typical React or Vue component, we could still use Proxy to centralize state, then expose a derived store that components consume. This keeps the UI declarative while still benefiting from automatic change detection. Moreover, Proxy is type‑agnostic; it works with any object shape, making it ideal for configuration objects, form models, or domain entities.

Real‑World Scenario: Managing a User Profile

Imagine a user profile form that needs to:

  • Validate field lengths and formats on the fly.
  • Update a UI avatar preview as the name changes.
  • Persist changes to localStorage without extra boilerplate.

Instead of scattering event listeners and storage calls across each input, we can create a ProfileStore that uses Proxy to centralize these concerns. The store will:

  1. Intercept property writes to validate input.
  2. Invoke callbacks when valid changes occur.
  3. Persist the entire profile object to localStorage.

Because the Proxy fires for every assignment, the UI can simply subscribe to the store and react to any change without manually triggering updates.

Implementation: A Reactive Profile Store

Below is a compact, production‑ready implementation. It includes validation, logging, and persistence, all wrapped in a single reusable class.


/**
 * ReactiveProfileStore
 * -----------------------
 * A Proxy‑based store that validates assignments,
 * logs changes, and persists to localStorage.
 */
class ReactiveProfileStore {
  constructor(initialData, validators = {}) {
    // Keep a reference to the raw data object.
    this._data = initialData;
    // Store validation rules keyed by field name.
    this._validators = validators;
    // List of callbacks to notify on change.
    this._subscribers = [];

    // Create the proxy and bind the instance methods.
    this.proxy = new Proxy(this._data, {
      get: (target, prop) => {
        // Forward normal property reads.
        return target[prop];
      },
      set: (target, prop, value) => {
        // Validate if a rule exists.
        const validator = this._validators[prop];
        if (validator && !validator(value)) {
          console.error(`[ProfileStore] Validation failed for '${prop}' with value:`, value);
          return false; // Reject the assignment.
        }

        // Log the change (useful for debugging).
        const oldValue = target[prop];
        if (oldValue !== value) {
          console.log(`[ProfileStore] '${prop}' changed from`, oldValue, 'to', value);
          target[prop] = value;

          // Persist the whole object.
          this._persist(target);

          // Notify all subscribers.
          this._notifySubscribers();
        }
        return true;
      },
      deleteProperty: (target, prop) => {
        console.log(`[ProfileStore] '${prop}' removed`);
        delete target[prop];
        this._persist(target);
        this._notifySubscribers();
        return true;
      }
    });
  }

  // Subscribe to store changes.
  subscribe(callback) {
    this._subscribers.push(callback);
    // Immediately invoke with current data for hydration.
    callback(this.proxy);
  }

  // Unsubscribe a callback.
  unsubscribe(callback) {
    this._subscribers = this._subscribers.filter(cb => cb !== callback);
  }

  // Internal: persist the current state to localStorage.
  _persist(state) {
    try {
      localStorage.setItem('profile', JSON.stringify(state));
    } catch (err) {
      console.warn('[ProfileStore] Failed to persist:', err);
    }
  }

  // Internal: notify all subscribers.
  _notifySubscribers() {
    const copy = { ...this._data }; // shallow clone for immutability perception.
    this._subscribers.forEach(cb => cb(copy));
  }

  // Helper to get the proxy (public API).
  get data() {
    return this.proxy;
  }
}

Let’s see how we can use it in a simple form scenario.


// Validation rules: name must be 2‑30 chars, email must match basic pattern.
const validators = {
  name: val => typeof val === 'string' && val.length >= 2 && val.length <= 30,
  email: val => /^\S+@\S+\.\S+$/.test(val)
};

// Initial profile from server or localStorage.
const stored = localStorage.getItem('profile');
const initial = stored ? JSON.parse(stored) : { name: '', email: '' };

const profileStore = new ReactiveProfileStore(initial, validators);

// UI component example (React style, but any framework works).
function ProfileForm() {
  const [profile, setProfile] = useState({});

  useEffect(() => {
    const update = p => setProfile(p);
    profileStore.subscribe(update);
    return () => profileStore.unsubscribe(update);
  }, []);

  const handleNameChange = e => {
    profileStore.data.name = e.target.value; // triggers validation & persistence
  };

  const handleEmailChange = e => {
    profileStore.data.email = e.target.value;
  };

  return (
    <div>
      <input placeholder="Name" onChange={handleNameChange} value={profile.name} />
      <input placeholder="Email" onChange={handleEmailChange} value={profile.email} />
    </div>
  );
}

The code above demonstrates the core benefits: validation errors are logged, changes are persisted automatically, and the UI stays in sync without manual event wiring.

When to Reach for Proxy

Proxy shines when you need transparent interception of object behavior. Typical use cases include:

  • Reactive state management without a library.
  • Debugging and logging of object mutations.
  • Dynamic validation or normalization of input.
  • Computed properties that derive from multiple sources.
  • Domain models that enforce invariants at runtime.

It’s less appropriate when you need deep observation of nested arrays or complex data structures; in those cases, a dedicated state management library may be clearer. However, for flat or lightly nested objects, Proxy provides a concise, performant solution.

Important Considerations

Proxy does not work on primitive values and cannot be used with Object.create(null) if you rely on prototype inheritance. Also, be mindful of performance when trapping every property access—over‑instrumentation can add overhead.

One practical tip is to keep the handler lean. If you need validation, move complex logic into separate validator functions to avoid bloating the trap. Additionally, when you serialize the store for storage or serialization, remember that Proxy itself isn’t serializable; always operate on the underlying target.

Another nuance: Proxy traps receive the target as the first argument. In our example, we store the target in this._data and refer to it from within the handler via closure. This pattern ensures that the handler can access instance methods like _persist and _notifySubscribers.

Wrapping Up

JavaScript Proxy is a powerful, often under‑used feature that can simplify reactive state management, validation, and logging. By centralizing change handling in a single handler, we reduce boilerplate, improve maintainability, and keep our code more declarative. Whether you’re building a small form model or a larger domain store, experimenting with Proxy can lead to cleaner architectures and fewer bugs.

Give it a try in your next project. You’ll likely find that the initial learning curve pays off quickly as you watch your objects become smarter without extra ceremony.