Why ZenBox?

How ZenBox compares to other React state management libraries

Motivation

I've been writing React for years. I've used Redux, MobX, Zustand, Jotai, Valtio - you name it. Each has its place, but none felt quite right.

Then I worked on a Vue project. The moment I wrote my first computed value, I thought: "Why can't React be this simple?"

Zustand x Vue

So I built ZenBox - it brings Zustand's simplicity with Vue's joyful developer experience to React.

What's actually different

Vue-like development experience

const userStore = createStore({
  name: "Alice",
  age: 25,
});

// Computed values that just work
const greeting = useComputed(() => `Hello ${userStore.value.name}!`);

// Watch changes like Vue
useWatch(
  () => userStore.value.name,
  (newName) => console.log(`Name changed: ${newName}`)
);

You don't write TypeScript interfaces

Other libraries:

interface UserState {
  name: string;
  age: number;
  posts: Post[];
  updateName: (name: string) => void;
  addPost: (post: Post) => void;
}

const useUserStore = create<UserState>()((set) => ({
  // ... implement everything again, and maintain types in 2 places
}));

✅ ZenBox:

const userStore = createStore({
  name: "Alice",
  age: 25,
  updateName: (name) =>
    userStore.setState((s) => {
      s.name = name;
    }),
});

// That's it. All types are inferred.

Cross-store dependencies just work

Zustand: You need to manually create a combined store with all the slices.

interface BearSlice {
  bears: number;
}

interface FishSlice {
  fishes: number;
}

const createBearSlice = () => ({ bears: 0 });
const createFishSlice = () => ({ fishes: 0 });

// Complex setup
const useBoundStore = create<BearSlice & FishSlice>()((...a) => ({
  ...createBearSlice(...a),
  ...createFishSlice(...a),
}));

const total = useBoundStore((state) => state.bears + state.fishes);

✅ ZenBox: Want to compute something from multiple stores? Just reference them:

const user = createStore({ name: "Alice" });
const posts = createStore({ items: [] });
const settings = createStore({ theme: "dark" });

// ZenBox automatically tracks all three stores
const dashboard = useComputed(() => ({
  greeting: `Hello ${user.value.name}`,
  postCount: posts.value.items.length,
  isDarkMode: settings.value.theme === "dark",
}));

Immer integration

Zustand: Manual middleware setup with type gymnastics

import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

// Separate type definitions required
type State = {
  count: number;
  nested: { items: string[] };
};

type Actions = {
  increment: (qty: number) => void;
  addItem: (item: string) => void;
};

// Complex middleware wrapping
export const useCountStore = create<State & Actions>()(
  immer((set) => ({
    count: 0,
    nested: { items: [] },
    increment: (qty: number) =>
      set((state) => {
        state.count += qty;
      }),
    addItem: (item: string) =>
      set((state) => {
        state.nested.items.push(item);
      }),
  }))
);

✅ ZenBox: Immer built-in, zero configuration

const store = createStore({
  count: 0,
  nested: { items: [] as string[] },
  increment: (qty: number) => {
    store.setState((state) => {
      state.count += qty; // Direct mutation works
    });
  },
  addItem: (item: string) => {
    store.setState((state) => {
      state.nested.items.push(item); // Nested mutations work
    });
  },
});

// Types are automatically inferred from initial state

Performance optimization

Zustand: Manual shallow comparison with imports

import { create } from "zustand";
import { useShallow } from "zustand/react/shallow";

const useMeals = create(() => ({
  papaBear: "large porridge-pot",
  mamaBear: "middle-size porridge pot",
  littleBear: "A little, small, wee pot",
}));

// Without useShallow, component re-renders on every state change
const SlowBearNames = () => {
  const names = useMeals((state) => Object.keys(state)); // Re-renders always
  return <div>{names.join(", ")}</div>;
};

const BearNames = () => {
  // Manual shallow comparison required
  const names = useMeals(useShallow((state) => Object.keys(state)));

  return <div>{names.join(", ")}</div>;
};

✅ ZenBox: Shallow comparison by default (same as React's useMemo)

// Default to shallow comparison - only re-renders when keys change
const names = useComputed(() => Object.keys(bearStore.value));

// Deep comparison when needed
const deepNames = useComputed(() => Object.keys(bearStore.value), {
  deep: true,
});

Performance Note: ZenBox uses shallow comparison by default for optimal performance. Deep comparison is available when needed but should be used sparingly.

State access patterns

Zustand: Manual get() and set() parameter passing

import { create } from "zustand";

const useCountStore = create((set, get) => ({
  count: 0,
  increment: () => {
    // Verbose parameter passing
    set({
      ...get(),
      count: get().count + 1,
    });
  },
}));

// External access requires getState()
useCountStore.getState().count;
useCountStore.getState().increment();

Jotai: Verbose get and set functions with limited readability

const count1 = atom(1);
const count2 = atom(2);
const count3 = atom(3);

// Every dependency needs explicit get() calls
const sum = atom((get) => get(count1) + get(count2) + get(count3));

// Complex operations become unreadable
const atoms = [count1, count2, count3, ...otherAtoms];
const sum = atom((get) => atoms.map(get).reduce((acc, count) => acc + count));

// Write atoms require dual function signatures
const decrementCountAtom = atom(
  (get) => get(countAtom),
  (get, set, _arg) => set(countAtom, get(countAtom) - 1)
);

Valtio: Proxy patterns with this context complexity

const state = proxy({
  count: 1,
  get doubled() {
    return this.count * 2; // Mental overhead with `this`
  },
  user: {
    name: "John",
  },
  greetings: {
    get greetingEn() {
      return "Hello " + this.user.name; // WRONG - `this` points to `state.greetings`
    },
  },
});

// Snapshots create confusion
const snap = snapshot(state);
console.log(snap.doubled); // 2

// State changes don't update snapshots automatically
state.count = 10;
console.log(snap.doubled); // Still 2 - stale!

✅ ZenBox: Unified store.value interface for everything

const store = createStore({
  count: 0,
  increment: () =>
    store.setState((s) => {
      s.count++;
    }),
});

// Read state - always current
const count = store.value.count;

// Update state - direct assignment
store.value = { count: count + 1 };

// Call actions - same interface
store.value.increment();

// Computed values - automatic dependency tracking
const doubleCount = useComputed(() => store.value.count * 2);

Feature comparison

FeatureZenBoxZustand
Learning Curve✅ As easy as Vue✅ Low
Vue-like HooksuseComputed / useWatch❌ Not supported
TypeScript✅ Auto-inference⚠️ Manual interfaces
Cross-Store✅ Auto tracking❌ Not supported
State Access✅ Unified store.value⚠️ Explicit get() / set()
Scoping✅ Built-in Provider❌ Global by default
Immer✅ Built-in⚠️ Middleware required
Persistence❌ No built-in support⚠️ Middleware required
DevTools❌ No built-in support⚠️ Middleware required
Bundle Size< 3KB gzipped (without Immer)< 1KB gzipped