The Store That Didn't Care About Your Framework

12 min read

I used to think state management was a framework problem. You pick React, you get Redux or Zustand. You pick Vue, you get Pinia. You pick Solid, you get... well, Solid already has signals. But what happens when your state needs to live outside the framework? Or worse, what happens when it needs to work with all of them at once?

Last week, I found myself thinking of a dashboard that needed to sync real-time stock prices across Web Components, a React widget, a Vue form, and a Solid chart. Four different frameworks. One source of truth.

This is the story of how I learned to stop worrying and love vanilla JavaScript.

The Problem Nobody Wants to Have

Here's the thing about modern web development: sometimes you don't get to pick your framework. Sometimes you inherit three. Sometimes you're migrating from one to another over 18 months. Sometimes you're building a widget library that needs to work everywhere.

The conventional wisdom says "just pick one framework and stick with it." But conventional wisdom hasn't met your enterprise codebase that's been evolving since 2016.

I needed state management that was like a good coffee shop—it doesn't care if you're a Mac person or a Windows person, it just serves coffee. My state store needed to be the coffee shop of state management.

The Vanilla Store Pattern

Here's what changed my perspective: Zustand isn't really a React library. It's a vanilla JavaScript library that happens to have React bindings. And once you realize that, a whole world opens up.

import { createStore } from 'zustand/vanilla';

const store = createStore((set, get) => ({
  count: 0,
  increment: () => set(state => ({ count: state.count + 1 })),
  decrement: () => set(state => ({ count: state.count - 1 }))
}));

This store doesn't know React exists. It doesn't know Vue exists. It's just a JavaScript object with a subscription mechanism. It's beautiful in its ignorance.

The magic is in the subscription API:

const unsubscribe = store.subscribe(
  state => state.count,  // selector
  count => console.log(count)  // listener
);

That's it. That's the whole API you need to understand. Select a slice of state, react to changes. No JSX. No templates. No compiler magic. Just functions calling functions.

Making It Work Everywhere

The real enlightenment came when I realized every framework is just JavaScript with opinions. And if you can subscribe to changes in JavaScript, you can make any framework reactive to those changes.

Web Components: The Vanilla Baseline

Web Components don't have a state management solution because they don't need one. They're just JavaScript classes:

class PriceDisplay extends HTMLElement {
  connectedCallback() {
    this.unsubscribe = store.subscribe(
      state => state.prices[this.symbol],
      price => this.render(price)
    );
  }
  
  disconnectedCallback() {
    this.unsubscribe?.();
  }
  
  render(price) {
    this.innerHTML = `<span>$${price.toFixed(2)}</span>`;
  }
}

No magic. Subscribe when you connect, unsubscribe when you disconnect. It's like addEventListener for state.

React: The useSyncExternalStore Revelation

React 18 gave us useSyncExternalStore, and suddenly external state became a first-class citizen:

function useStoreSelector(selector) {
  return useSyncExternalStore(
    callback => store.subscribe(selector, callback),
    () => selector(store.getState()),
    () => selector(store.getState())
  );
}

Three arguments: how to subscribe, how to get the current value, how to get the server value (we just use the same). React handles the rest. No context providers, no HOCs, just a hook that says "here's some external state, please track it."

Vue 3: Refs Are Just Reactive Boxes

Vue's reactivity system is actually the most straightforward to integrate:

import { ref, onMounted, onUnmounted } from 'vue';

function useStore(selector) {
  const value = ref(selector(store.getState()));
  let unsubscribe;
  
  onMounted(() => {
    unsubscribe = store.subscribe(
      selector,
      v => value.value = v
    );
  });
  
  onUnmounted(() => unsubscribe?.());
  
  return value;
}

Vue's ref is just a reactive box. Put values in, get reactivity out. The store subscription just needs to update the box.

Solid: Signals All the Way Down

Solid was almost too easy:

import { createSignal, onCleanup } from 'solid-js';

function useStore(selector) {
  const [value, setValue] = createSignal(selector(store.getState()));
  
  const unsubscribe = store.subscribe(selector, setValue);
  onCleanup(() => unsubscribe());
  
  return value;
}

Signals are just... subscribable values. The store has subscribable values. It's subscriptions all the way down.

The Selector Pattern: Your New Best Friend

Here's something that took me too long to appreciate: selectors aren't just for performance. They're a contract between your store and your components.

// Instead of this:
const state = store.getState();
const price = state.prices[symbol];

// You write this:
const selectPrice = symbol => state => state.prices[symbol];
const price = store.subscribe(selectPrice('AAPL'), handlePriceChange);

Selectors let you subscribe to exactly what you care about. Change anything else in the store? Your component doesn't even know. It's like GraphQL for your state.

The Performance Secret Nobody Talks About

Here's what blew my mind: this pattern is often faster than framework-specific state management.

Why? Because the store doesn't care about your framework's rendering cycle. It just calls functions. No virtual DOM diffing. No dependency tracking. No proxy magic. Just "this value changed, here's the new one."

Your framework only gets involved when it needs to—when the specific piece of state you're subscribed to actually changes. It's like having a personal assistant who only interrupts you when something important happens.

The Middleware Story

Since your store is just JavaScript, middleware becomes trivial:

import { subscribeWithSelector } from 'zustand/middleware';

const store = createStore(
  subscribeWithSelector((set, get) => ({
    // Now subscribers can use selectors with equality checks
    prices: {},
    updatePrice: (symbol, price) => set(state => ({
      prices: { ...state.prices, [symbol]: price }
    }))
  }))
);

Want logging? Wrap the setter. Want persistence? Subscribe to changes and save. Want time-travel debugging? Keep a history array. It's just JavaScript.

When Cross-Framework Reactivity Makes Sense (And When It Doesn't)

Let's be honest: most apps don't need this. If you're building a React app, use React state. If you're building a Vue app, use Vue state. Framework-specific solutions are optimized for their frameworks.

But cross-framework reactivity shines when:

  • You're migrating between frameworks gradually
  • You're building a plugin system where you don't control the host framework
  • You're creating embeddable widgets that need to work anywhere
  • You have a micro-frontend architecture with different teams using different frameworks
  • You need state to persist across framework boundaries (like between your app and a browser extension)

Comparison: The Alternatives

Module State Pattern

// The simplest approach
let state = { count: 0 };
const listeners = new Set();

export function subscribe(fn) {
  listeners.add(fn);
  return () => listeners.delete(fn);
}

export function updateState(newState) {
  state = { ...state, ...newState };
  listeners.forEach(fn => fn(state));
}

This works! But you lose selectors, middleware, and DevTools. You're building Zustand from scratch.

Event Emitters

const emitter = new EventTarget();
const state = { count: 0 };

function updateState(newState) {
  Object.assign(state, newState);
  emitter.dispatchEvent(new CustomEvent('change', { detail: state }));
}

Also works! But now you're manually managing event types and have no built-in selector support. Plus, EventTarget wasn't designed for this.

MobX

MobX is fantastic and works across frameworks too. But it uses proxies and decorators, which means more magic and a steeper learning curve. Sometimes you want your state boring.

Valtio

Valtio is proxy-based like MobX but simpler. It's great if you want mutable-style updates. But again, proxies mean you're opting into magic, and magic has a cost.

Native Observables (When They Land)

The Observable proposal would make this pattern native to JavaScript. Until then, we build our own.

The Philosophical Bit

There's something profound about state that doesn't care about its consumers. It reminds me of the Unix philosophy: do one thing well, and compose.

Your store manages state. Your framework renders UI. They communicate through a simple contract: subscriptions. Neither needs to know the implementation details of the other.

This separation isn't just architecturally clean—it's liberating. Your state logic becomes portable. Your components become simpler. Your tests become trivial.

The Thing I Wish I'd Known Earlier

Framework boundaries are artificial. Under the hood, it's all just JavaScript responding to changes. Once you internalize this, the walls between frameworks start to dissolve.

That stock price dashboard I mentioned?

You can see a live demo on stackblitz https://stackblitz.com/edit/stackblitz-starters-3zeqznxo?file=index.html. Four frameworks, one store, zero problems. The Web Components don't know React exists. React doesn't know Vue exists. Vue doesn't know Solid exists. They all just know that when prices change, they should update.

And isn't that all we really want? State that changes, and UI that responds. Everything else is just implementation details.


The vanilla store pattern isn't always the right answer, but when you need state that transcends framework boundaries, it might be the only answer that makes sense. Sometimes the best framework-specific solution is no framework at all.