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?"

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 statePerformance 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
| Feature | ZenBox | Zustand |
|---|---|---|
| Learning Curve | ✅ As easy as Vue | ✅ Low |
| Vue-like Hooks | ✅ useComputed / 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 |