Why I Built Snapstate
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/snapstateIf this boundary sounds useful to you, take a look at the docs or the example app.