10 Oct 2025 ~ 10 min read

Building Your Own State Management Library with JavaScript Proxies


State management picture

Have you ever wondered how state management libraries like Zustand or Valtio work under the hood? In this post, we’ll build our own reactive state management solution from scratch, learning about JavaScript Proxies, the Reflect API, and React’s useSyncExternalStore along the way.

The Problem We’re Solving

When building React applications, we often need to share state across multiple components. While React’s built-in useState works great for local state, sharing state means either prop drilling or reaching for a library. But what if we could build our own?

Step 1: The Simplest Store

Let’s start with the absolute basics - a store that holds state and notifies React when it changes:

export function createStore<T>(initialState: T) {
  let state = initialState;
  const listeners = new Set<Function>();

  const notify = () => {
    listeners.forEach(fn => fn());
  };

  const subscribe = (listener: Function) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  const setState = (newState: T) => {
    state = newState;
    notify();
  };

  const getState = () => state;

  return { getState, setState, subscribe };
}

This is pretty straightforward:

  • We store the state in a closure variable
  • We maintain a list of listeners (components that care about changes)
  • When state changes, we notify all listeners
  • We provide methods to get and set state

But there’s a problem: we have to manually call setState every time we want to update. What if we could make it automatic?

Step 2: Enter JavaScript Proxies

Proxies are one of JavaScript’s most underrated features. They let us intercept and customize operations on objects. Think of them as a wrapper that sits between you and your object, letting you run code whenever someone reads or writes a property.

Here’s a simple example:

const user = { name: "Alice", age: 25 };

const proxy = new Proxy(user, {
  set(target, property, value) {
    console.log(`Setting ${String(property)} to ${value}`);
    target[property] = value;
    return true;
  }
});

proxy.age = 26; // Logs: "Setting age to 26"

Let’s use this to make our store automatically reactive:

export function createStore<T extends object>(initialState: T) {
  let state = { ...initialState };
  const listeners = new Set<Function>();

  const notify = () => {
    listeners.forEach(fn => fn());
  };

  const store = new Proxy(state, {
    set(target, property, value) {
      if (target[property] !== value) {
        target[property] = value;
        notify(); // Automatically notify on changes!
      }
      return true;
    }
  });

  const subscribe = (listener: Function) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  return { store, subscribe };
}

Now we can just mutate the store directly:

const { store } = createStore({ count: 0 });
store.count++; // Automatically notifies listeners!

Step 3: Connecting to React

React components need to know when to re-render. React 18 introduced useSyncExternalStore specifically for this use case. It’s a hook that lets React subscribe to external stores (stores that live outside of React’s state system).

import { useSyncExternalStore } from 'react';

export function createStore<T extends object>(initialState: T) {
  let state = { ...initialState };
  let cachedSnapshot = { ...state };
  const listeners = new Set<Function>();

  const notify = () => {
    cachedSnapshot = { ...state }; // Create new snapshot
    listeners.forEach(fn => fn());
  };

  const store = new Proxy(state, {
    set(target, property, value) {
      if (target[property] !== value) {
        target[property] = value;
        notify();
      }
      return true;
    }
  });

  const subscribe = (listener: Function) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  };

  const getSnapshot = () => cachedSnapshot;

  function useStore() {
    return useSyncExternalStore(subscribe, getSnapshot);
  }

  return { store, useStore };
}

Now we can use it in React:

const counterStore = createStore({ count: 0 });

function Counter() {
  const state = counterStore.useStore();
  
  return (
    <button onClick={() => counterStore.store.count++}>
      Count: {state.count}
    </button>
  );
}

Why the cached snapshot? useSyncExternalStore calls getSnapshot frequently to check if the state changed. If we return a new object every time ({ ...state }), React thinks the state changed even when it didn’t, causing infinite loops. By caching the snapshot and only creating a new one when state actually changes, we solve this problem.

Step 4: Deep Reactivity - The Real Challenge

Our current solution has a limitation: it only tracks changes to top-level properties. What about nested objects?

const store = createStore({ user: { name: "Alice", age: 25 } });
store.user.age = 26; // This won't trigger a re-render! 😱

The Proxy only wraps the top-level object, not nested ones. We need deep reactivity.

Step 5: Introducing Reflect API

Before we solve deep reactivity, let’s talk about the Reflect API. It’s JavaScript’s functional equivalent to object operations.

// Traditional way
obj[property] = value;
delete obj[property];

// Reflect way
Reflect.set(obj, property, value);
Reflect.deleteProperty(obj, property);

Why use Reflect in Proxies?

  1. It’s more explicit: You can see exactly what operation is happening
  2. Better error handling: Returns boolean success instead of throwing
  3. Respects receiver: Important for proper prototype chain handling
  4. Symmetry: Proxy traps and Reflect methods have matching signatures

Here’s an example showing why receiver matters:

const parent = { value: 1 };
const child = Object.create(parent);

const proxy = new Proxy(parent, {
  get(target, prop, receiver) {
    console.log('receiver:', receiver === child);
    // Using Reflect.get preserves the 'this' context correctly
    return Reflect.get(target, prop, receiver);
  }
});

Object.setPrototypeOf(child, proxy);
child.value; // receiver: true

Step 6: Making It Deeply Reactive

Now let’s create Proxies all the way down:

const makeReactive = (obj: any): any => {
  if (typeof obj !== "object" || obj === null) return obj;

  return new Proxy(obj, {
    get(target, prop, receiver) {
      const value = Reflect.get(target, prop, receiver);
      // If the value is an object, make it reactive too!
      return typeof value === "object" && value !== null
        ? makeReactive(value)
        : value;
    },
    set(target, prop, value, receiver) {
      const oldValue = target[prop];
      const didChange = oldValue !== value;
      const result = Reflect.set(target, prop, value, receiver);
      if (didChange) {
        notify();
      }
      return result;
    },
    deleteProperty(target, prop) {
      const result = Reflect.deleteProperty(target, prop);
      notify();
      return result;
    },
  });
};

This recursively wraps every object we access! When you do store.user.address.city = "NYC", each level (store, user, address) becomes a Proxy that can trigger notifications.

Step 7: Handling Arrays and Cloning

Arrays need special handling. When we clone state for snapshots, we need to recursively clone arrays too:

const clone = (obj: any): any =>
  Array.isArray(obj)
    ? obj.map(clone)
    : obj && typeof obj === "object"
      ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, clone(v)]))
      : obj;

This creates deep copies, ensuring our snapshots are truly independent from the reactive state.

Step 8: Optimizing with Selectors

Re-rendering every component when any part of state changes is wasteful. Let’s add selectors:

function useStore<U>(
  selector: (state: T) => U,
  equalityFn: (a: U, b: U) => boolean = Object.is
): U {
  let previous = selector(getSnapshot());

  return useSyncExternalStore(
    subscribe,
    () => {
      const next = selector(getSnapshot());
      // Only update if the selected value actually changed
      if (!equalityFn(previous, next)) {
        previous = next;
      }
      return previous;
    },
    () => selector(getSnapshot())
  );
}

Now components only re-render when their selected slice changes:

function UserName() {
  // Only re-renders when user.name changes
  const name = store.useStore(state => state.user.name);
  return <div>{name}</div>;
}

function UserAge() {
  // Only re-renders when user.age changes
  const age = store.useStore(state => state.user.age);
  return <div>{age}</div>;
}

The Complete Solution

Here’s our final, production-ready implementation:

import { useSyncExternalStore } from "react";

type Listener = () => void;

export function createStore<T extends object>(initialState: T) {
  const listeners = new Set<Listener>();
  let cachedSnapshot: T;

  const notify = () => {
    cachedSnapshot = clone(state);
    listeners.forEach((fn) => fn());
  };

  const subscribe = (fn: Listener) => {
    listeners.add(fn);
    return () => listeners.delete(fn);
  };

  const clone = (obj: any): any =>
    Array.isArray(obj)
      ? obj.map(clone)
      : obj && typeof obj === "object"
        ? Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, clone(v)]))
        : obj;

  const makeReactive = (obj: any): any => {
    if (typeof obj !== "object" || obj === null) return obj;

    return new Proxy(obj, {
      get(target, prop, receiver) {
        const value = Reflect.get(target, prop, receiver);
        return typeof value === "object" && value !== null
          ? makeReactive(value)
          : value;
      },
      set(target, prop, value, receiver) {
        const oldValue = target[prop];
        const didChange = oldValue !== value;
        const result = Reflect.set(target, prop, value, receiver);
        if (didChange) {
          notify();
        }
        return result;
      },
      deleteProperty(target, prop) {
        const result = Reflect.deleteProperty(target, prop);
        notify();
        return result;
      },
    });
  };

  const state = makeReactive(clone(initialState));
  cachedSnapshot = clone(state);

  const getSnapshot = () => cachedSnapshot;

  function useStore<U>(
    selector: (state: T) => U,
    equalityFn: (a: U, b: U) => boolean = Object.is
  ): U {
    let previous = selector(getSnapshot());

    return useSyncExternalStore(
      subscribe,
      () => {
        const next = selector(getSnapshot());
        if (!equalityFn(previous, next)) {
          previous = next;
        }
        return previous;
      },
      () => selector(getSnapshot())
    );
  }

  return { store: state, useStore };
}

Usage Example

// Create a store
const appStore = createStore({
  user: {
    name: "Alice",
    age: 25,
    address: {
      city: "New York",
      country: "USA"
    }
  },
  todos: [
    { id: 1, text: "Learn Proxies", done: false }
  ]
});

// Use in components
function UserProfile() {
  const user = appStore.useStore(state => state.user);
  
  return (
    <div>
      <h1>{user.name}</h1>
      <button onClick={() => appStore.store.user.age++}>
        Age: {user.age}
      </button>
    </div>
  );
}

function CitySelector() {
  const city = appStore.useStore(state => state.user.address.city);
  
  return <div>City: {city}</div>;
}

function TodoList() {
  const todos = appStore.useStore(state => state.todos);
  
  return (
    <ul>
      {todos.map(todo => (
        <li key={todo.id}>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => {
              // Mutate directly - it's reactive!
              todo.done = !todo.done;
            }}
          />
          {todo.text}
        </li>
      ))}
    </ul>
  );
}

Future Enhancements

Our store works well, but here are some ideas for extending it:

1. Middleware Support

Add hooks for logging, persistence, or time-travel debugging:

type Middleware = (change: { path: string[], oldValue: any, newValue: any }) => void;

// Could track property paths and call middleware on changes
const logger: Middleware = ({ path, oldValue, newValue }) => {
  console.log(`${path.join('.')} changed from`, oldValue, 'to', newValue);
};

2. Computed Values

Automatically derive state from other state:

const store = createStore({
  todos: [],
  get completedCount() {
    return this.todos.filter(t => t.done).length;
  }
});

3. Persistence

Automatically save/load from localStorage:

createStore(initialState, {
  persist: {
    key: 'app-state',
    storage: localStorage
  }
});

4. DevTools Integration

Connect to Redux DevTools for time-travel debugging and state inspection.

5. Transactions

Batch multiple changes to trigger only one notification:

store.batch(() => {
  store.user.name = "Bob";
  store.user.age = 30;
  store.user.address.city = "Boston";
}); // Only notifies once

6. Immutable Mode

For teams that prefer immutable updates, provide a setter function:

const [state, setState] = useStore();

setState(prev => ({
  ...prev,
  user: {
    ...prev.user,
    age: prev.user.age + 1
  }
}));

Key Takeaways

  1. Proxies intercept operations: They let you intercept object operations and add reactive behavior
  2. Reflect is cleaner: Use it in Proxy traps for better code and proper receiver handling
  3. Deep reactivity needs recursion: Wrap nested objects in Proxies too
  4. Caching prevents loops: Cache snapshots to avoid unnecessary re-renders
  5. Selectors optimize: Only re-render components when their selected data changes
  6. Clone for safety: Deep clone state for snapshots to avoid reference issues

Conclusion

We’ve built a production-ready reactive state management library in under 100 lines of code! While libraries like Zustand and Valtio have more features and battle-tested edge cases, understanding how they work demystifies state management and makes you a better developer.

The beauty of Proxies is that they make reactivity feel magical while keeping the implementation surprisingly simple. Next time you use a state management library, you’ll appreciate the elegance hiding under the hood.

Happy coding! 🚀


Further Reading: