Building a Lightweight Reactive State Manager with JavaScript Proxies

Tired of frontend frameworks and complex state management libraries? Learn how a few lines of JavaScript and the power of Proxies can automatically sync your UI with your application state.


Building a Lightweight Reactive State Manager with JavaScript Proxies

Frontend state management can be a beast. We start with simple variables, then graduate to complex objects, and before we know it, we’re wrestling with reducers, actions, and selectors from a heavy library. Often, we find ourselves writing a ton of boilerplate just to answer a simple question: “How do I make sure my UI automatically updates when this value changes?”

What if you didn’t need a library for that? What if you could build a reactive system that’s both powerful and incredibly simple, using a native JavaScript feature you might have overlooked?

Enter the Proxy object. A Proxy lets you wrap another object and intercept fundamental operations, like getting or setting a property. By intercepting the “set” operation (the = assignment operator call set() under the hood), we can create a lightweight, “auto-magic” connection between our application’s state and our UI update functions. Let’s look at a real-world example to see how it’s done.

The Core Idea: An Interceptor for Your State

The goal is to have our UI update functions run automatically whenever our state object is modified. We don’t want to manually call updateTheUI() every time we change a value.

A JavaScript Proxy is the perfect tool for this. It acts as a middleman. When you try to change a property on the state object, the proxy steps in and says, “Before I change that value, is there anything else you want me to do?”

Here’s the basic setup:

  1. The State Object: A plain JavaScript object that holds our application’s data.
  2. The Handler: An object that defines what to do when an operation (like set) is performed on the proxy. This is where we’ll trigger our UI updates.
  3. The Proxy Itself: The reactive object our application will interact with instead of the original state object.

A Practical Example: A Media Submission Form

In our application, we have a media submission form for online ads where users can select either 4 photos or 1 video. The state for this feature looks something like this:

// The raw state object
const mediaState = {
  photos: {
    "1": null,
    "2": null,
    "3": null,
    "4": null,
  },
  video: "",
  mediaType: "none", // "photo" | "video" | "none"
};

We need to update several parts of the UI whenever these values change: the selected photo display, the buttons in the media gallery, and the hidden form inputs that will be submitted to the server.

Instead of calling three different update functions every time we add a photo, we create a proxy.

// The handler that defines our "trap"
// (see the MDN docs on proxies for more on "traps")
const handler = {
  set(target, property, value) {
    // First, update the actual property on the target object
    target[property] = value;

    // Now, trigger all our UI updates!
    // put values in hidden inputs
    updateHiddenInputs();

    // disable buttons after max media is selected
    updateGalleryModalButtons();

    // selected photos appear in a specific order in four boxes
    // these select photos can be dragged around to re-order
    updateSelectedPhotoSquares();

    // Return true to indicate success
    return true;
  },
};

// Create the reactive proxy
export const mediaStateProxy = new Proxy(mediaState, handler);

That’s it! Now, anywhere in our application, instead of modifying mediaState, we modify mediaStateProxy.

// This one line...
mediaStateProxy.video = "https://example.com/my-video.mp4";

// ...automatically triggers all three UI update functions.
// updateHiddenInputs();
// updateGalleryModalButtons();
// updateSelectedPhotoSquares();

The code that sets the video doesn’t need to know about the UI. It just performs a simple state change, and the proxy takes care of the rest. This creates a clean separation of concerns.

The Full Picture: State Queries and Public APIs

While the proxy handles the automatic UI updates, we round out our system with two other pieces to keep the code clean and organized.

1. Public API Functions

To avoid scattering mediaStateProxy.photos['1'] = ... throughout the codebase, we create a set of simple, intention-revealing functions that act as the public API for our state.

// A public function to add a photo
export function addPhoto(url) {
  const position = StateQueries.getFirstAvailablePosition();
  // Find the next open slot (1, 2, 3, or 4)
  if (position) {
    // This assignment triggers the proxy's 'set' trap
    mediaStateProxy.photos[position] = url;
    mediaStateProxy.mediaType = "photo";
  }
}

// A public function to set the video
export function setVideo(url) {
  // This assignment also triggers the proxy
  mediaStateProxy.video = url;
  mediaStateProxy.mediaType = "video";
}

This makes the rest of our code easier to read and abstracts away the implementation details of our state object.

Handling Complex Interactions: Reordering Photos

This pattern isn’t just for simple additions or changes. It scales nicely to more complex UI interactions, like drag-and-drop reordering. Imagine the user can drag the four selected photos to arrange them in a different order.

The drag-and-drop library (sortableJS is what we use) would give us the new order, likely as an array of photo URLs. All we need to do is create a new public API function that takes this new order and updates the state. The proxy handles the rest.

// A public function to handle reordering
export function reorderPhotos(newOrderArray) {
  const newPhotosState = { "1": null, "2": null, "3": null, "4": null };

  // Create a new photos object from the array
  newOrderArray.forEach((url, index) => {
    // Keys are '1', '2', '3', '4'
    newPhotosState[String(index + 1)] = url;
  });

  // This single assignment to a property on the proxy...
  mediaStateProxy.photos = newPhotosState;
  // ...is enough to trigger our 'set' trap and all the UI updates.
}

When reorderPhotos is called, it overwrites the photos property on our state object. The proxy intercepts this change, and just like before, it automatically runs our UI update functions (updateSelectedPhotoSquares, etc.). The reordering logic is contained in one place, and the UI synchronization is still handled for us automatically.

2. State Query Functions

Since we don’t want other parts of the app to reach directly into the state object for complex lookups, we create a StateQueries module. These are simple functions that read from the state (without triggering the proxy’s set handler, of course).

export const StateQueries = {
  countPhotos() {
    return Object.values(mediaStateProxy.photos).filter((p) => p !== null)
      .length;
  },

  getFirstAvailablePosition() {
    for (const pos of ["1", "2", "3", "4"]) {
      if (mediaStateProxy.photos[pos] === null) {
        return pos;
      }
    }
    return null;
  },

  isMediaSelectionValid() {
    const photoCount = this.countPhotos();
    const hasVideo = !!mediaStateProxy.video;
    return (photoCount === 4 && !hasVideo) || (photoCount === 0 && hasVideo);
  },
};

This keeps all the state-related logic in one place and makes it easily testable.

Why This Approach Is Great

For many applications, this lightweight, proxy-based approach is a fantastic alternative to pulling in a large state management library along with a frontend framework.

  • Simplicity: The core logic is just a few lines of code. It’s easy to understand and debug.
  • No Dependencies: It’s built entirely with native JavaScript features. No npm install needed.
  • Clean Separation: The code that changes the state is completely decoupled from the code that updates the UI.
  • Low Boilerplate: You don’t need to define actions, reducers, or dispatchers. You just… change the value.

The next time you find yourself needing to sync state and UI, give JavaScript’s Proxy a look. It might be all the state management you need.