https://thales.me/posts/why-i-built-snapstate/
thales.me blog
Why I Built Snapstate
April 8, 2026 / 17 min read
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
Failed to load: {error}
; return (Failed to load dashboard: {error}
, }); 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. - back to blog