You don't need a state store for your CRUD app
Most teams default to a state store before they’ve drawn a single component. It’s a reflex: complex frontend, therefore store. You tell yourself you’re building something cleaner. What you’re building is a cache pretending to be the truth, when the backend already has it. I used to do the same.
For CRUD flows (multi-step forms, listing creation, anything where each step saves to a server before the next opens), the better bet is to keep components dumb and state short-lived. You’re not winning by being better at state management. You’re winning by having less state to manage.
The flawless store is always theoretical
Every team that tries one ships the 90% correct version. The first iteration handles the common path. The remaining 10% (cache invalidation, store fields drifting from what the server actually returns) lives forever as a bug backlog.
Take a listing creation wizard for a car marketplace. The seller works through 6 steps:
- Step 1: enter the vehicle registration number, which fills in make, model, year, and basic spec and creates a draft listing
- Step 2: confirm make, model, pricing
- Step 3: vehicle details (transmission, engine size, mileage)
- Step 4: photos
- Step 5: seller contact details
- Step 6: summary and publish
Each step saves to the backend as the seller advances. Run that wizard with a centralised store and watch what breaks:
- The seller raises the asking price and clicks Next. The save fails on the server but the error gets swallowed in the response pipeline, so the store updates anyway. The summary step shows the new price, and the seller publishes a listing where the backend still has the old one.
- The seller publishes a listing with “price on application” ticked, then starts a second listing. The store still holds the first listing’s state, the toggle is still on, and the price field stays hidden. The seller has to spot the leftover toggle before they can enter a price at all.
- Refresh on step 3 and the progress bar resets to step 1, even though the data is safely on the server. The wizard tracks what’s done in the store, not against the server, and the store starts empty on every fresh load.
- Editing an existing listing uses a different entry point from creating a new one, with its own version of every bug above.
These look like 4 separate bugs. They’re one decision. Nobody agreed where step state lives, so each step invented its own answer, and the answers don’t compose.
The interesting move isn’t fixing the store. It’s noticing that most of the state shouldn’t exist on the frontend at all.
Less state beats better state management
If state doesn’t live long, it can’t go stale. If it isn’t shared across components, it can’t go out of sync with itself. The problem class shrinks, not just the bug count.
Rewrite the listing wizard around a single rule: each step mounts, fetches its own slice from the backend, lets the seller edit in local component state, submits on Next, and discards everything on unmount. The next visit fetches fresh. The wizard itself follows the same rule one tier up: its own state (the listing name in the header, the save indicator) lives in the wizard component, not in any one step.
No form silently carrying the previous listing’s data when the seller starts a new one back-to-back. No leftover “price on application” toggle from the previous listing hiding the price field on the next one. No “why did my pricing edits come back when I clicked Back?” moments. No half-saved listings from clicking Next twice while a previous save is still in flight. The step doesn’t care how the seller got there. Mount, fetch, render. That’s the whole contract.
This works because the frontend was never the source of truth to begin with.
The backend is already the source of truth
Every store is a cache pretending to be the truth. Short-lived state stops pretending. The moment a seller clicks Next on the pricing step, the server has the canonical version. Holding a second copy in a frontend store is optional, not necessary, and every optional copy is a future invalidation bug.
This design assumes the backend is shaped for it. Small per-step endpoints (GET /listings/:id/pricing, GET /listings/:id/photos) that return just the slice the step needs, fast and cheap to call. The frontend isn’t fighting the backend, it’s working with it.
Deep links work for free. A new starter on the dealership team pastes /listings/42/photos into a fresh browser. The
photos step mounts, fetches, renders. No prefill dance, no “did the wizard get hydrated first?” race. The URL is the
only argument the step needs.
The rule underneath all of this is a simple one about lifetime.
State lifetime should match state home
State has a natural lifetime, the window during which it’s actually used. Most architectures put state somewhere with a longer lifetime than it needs, “just in case” something else later might want to read it.
graph LR
A["State that lives one step visit"]
A -->|right home| B["Component state"]
A -->|lifted too high| C["Session-scoped store"]
C --> D["Cache invalidation, drift, stale reads"]
Most bugs in state-heavy apps come from lifting state one tier higher than its actual lifetime. A computed value that’s only needed inside one function ends up on a service “in case another component needs it”. A form that lives for one step’s worth of editing ends up in a store that survives navigation, route changes, and accidental hydration of the wrong tab.
That “just in case” becomes real coupling. Real coupling becomes real invalidation complexity. Real invalidation complexity becomes real bugs. And every bug fix in that direction makes the store more elaborate, not the system simpler.
The listing wizard’s bugs all trace to that mismatch: state parked at the session tier (a singleton service spanning the whole wizard) when its real lifetime is interaction-scoped (one step visit). Every fix to the store is a fix to a problem the store invented.
This doesn’t mean stores are always wrong. It means they’re often misapplied.
Where a real store still wins
There are kinds of feature where a store is the right answer because the state legitimately has session-scope lifetime or longer:
- Live shared state. Collaborative document editing, a real-time bid board on an auction listing, a search page where a map and a result list share what’s hovered and selected as the user pans. Multiple users or components need the same state visible at once.
- Optimistic updates. When a buyer clicks Reserve on a listing, the button needs to flip to “Reserved” instantly, not wait 800ms for the server to confirm. The frontend has to hold a “pretending it succeeded” state and roll it back if the server rejects.
- Offline or buffered editing. If a private seller can fill in a listing on a flaky train, something has to hold pending edits until they’re submittable.
- High cross-feature read volume. If 20 components all need the seller’s unread-message count, fetching it once and caching it beats 20 round trips.
- Undo and redo. Needs retained history.
None of these describe a listing wizard where each step PATCHes its slice before the next one opens. They describe Figma, an auction house’s live bid display, an offline-first mobile app, a shared messaging tray. If your feature isn’t one of those, a store is almost always more state than you need.
The takeaway isn’t anti-store. It’s honesty about lifetime.
The useful question
The useful question isn’t “store or no store”. It’s “what is the actual lifetime of this piece of state?” Match the lifetime to the home and most of the invalidation problem disappears before you have to solve it.
For a listing wizard, the lifetime is one step visit. Let it live there. Let it die when the step unmounts. Let the backend be the truth it already is, and let the frontend stop competing for that title.
The bugs you’re trying to fix with a better store are usually the bugs your store is generating.