React State Management with Reatom: When useState Stops Being Enough

The policy builder did not look dangerous at first.
It was "just a form." A few sections, some conditional fields, a Save Draft button, a final Submit action. We already had TanStack Query for server state and TanStack Form for field state. It felt like the rest could live in component state.
Then the requirements arrived one by one.
If the user changed coverage type, three sections had to recalculate. If they removed a beneficiary, dependent warnings had to disappear. If they switched tabs, the draft had to stay alive. If the API returned a validation warning, it had to attach to a section that might not be mounted. If they clicked Review, the app had to derive a summary from fields, rules, permissions, and unsaved changes.
Nothing was individually hard. Together, it became a graph.
I've been in this room before. Different company, different domain, same shape. A workflow starts as local state. Then you add a context. Then a reducer. Then a few selectors. Then effects hidden in components. Eventually nobody can answer a simple question: when this value changes, what else reacts?
That is the point where I want a state management library.
Not for every boolean. Not for every modal. For the moment client state becomes a reactive model instead of a component detail.
The question is not "do we need global state?" The question is "does this workflow have a state graph?"
This is article #7 in the React Playbook series. We already covered TanStack Query and TanStack Router. Now we need the missing piece: client state that does not belong to the server, but has outgrown useState.
Query Is Not Your Client Store
TanStack Query owns server state.
Policies from the API. Customer records. Coverage types. Search results. Anything fetched, cached, invalidated, and synchronized with a backend.
Client state is different:
- which policy sections are expanded
- current unsaved draft
- selected rows in a table
- multi-step wizard progress
- temporary validation warnings
- optimistic local operations before submit
- undo history
- derived review summary
The mistake is pushing these into Query because Query is already installed.
queryClient.setQueryData(['policy-builder-draft'], draft);
This works mechanically. It is also the wrong abstraction. Query cache is built around remote data identity, freshness, invalidation, refetching, and synchronization. A local draft is not stale. It should not refetch. It does not have server freshness semantics.
Use Query for server state.
Use a client-state tool for client state.
Why Not Just useState?
useState is still the default.
For component-local state, it is perfect:
const [isOpen, setIsOpen] = useState(false); const [selectedTab, setSelectedTab] = useState<'details' | 'activity'>('details');
Do not install a state library for that.
useReducer is good when one component owns a local state machine:
const [state, dispatch] = useReducer(policyWizardReducer, initialWizardState);
Still fine.
The trouble starts when state has to be shared, derived, observed, and changed from multiple places.
In the policy builder:
- The wizard footer needs to know whether any section is dirty.
- The review page needs a derived summary.
- Section components need local edits.
- Autosave needs to react to draft changes.
- Permission logic needs to disable actions.
- Validation warnings need to attach to sections.
You can put all of this in context. But context broadcasts. Every consumer becomes part of the same render conversation. You can split contexts. Then you manage context architecture. You can write selectors. Then you are building a state library slowly, without the debugging tools or mental model.
That is where Reatom enters.
Why Reatom
I like Reatom for workflows where state is a graph.
Small atoms. Derived atoms. Actions. Effects. Explicit dependencies. React components subscribe to the pieces they read, not to a giant provider value.
The important thing is not the library name. The important thing is the model:
Break client state into small atoms, derive what can be derived, and make actions the place where transitions happen.
That model fits complex frontend workflows better than a single reducer object or a large Zustand store.
Zustand is excellent for simple global stores. I use it happily for small apps: auth-ish UI state, sidebar preferences, feature flags, lightweight cross-component state.
But for CRM-scale workflows with derived state, undo, chained reactions, and local process logic, I prefer a graph. Reatom makes the graph visible.
A Small Policy Builder Model
Imagine the policy builder feature:
src/ features/ build-policy/ model/ policy-builder.atoms.ts policy-builder.actions.ts policy-builder.selectors.ts ui/ PolicyBuilderPage.tsx CoverageSection.tsx BeneficiariesSection.tsx ReviewPanel.tsx index.ts
The model belongs to the feature because the state exists for this workflow. It is not an entity. It is not shared. It is not a generic app store.
Start with atoms:
// src/features/build-policy/model/policy-builder.atoms.ts import { atom } from '@reatom/core'; export interface PolicyDraft { holderName: string; coverageType: 'standard' | 'premium'; beneficiaries: BeneficiaryDraft[]; } export const policyDraftAtom = atom<PolicyDraft>( { holderName: '', coverageType: 'standard', beneficiaries: [], }, 'policyDraftAtom', ); export const activeSectionAtom = atom<PolicySection>('coverage', 'activeSectionAtom'); export const dirtySectionsAtom = atom<Set<PolicySection>>( new Set(), 'dirtySectionsAtom', );
Small atoms. Named atoms. No giant policyBuilderStore object with everything inside.
Why?
Because different components care about different parts. The coverage section should not rerender because the beneficiaries list changed. The review panel might care about the full draft, but the active tab indicator only cares about activeSectionAtom.
Derived State Should Be Derived
Dirty state often gets stored badly.
const [canSubmit, setCanSubmit] = useState(false);
Then you remember to update it after every draft change, every validation change, every permission change. Or you forget, and the button lies.
Derived state should be computed from source state:
// src/features/build-policy/model/policy-builder.selectors.ts import { atom } from '@reatom/core'; import { dirtySectionsAtom, policyDraftAtom } from './policy-builder.atoms'; export const hasUnsavedChangesAtom = atom((ctx) => { return ctx.spy(dirtySectionsAtom).size > 0; }, 'hasUnsavedChangesAtom'); export const reviewSummaryAtom = atom((ctx) => { const draft = ctx.spy(policyDraftAtom); return { holder: draft.holderName || 'Missing holder name', coverage: draft.coverageType, beneficiaryCount: draft.beneficiaries.length, }; }, 'reviewSummaryAtom');
The exact Reatom API can vary by package setup, but the principle is stable: derived atoms observe source atoms and recompute when dependencies change.
No manual synchronization.
No effect that says "when A changes, update B."
B is a function of A.
Actions Are Transitions
I do not want random components mutating atoms however they like.
For meaningful workflow changes, define actions:
// src/features/build-policy/model/policy-builder.actions.ts import { action } from '@reatom/core'; import { dirtySectionsAtom, policyDraftAtom } from './policy-builder.atoms'; export const updateCoverageType = action( (ctx, coverageType: PolicyDraft['coverageType']) => { const draft = ctx.get(policyDraftAtom); policyDraftAtom(ctx, { ...draft, coverageType, }); const dirtySections = new Set(ctx.get(dirtySectionsAtom)); dirtySections.add('coverage'); dirtySectionsAtom(ctx, dirtySections); }, 'updateCoverageType', );
The coverage type change is not just a setter. It changes the draft and marks a section dirty. Later it might clear dependent fields. Later it might add a warning. The action is where that transition belongs.
This is the main reason I like action-based state models. They create names for product events:
updateCoverageTyperemoveBeneficiaryrestoreDraftmarkSectionReviewedresetBuilder
Those names are easier to reason about than a trail of setState calls.
React Components Stay Small
The component should read state and dispatch intent.
// src/features/build-policy/ui/CoverageSection.tsx import { reatomComponent } from '@reatom/npm-react'; import { policyDraftAtom } from '../model/policy-builder.atoms'; import { updateCoverageType } from '../model/policy-builder.actions'; export const CoverageSection = reatomComponent(({ ctx }) => { const draft = ctx.spy(policyDraftAtom); return ( <section> <h2>Coverage</h2> <CoverageTypeSelect value={draft.coverageType} onChange={(coverageType) => updateCoverageType(ctx, coverageType)} /> </section> ); }, 'CoverageSection');
The component does not know how dirty state works. It does not know which dependent values reset. It says: the user changed coverage type.
The model handles the transition.
That separation is the payoff.
Where Reatom Meets TanStack Query
Query loads the server state. Reatom owns the local workflow.
When opening an existing policy builder, seed the draft from Query data:
export function PolicyBuilderRoute({ policyId }: { policyId: string }) { const policyQuery = useQuery(policyDetailQueryOptions(policyId)); if (policyQuery.isPending) return <PolicyBuilderSkeleton />; if (policyQuery.isError) return <PolicyBuilderError onRetry={() => policyQuery.refetch()} />; return <PolicyBuilderPage initialPolicy={policyQuery.data} />; }
Then initialize the feature model at the boundary:
export function PolicyBuilderPage({ initialPolicy }: { initialPolicy: PolicyDetail }) { const draft = mapPolicyToDraft(initialPolicy); return ( <PolicyBuilderScope initialDraft={draft}> <CoverageSection /> <BeneficiariesSection /> <ReviewPanel /> </PolicyBuilderScope> ); }
The important boundary: server data becomes an initial draft. After that, the draft is client state.
When the user saves:
export const savePolicyDraft = action(async (ctx) => { const draft = ctx.get(policyDraftAtom); await updatePolicyDraft(draft); dirtySectionsAtom(ctx, new Set()); }, 'savePolicyDraft');
Then invalidate Query data if needed:
queryClient.invalidateQueries({ queryKey: policyKeys.detail(policyId) });
Do not keep the Query cache and draft atom synchronized on every keystroke. That turns two clear models into one blurry model.
Effects Need Discipline
Autosave is a good example.
It is tempting to put autosave in a component:
useEffect(() => { const id = setTimeout(() => saveDraft(draft), 1000); return () => clearTimeout(id); }, [draft]);
That works until the component unmounts, remounts, gets split, or the draft changes from somewhere else.
For serious workflows, autosave should live near the state model. It reacts to the model, not to a particular component being mounted.
The exact Reatom effect API depends on setup, but the shape is what matters:
export const autosaveDraft = action(async (ctx) => { const draft = ctx.get(policyDraftAtom); const hasChanges = ctx.get(hasUnsavedChangesAtom); if (!hasChanges) return; await savePolicyDraftToServer(draft); dirtySectionsAtom(ctx, new Set()); }, 'autosaveDraft');
Then schedule or trigger it from the feature boundary, not from every field.
Effects should be named. Effects should be cancellable when needed. Effects should not hide in five components.
Undo and History
This is where graph-based state starts to feel worth it.
Undo is awkward when state is scattered across components. It is manageable when workflow state has a model.
export const draftHistoryAtom = atom<PolicyDraft[]>([], 'draftHistoryAtom'); export const commitDraftChange = action((ctx, nextDraft: PolicyDraft) => { const currentDraft = ctx.get(policyDraftAtom); const history = ctx.get(draftHistoryAtom); draftHistoryAtom(ctx, [...history, currentDraft]); policyDraftAtom(ctx, nextDraft); }, 'commitDraftChange'); export const undoDraftChange = action((ctx) => { const history = ctx.get(draftHistoryAtom); const previousDraft = history.at(-1); if (!previousDraft) return; draftHistoryAtom(ctx, history.slice(0, -1)); policyDraftAtom(ctx, previousDraft); }, 'undoDraftChange');
Would I add undo to every form? No.
But when the product needs it, I want the state model to make it boring.
What Not to Put in Reatom
Reatom is not a dumping ground.
Do not put this in Reatom by default:
- fetched server data that belongs in TanStack Query
- simple component booleans
- URL state that belongs in TanStack Router search params
- form field state already owned cleanly by TanStack Form
- one-off UI state that never leaves a component
The fact that a library can hold state does not mean it should hold all state.
My default split:
- TanStack Query: server state
- TanStack Router: URL/navigation state
- TanStack Form: form field state and validation flow
- Reatom: complex client workflows and reactive graphs
- useState: local component details
This split keeps each tool honest.
The Review Checklist
When I review client state, I ask:
- Is this state server-owned, URL-owned, form-owned, or client-owned?
- Can it stay local with
useState? - Is this derived value being stored manually?
- Are components changing atoms directly, or dispatching named actions?
- Does the feature model live near the feature?
- Are effects named and centralized, or hidden in component trees?
- Is Query cache being abused as a local store?
- Can a new developer see the state graph without opening ten components?
The last question matters most. State management is not about reducing lines. It is about making change propagation understandable.
What Comes Next
Reatom gives us a way to model complex client workflows. The next article goes one layer deeper: immutable data structures in React. Not as a purity lecture, but as the practical reason state graphs, memoization, optimistic updates, and undo histories stay predictable.
The policy builder eventually stopped feeling like a form and started feeling like a small application. That was the important realization. Treating it as "just component state" made every requirement feel like an exception. Treating it as a workflow model made the requirements fit somewhere.
Not because Reatom was magic.
Because the state graph finally had a shape.
If you're working on a React feature where every small change triggers three unrelated effects, reach out. The problem may not be your components. It may be that the workflow wants a model of its own.