loke.dev
Header image for How I Finally Caught the Silent Mutator Sabotaging My State

How I Finally Caught the Silent Mutator Sabotaging My State

I turned a desperate debugging session into a forensic breakthrough by using the JavaScript Proxy API to intercept and unmask rogue state changes.

· 4 min read

I once lost an entire Tuesday afternoon to a "read-only" helper function that was actually a secret saboteur. My UI state was flickering, values were changing behind my back, and I was about five minutes away from throwing my laptop into a lake. Everything looked fine in the Redux DevTools until—poof—a nested property would just flip, and I couldn't for the life of me find the line of code responsible.

We’ve all been there. You pass an object into a function, assuming it’s a pure operation, but somewhere deep in a utility folder, someone (maybe past-you) decided to do data.user.isActive = true instead of returning a new object. In a large codebase, finding that one line is like looking for a needle in a haystack made of other needles.

Then I remembered the JavaScript Proxy API. It’s one of those "browser magic" features we rarely use in day-to-day feature work, but it’s an absolute godsend for debugging.

The Crime Scene

Here is a simplified version of the nightmare. Imagine you have a state object, and you pass it to a function that *should* only be reading data.

let state = {
  user: {
    id: 1,
    settings: {
      theme: 'dark',
      notifications: true
    }
  },
  inventory: ['sword', 'shield']
};

// Somewhere deep in a file you haven't touched in months...
function updateDashboard(data) {
  // ... lots of logic ...
  if (data.user.settings.theme === 'dark') {
    // SNEAKY MUTATION! 
    data.user.settings.notifications = false; 
  }
}

updateDashboard(state);

If state is supposed to be immutable, you’re now in a "silent failure" loop. React won’t re-render correctly because the reference didn't change, but your data is now wrong.

Enter the Proxy: The Bouncer for Your Objects

The Proxy object allows you to create a wrapper for another object, which can intercept and redefine fundamental operations for that object—like getting, setting, and defining properties.

To catch a mutator, we want to intercept the set operation. Here is a basic "Mutation Guard":

const state = { theme: 'dark' };

const bouncer = new Proxy(state, {
  set(target, property, value) {
    console.warn(`🛑 ALERT: Something tried to change "${property}" to:`, value);
    
    // We can actually block the change by simply not doing target[property] = value
    // or we can let it through but log it.
    target[property] = value;
    return true; 
  }
});

bouncer.theme = 'light'; // Logs: 🛑 ALERT: Something tried to change "theme" to: light

Making it Forensic: Adding the Stack Trace

The console warning above is "okay," but it doesn't tell me *who* did it. To find the culprit, we need the stack trace. JavaScript’s Error().stack is the perfect tool for this. It’s a bit of a hack, but it works wonders during a debugging session.

function createInspector(obj) {
  return new Proxy(obj, {
    set(target, prop, value, receiver) {
      const stack = new Error().stack;
      console.group(`%c State Mutation Detected! `, 'color: white; background: red; font-weight: bold;');
      console.log(`Property: ${prop}`);
      console.log(`New Value:`, value);
      console.log(`Stack Trace:`, stack);
      console.groupEnd();

      return Reflect.set(target, prop, value, receiver);
    }
  });
}

const trackedState = createInspector({ status: 'idle' });
trackedState.status = 'loading'; 

Now, when that sneaky function tries to change the state, the console will explode with a red banner and a clickable link directly to the line of code that caused the mutation.

The Final Boss: Deep Nesting

There’s a catch. A standard Proxy only watches the top-level properties. If you change state.user.settings.theme, the Proxy on state won't trigger because you aren't setting user; you're getting user and then setting a property on the object *it* returns.

To catch the real-world bugs, we need a recursive Proxy.

function deepInspect(obj, path = '') {
  return new Proxy(obj, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      
      // If the property is an object, wrap it in a Proxy too!
      if (typeof value === 'object' && value !== null) {
        return deepInspect(value, `${path}${prop}.`);
      }
      return value;
    },
    set(target, prop, value, receiver) {
      console.error(`MUTATION DETECTED at: ${path}${prop}`);
      console.trace(); // Better than manually grabbing Error().stack
      return Reflect.set(target, prop, value, receiver);
    }
  });
}

const complexState = deepInspect({
  settings: { layout: 'grid' }
});

// This will now trigger the error!
complexState.settings.layout = 'list';

Why Not Just Use Object.freeze()?

You might wonder: "Why not just use Object.freeze(state)?"

Object.freeze is fine, but it’s a blunt instrument. It throws an error (in strict mode) or fails silently (in sloppy mode). It doesn't give you a rich stack trace, and it's annoying to unfreeze if you actually need to modify the data later in a controlled way.

The Proxy approach is observability. You aren't necessarily stopping the mutation; you're shining a giant spotlight on it.

When to Use This (And When to Not)

Use it when:
- You’re debugging a "how did this value get here?" mystery.
- You're integrating a legacy library that you suspect is messing with your global objects.
- You're teaching a junior dev about why immutability matters.

Don't use it in production:
Proxies have a performance overhead. Wrapping your entire production state tree in a recursive Proxy is a great way to make your app feel like it's running on a calculator from 1994. Wrap your objects, find the bug, fix the bug, and then strip the Proxy out before you merge.

It took me twenty minutes to write the deepInspect utility and exactly three seconds to find the bug once I refreshed the page. The culprit? A "formatCurrency" utility that was truncating decimals by modifying the input object directly.

Sometimes, the best way to fix a bug isn't to think harder, but to build a better trap.