Client State vs Server State - Jotai and TanStack Query Instead of Redux Toolkit
Split client state and server state cleanly with Jotai and TanStack Query, and drop Redux Toolkit's boilerplate.
This post contains affiliate links for tools I use in production. If you buy through them I earn a commission at no extra cost to you. Recommendations are based on my own experience.
Table of Contents
Introduction
TL;DR: use Jotai or Zustand for client state and TanStack Query for server state. Treating both as the same problem is what makes Redux Toolkit setups grow complicated.
Current versions:
{ "jotai": "^3.0.0", "@tanstack/react-query": "^5.103.2"}- Jotai: primitive, atom-based state for React. No reducers, no action types.
- TanStack Query: async state management with caching, retries, and background refetching for anything that comes from a server.
Why Not Redux Toolkit?
Jotai plus TanStack Query is a lighter alternative to Redux Toolkit for most apps. Redux Toolkit still does its job, but two things add friction once a codebase grows past a handful of features: a single global store that every slice has to register with, and a thunk-based async layer that duplicates what TanStack Query already does better, namely caching, deduping in-flight requests, and retrying failed calls.
If your app already has a large Redux Toolkit store with RTK Query, migrating away is not automatically a win. The point here is for new projects, or projects where server state is currently jammed into slices and reducers.
Should You Use Only TanStack Query?
If you know TanStack Query well, it can act as your only state layer, including state that never touches a server. Store it with a query whose queryFn never actually fetches, as shown below. This works, but it means every consumer needs to understand TanStack Query’s cache semantics even for state that has nothing to do with a server, which is a real cost for team onboarding.
Quick Comparison
Client-side state with Jotai:
const outputDatetimeAtom = atom("");const inputIncludesTimezoneAtom = atom(false);
const [outputDatetime, setOutputDatetime] = useAtom(outputDatetimeAtom);const [inputIncludesTimezone, setInputIncludesTimezone] = useAtom( inputIncludesTimezoneAtom);Non-server global state modeled through TanStack Query’s cache:
import { useOutputDatetimeState } from "./state/useOutputDatetimeState";
export default function App() { const { setData: setOutputDatetimeData, resetData: resetOutputDatetimeData } = useOutputDatetimeState(); // ...}import { createGlobalState } from "./createGlobalState";
type OutputDatetimeState = { convertedDatetime: string;};
export const useOutputDatetimeState = createGlobalState<OutputDatetimeState>( "outputDatetimeState", { convertedDatetime: "" });import { useQuery, useQueryClient } from "@tanstack/react-query";
export function createGlobalState<T>( queryKey: unknown, initialData: T | null = null) { return function () { const queryClient = useQueryClient();
const { data } = useQuery({ queryKey: [queryKey], queryFn: () => Promise.resolve(initialData), gcTime: Infinity, // renamed from cacheTime in TanStack Query v5 refetchInterval: false, refetchOnMount: false, refetchOnWindowFocus: false, refetchOnReconnect: false, refetchIntervalInBackground: false, });
function setData(newData: Partial<T>) { queryClient.setQueryData<T | undefined>([queryKey], (oldData) => ({ ...(oldData || {}), ...newData, })); }
function resetData() { queryClient.invalidateQueries({ queryKey: [queryKey] }); queryClient.refetchQueries({ queryKey: [queryKey] }); }
return { data, setData, resetData }; };}cacheTime was renamed to gcTime in TanStack Query v5; the old name is silently ignored, so if a v4 snippet like this stops working after an upgrade, that rename is the first thing to check.
Debugging this in production
State that lives half in Jotai atoms and half in a TanStack Query cache is hard to reason about from a bug report alone: the user says “the value reset” but you cannot tell which layer reset it without reproducing their exact session. A session replay and error tracker such as LogRocket or Sentry shows the exact sequence of actions and the state at the moment it broke, and both have a free tier that covers a side project.
Conclusion
Splitting client state and server state into Jotai and TanStack Query respectively removes a category of Redux Toolkit boilerplate: no slices, no thunks, no manual cache invalidation logic. Each library does the part of state management it is actually good at.
If you are taking this into production, the next steps below cover auth, data and observability, and the newsletter is where the Astro SaaS boilerplate ships first.
Next steps: scaling to production
If you take this into production, these are the pieces I would add first.
- Clerk Clerk provides drop-in authentication and user management components. Hosted auth saves the login, session and org code you would otherwise maintain.
- Supabase Supabase is a hosted Postgres platform with authentication and storage built in. Postgres with row-level security, so the data layer is ready for multi-tenant apps.
- Sentry Sentry captures errors and performance traces from production applications. Errors and slow transactions from real users, with source maps, before customers report them.
Production-ready Astro + TanStack architecture
Get the architecture cheat sheet and join the waitlist for the Astro SaaS boilerplate.