DEV Community

Cover image for React State Management in 2025: Why I'm Ditching Zustand for ZenBox
Del Wang
Del Wang

Posted on

React State Management in 2025: Why I'm Ditching Zustand for ZenBox

TL;DR: Found a new React state manager that combines Zustand's simplicity with Vue's developer experience. It's called ZenBox, it's tiny (~100 lines of core code), and it might solve some pain points you didn't know you had.

Context: My State Management Journey

Been doing React for 5+ years. Started with Redux (the pain), moved to MobX (the magic), tried Recoil (RIP), settled on Zustand (the balance). Recently worked on a Vue project and... damn, computed and watch are nice.

// Vue - so natural it hurts
const greeting = computed(() => `Hello ${user.value.name}!`);

watch(
  () => user.value.name,
  (newName) => {
    console.log(`Welcome, ${newName}!`);
  }
);
Enter fullscreen mode Exit fullscreen mode

Came back to React wondering: why can't we have nice things?

What I Found: ZenBox

Someone built exactly what I was thinking about. It's like Zustand but with Vue-inspired APIs that actually make sense.

// ZenBox - Vue vibes, React power
import { createStore, useComputed, useWatch } from "zenbox";

// All types are inferred. No interfaces needed.
const userStore = createStore({
  name: "Alice",
  age: 25,
  updateName: (name: string) => userStore.setState({ name }),
});

function UserProfile() {
  // Vue-like computed - automatic dependency tracking
  const greeting = useComputed(() => `Hello, ${userStore.value.name}!`);

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

  return <h1>{greeting}</h1>;
}
Enter fullscreen mode Exit fullscreen mode

If you squint, this could be Vue code. But it's React, and it works exactly like you'd expect.

The Good Stuff

1. No TypeScript Boilerplate

Instead of this Zustand dance:

interface UserState {
  name: string;
  age: number;
  updateName: (name: string) => void;
}

const useUserStore = create<UserState>()((set) => ({
  name: "Alice",
  age: 25,
  updateName: (name) => set((state) => ({ ...state, name })),
}));
Enter fullscreen mode Exit fullscreen mode

You write this:

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

// All types are inferred. No interfaces needed.
Enter fullscreen mode Exit fullscreen mode

The difference? I write the logic once, not twice. No more maintaining separate interfaces and implementations.

2. Cross-Store Dependencies Actually Work

This is where Zustand gets messy. Want to combine multiple stores? Good luck with that slice pattern.

ZenBox just... works:

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

function Header() {
  const { greeting, isDarkMode, unreadCount } = useComputed(() => ({
    greeting: `Hello ${user.value.name}`,
    isDarkMode: settings.value.theme === "dark",
    unreadCount: notifications.value.unread.length,
  }));

  return (
    <header className={isDarkMode ? "dark" : "light"}>
      <h1>{greeting}</h1>
      {unreadCount > 0 && <Badge count={unreadCount} />}
    </header>
  );
}
Enter fullscreen mode Exit fullscreen mode

ZenBox automatically tracks all three stores and only triggers re-renders when the computed values actually change.

3. Immer Built-In, Zero Config

const store = createStore({
  todos: [],
  addTodo: (text) => {
    store.setState((state) => {
      state.todos.push({ id: Date.now(), text, done: false });
    });
  },
});
Enter fullscreen mode Exit fullscreen mode

Direct mutations work out of the box. No middleware, no configuration, no headaches.

4. Performance by Default

ZenBox uses shallow comparison by default (like React's useMemo), so you get optimal performance without thinking about it:

// Only re-renders when the keys actually change
const keys = useComputed(() => Object.keys(todos.value));
Enter fullscreen mode Exit fullscreen mode

Real Example: Todo App

Here's a complete todo implementation to show how it all fits together:

import { createStore, useComputed, useWatch } from "zenbox";

const todoStore = createStore({
  todos: [] as { id: number; text: string; done: boolean }[],
  filter: "all" as "all" | "active" | "completed",

  addTodo: (text) => {
    todoStore.setState((s) => {
      s.todos.push({ id: Date.now(), text, done: false });
    });
  },

  toggleTodo: (id) => {
    todoStore.setState((s) => {
      const todo = s.todos.find((t) => t.id === id);
      if (todo) todo.done = !todo.done;
    });
  },
});

function TodoApp() {
  // Computed values - like Vue
  const filteredTodos = useComputed(() => {
    const { todos, filter } = todoStore.value;
    return filter === "active" ? todos.filter((t) => !t.done) : todos;
  });

  const stats = useComputed(() => {
    const todos = todoStore.value.todos;
    return {
      total: todos.length,
      remaining: todos.filter((t) => !t.done).length,
    };
  });

  // Watch for side effects - like Vue
  useWatch(
    () => todoStore.value.todos,
    (todos) => localStorage.setItem("todos", JSON.stringify(todos))
  );

  const [input, setInput] = useState("");

  return (
    <div>
      <h1>Todos ({stats.remaining} remaining)</h1>

      <form
        onSubmit={(e) => {
          e.preventDefault();
          if (input.trim()) {
            todoStore.value.addTodo(input.trim());
            setInput("");
          }
        }}
      >
        <input
          value={input}
          onChange={(e) => setInput(e.target.value)}
          placeholder="Add todo..."
        />
      </form>

      {filteredTodos.map((todo) => (
        <div key={todo.id}>
          <input
            type="checkbox"
            checked={todo.done}
            onChange={() => todoStore.value.toggleTodo(todo.id)}
          />
          <span>{todo.text}</span>
        </div>
      ))}
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The Trade-offs

What ZenBox excels at:

  • Vue-like developer experience in React
  • Less TypeScript boilerplate
  • Cross-store dependencies just work
  • Immer built-in (direct mutations)
  • Lightweight (~100 lines of core code)

What it doesn't have (yet):

  • Newer library (less battle-tested)
  • Smaller ecosystem than Zustand
  • No built-in persistence
  • No DevTools integration

Should You Switch?

Switch if:

  • You're tired of Zustand's TypeScript ceremony
  • You want Vue-like computed/watch APIs
  • You need cross-store dependencies
  • You're starting a new project

Stick with Zustand if:

  • You need battle-tested stability
  • You rely heavily on middleware ecosystem
  • Your team is already productive with current setup

My Recommendation

I genuinely think ZenBox is the future of React state management. It takes the best parts of Zustand and adds the developer experience improvements we've been wanting.

Is it perfect? No. Is it ready for production? I think so (I'm using it). Will it replace Zustand for everyone? Probably not, but it should be on your radar.

Try it in your next side project. See if it clicks for you like it did for me.

npm install zenbox
Enter fullscreen mode Exit fullscreen mode

👉 Visit https://zenbox.del.wang to view the full documentation.

Final Thoughts

Three months ago, I was a happy Zustand user. Today, I'm a ZenBox advocate. Not because it's perfect, but because it solved real pain points I was experiencing.

The Vue-inspired APIs aren't just syntactic sugar - they represent a different way of thinking about state that feels more natural to me. Your mileage may vary, but I think it's worth exploring.

What’s your take on React state management? Have you tried ZenBox? Share your thoughts in the comments.

Top comments (0)