Show HN: Tired of logic in useEffect, I built a class-based React state manager

原始链接: https://thales.me/posts/why-i-built-snapstate/

## Snapstate:在 React 中分离逻辑与 UI Snapstate 是一种新的状态管理方法,旨在解决 React 组件因包含过多业务逻辑而变得臃肿的常见问题。作者发现,随着时间的推移,React 代码库经常将 UI 关注点与数据获取、规则和变更混合在一起,使代码更难测试、重用和理解。 Snapstate 通过倡导明确的关注点分离来解决这个问题:**React 处理渲染,而纯 TypeScript 类管理状态和应用程序逻辑。** 这与流行的选项(如 Redux,过于繁琐;Zustand,针对复杂流程的 Hook 优先;MobX,过于“神奇”)形成对比。 核心思想是创建专门的“存储”——基于类的实体——来封装行为。组件随后成为“哑视图”,从这些存储接收数据和回调。这使得无需 React 渲染环境即可直接测试业务逻辑。 作者强调了简化测试、提高可重用性和更清晰的代码组织等好处。Snapstate 还在此核心原则的基础上提供了作用域存储和表单验证等功能。它目前作为 alpha 项目在 GitHub 和 npm 上可用。

一位 Hacker News 用户 thalesfp 分享了一个新的 React 状态管理器,作为替代复杂的 `useEffect` 逻辑,详情见 thales.me。该项目采用基于类的状态管理方法。 评论区的讨论集中在是否需要这种自定义解决方案。一位评论者 igor47 认为,像 TanStack Query (React Query) 这样的工具已经处理了许多常见状态管理需求,特别是数据获取,并且开发者常常过度复杂化前端状态。他们提倡更多地依赖服务器端状态。 另一位评论者 mjfisher 赞扬了该状态管理器明确的编写步骤 (`scoped()`),并将其与 Zustand 和 MobX 的方法进行了有利对比。 这篇帖子引发了一场关于平衡自定义解决方案与现有库,以及不同应用程序的适当复杂程度的讨论。
相关文章

原文

TL;DR: I built Snapstate to move business logic out of React components and into plain TypeScript classes. The result: stores you can test without React, components that only render, and a cleaner boundary between UI and application logic.


React is excellent at rendering UI. It's less convincing as the place where the rest of the app should live.

Over time, a lot of React codebases drift into the same shape: data fetching in useEffect, business rules inside custom hooks, derived values spread across useMemo, and mutations hidden in event handlers. The app still works, but the boundaries get blurry. Logic that should be easy to test or reuse ends up coupled to render timing, hook rules, and component lifecycles.

I've written plenty of code like this myself. Snapstate came out of wanting a cleaner boundary: React for rendering, plain TypeScript classes for state and business logic.

The boundary I wanted

This isn't an argument against hooks. Hooks are a good fit for UI concerns: subscribing to browser APIs, coordinating animations, managing local component state, and composing rendering behavior.

The trouble starts when application logic moves into that same layer. A hook that fetches data, normalizes it, tracks loading and errors, coordinates retries, and exposes mutations is no longer just a React concern. It's an application service expressed in React primitives.

That has a few predictable costs. Testing usually starts with rendering infrastructure instead of the logic itself. Reuse is tied to React, even when the logic is not. And understanding the behavior means reasoning about dependency arrays, mount timing, and re-renders alongside the business rules.

I wanted a place where that logic could exist without carrying React around with it.

Why not the existing options?

I didn't build Snapstate because the ecosystem was empty. I built it because I wanted a different set of tradeoffs.

Redux gives you a predictable model, but for the kind of apps I build it also brings more ceremony than I want. The state story is clear. The business-logic story still tends to spread across reducers, thunks, middleware, and selectors.

Zustand is much lighter, and I understand why people like it. But for larger flows I wanted something less hook-centric. Once async operations, derived values, and cross-store dependencies start piling up, I still want the logic to read like regular application code.

MobX is probably the closest to what I wanted. It embraces classes and keeps a lot of logic out of components. I just wanted something more explicit and less magical than implicit proxy tracking.

Snapstate is my attempt at that middle ground: class-based stores, explicit updates, and React as an adapter instead of the place where the business logic lives.

The shape I was trying to get away from

Here's a simplified dashboard component in the style I've seen many times. Auth state comes from context, data is fetched in an effect, loading and errors are local, and derived values live next to rendering:

function Dashboard() {
  const { user } = useAuth();
  const [stats, setStats] = useState(null);
  const [notifications, setNotifications] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    if (!user) return;
    setLoading(true);
    setError(null);

    Promise.all([
      fetch(`/api/users/${user.id}/stats`).then((r) => r.json()),
      fetch(`/api/users/${user.id}/notifications`).then((r) => r.json()),
    ])
      .then(([statsData, notifData]) => {
        setStats(statsData);
        setNotifications(notifData);
      })
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, [user]);

  const unreadCount = useMemo(
    () => notifications.filter((n) => !n.read).length,
    [notifications]
  );

  const markAsRead = (id: string) => {
    setNotifications((prev) =>
      prev.map((n) => (n.id === id ? { ...n, read: true } : n))
    );
  };

  if (loading) return <Skeleton />;
  if (error) return <p>Failed to load: {error}</p>;

  return (
    <div>
      <h1>Dashboard ({unreadCount} unread)</h1>
      <StatsCards stats={stats} />
      <NotificationList items={notifications} onRead={markAsRead} />
    </div>
  );
}

There is nothing unusual or even wrong about this component. The problem is that it is carrying too many responsibilities at once: auth awareness, data fetching, loading state, error state, derived data, mutations, and rendering. That makes it harder to test and harder to reuse because the logic is glued to the component lifecycle.

The same feature with a store

What I wanted instead was to move that behavior into a store and leave React with the rendering.

Here's an auth store:

interface AuthState {
  user: User | null;
  token: string;
}

class AuthStore extends SnapStore<AuthState, "login"> {
  constructor() {
    super({ user: null, token: "" });
  }

  login(email: string, password: string) {
    return this.api.post({
      key: "login",
      url: "/api/auth/login",
      body: { email, password },
      onSuccess: (res) => {
        this.state.merge({ user: res.user, token: res.token });
      },
    });
  }

  logout() {
    this.state.reset();
  }
}

export const authStore = new AuthStore();

And here's a dashboard store that derives the current userId from auth, loads the dashboard data, and owns the mutations:

interface DashboardState {
  userId: string;
  stats: { revenue: number; activeUsers: number; errorRate: number } | null;
  notifications: { id: string; message: string; read: boolean }[];
}

class DashboardStore extends SnapStore<DashboardState, "load"> {
  private unreadCount_ = this.state.computed(
    ["notifications"],
    (s) => s.notifications.filter((n) => !n.read).length
  );

  constructor() {
    super({ userId: "", stats: null, notifications: [] });
    this.derive("userId", authStore, (s) => s.user?.id ?? "");
  }

  load() {
    const userId = this.state.get("userId");
    return this.api.all({
      key: "load",
      requests: [
        { url: `/api/users/${userId}/stats`, target: "stats" },
        { url: `/api/users/${userId}/notifications`, target: "notifications" },
      ],
    });
  }

  get unreadCount() {
    return this.unreadCount_.get();
  }

  markAsRead(id: string) {
    this.state.patch("notifications", (n) => n.id === id, { read: true });
  }
}

There's no global instance for DashboardStore — it gets scoped to the component lifecycle. The view stays dumb. It gets plain props and callbacks, and it doesn't know where the data came from, how it loads, or how auth works.

function DashboardView({
  stats,
  notifications,
  unreadCount,
  onMarkRead,
}: {
  stats: DashboardState["stats"];
  notifications: DashboardState["notifications"];
  unreadCount: number;
  onMarkRead: (id: string) => void;
}) {
  return (
    <div>
      <h1>Dashboard ({unreadCount} unread)</h1>
      <StatsCards stats={stats} />
      <NotificationList items={notifications} onRead={onMarkRead} />
    </div>
  );
}

export const Dashboard = SnapStore.scoped(DashboardView, {
  factory: () => new DashboardStore(),
  props: (store) => ({
    stats: store.getSnapshot().stats,
    notifications: store.getSnapshot().notifications,
    unreadCount: store.unreadCount,
    onMarkRead: (id: string) => store.markAsRead(id),
  }),
  fetch: (store) => store.load(),
  loading: () => <Skeleton />,
  error: ({ error }) => <p>Failed to load dashboard: {error}</p>,
});

This isn't magic. It is just a different boundary. The store owns the behavior. React renders the result. That separation makes the code easier to read because the component no longer has to explain the entire feature.

Testing gets simpler

The payoff is most obvious in tests. When the logic lives in a plain class, you can exercise it directly.

describe("DashboardStore", () => {
  it("marks a notification as read", () => {
    const store = new DashboardStore();

    store.state.set("notifications", [
      { id: "n1", message: "Invoice paid", read: false },
      { id: "n2", message: "New signup", read: true },
    ]);

    store.markAsRead("n1");

    expect(store.unreadCount).toBe(0);
    expect(store.getSnapshot().notifications[0].read).toBe(true);
  });
});

No render harness. No providers. No act(). No waiting for UI state just to verify a business rule. The view can still be tested with React Testing Library if you want, but the important part is that the behavior is no longer trapped inside the component.

What followed from that design

Once I committed to that boundary, some other APIs fell out naturally: scoped stores for per-screen lifecycle, form stores with Zod validation, and URL synchronization for screens where the URL should be part of the state model.

Those features matter, but they are consequences of the original idea, not the reason I started the project. The reason was much simpler: I wanted business logic to live in plain TypeScript, and I wanted React to go back to being the rendering layer.

Try it

Snapstate is open source on GitHub and available on npm. I still consider it alpha because I want more time on the edges, but the core API has been stable in production for me.

npm install @thalesfp/snapstate

If this boundary sounds useful to you, take a look at the docs or the example app.

联系我们 contact @ memedata.com