Skip to main
Article
A dark vintage accessory store lit by lamps and bare bulbs, with bags, jewelry, sunglasses and a bowler hat on the wooden shelves and carved table.

Proxy Is What’s in Store

You may not need anything more

When adding interactivity to a web app, it can be tempting to reach for a framework to manage state. But sometimes, all you need is a Proxy.

Recently, I was building the Playground feature on the Sass website. It’s an interactive feature built into a static Eleventy site, so we didn’t have a UI Framework already integrated.

A screenshot of the Sass playground web app.

Let’s take a look at the interactive parts of the tool. Users have three inputs:

From this, there are several side effects. Any time one of these changes, we need to recompile the Sass. We also need to update the hash in the URL, which allows users to share the state of the page.

If compilation succeeds, we can simply update the output value. If it fails, we need to display the error.

While this isn’t an extremely complex set of interactions, it’s enough that we want to be mindful of how we set this up. Essentially, we have three inputs and three outputs.

So, what is it that we actually need here?

  1. We need a central place to hold our data, or our “state.”
  2. When our data changes, we need a way to trigger the appropriate side effects.

At this point, there are a few options. A UI Framework like Svelte would provide an easy, centralized interface for reactivity, but comes with the long term update and maintenance costs of an extra dependency.

We could also add our logic directly in click handlers of our inputs. This is perhaps the most straightforward approach, but also is decentralized, and would come with a lot of code duplication.

A third solution is using a Proxy. We can store our data in a proxied object, and then intercept – or “trap” – the setters to trigger the reactivity.

const initialState = { input: '' };

const state = new Proxy(initialState, {
  set(state, prop, value) {
    const set = Reflect.set(...arguments);
    console.log(`Setting ${prop} to ${value}`);
    return set;
  },
});

state.input = 'Hello!';
// "Setting input to Hello!"

Every time we set the value of state.input, we log the prop and value. Note that we use the proxied object state, and not the initialState object. Because we are trapping set, we need to set the value with Reflect.set(). We also need to return a Boolean specifying whether the set was successful.

const initialState = {
  inputFormat: 'scss',
  outputFormat: 'expanded',
  inputValue: '',
  compilerHasError: false,
};

const playgroundState = new Proxy(initialState, {
  set(state, prop, value) {
    // Set state first so called functions have access
    const set = Reflect.set(...arguments);
    if (['inputFormat', 'outputFormat', 'inputValue'].includes(prop)) {
      updateCSS();
      updateURL();
    } else if (prop === 'compilerHasError') {
      updateErrorState();
    }
    return set;
  },
});
Note:

This is a bit simplified from the full source code.

With this, we’ve achieved a few things. First, we have a central location for the data we need to access. Second, we have a central place to declare what happens when a value changes.

When a user changes the input format, we simply need to update the state with playgroundState.inputFormat = 'sass'; and updateCSS and updateURL are automatically called. We no longer need to remember to trigger the side effects in each place where state is updated.

For this particular project, I only needed to trigger side effects, so I only needed to use the set trap. One thing I’m interested in trying out in a future project is using a get trap to set up a computed value based on other values in the state.

const initialState = { input: '' };

const state = new Proxy(initialState, {
  get(target, prop, receiver) {
    if (prop === 'isEmpty') {
      return target.input.length === 0;
    }
    return Reflect.get(...arguments);
  },
});

console.log(state.isEmpty); // true
state.input = 'has a value';
console.log(state.isEmpty); // false

This pattern would be useful when you need to access a value derived from other pieces of the state at a different point than when you set the state.

This isn’t a universal solution. If you have an existing library, use it. If you find yourself abstracting out things like watch or computed for your proxy state, you are starting down the road to developing your own framework, and it might be a good time to pause and see if your application has grown complex enough to bring in something more robust.

This pattern does work well if other files want to update or access the state in the store. However, you’ll likely want to contain side effect logic to the file where the state is defined. If you’re wanting to define side effects across files, you’d need to do that with callbacks, and ideally start looking for ways to abstract that out. At that point you’ll probably be happier reaching for another solution.

Recent Articles

  1. A dog zooming by the camera, up-close, body twisted and eyes wide as it circles a grass yard
    Article post type

    Zoom, zoom, and zoom

    The three types of browser (and CSS!) magnification

    I’m working on an article about fluid typography, and relative units. But instead, I fell down this rabbit hole – or a cleverly-disguised trap? – trying to understand ‘zoom’ in the browser (not Zoom™️ the software). Since I couldn’t find any up-to-date articles on the subject, I thought I shoul…

    see all Article posts
  2. A rusty anchor hanging with the sea in the background.
    Article post type

    Updates to the Anchor Position Polyfill

    Catching up to the spec

    Our sponsors are supporting the continued development of the CSS Anchor Positioning Polyfill. Here’s a summary of the latest updates.

    see all Article posts
  3. A back hoe on the bank of the Suez, trying to free the Ever Given cargo ship
    Article post type

    Learn Grid Now, Container Queries Can Wait

    Take your time with new CSS, but don’t sleep on the essentials

    Several people have asked recently why container queries aren’t being used more broadly in production. But I think we underestimate the level of legacy browser support that most companies require to re-write a code-base.

    see all Article posts