Our services
If you can think it, we can make it brainsoft.
If you can think it, we can make it brainsoft.
Written By: BrainSoft In Frontend
Most medium-sized SPAs do not have a state management problem at first. They have a prop-drilling problem, a caching problem, and a server-state problem, and all three get lumped under the same label. I have walked into codebases where a single Redux store held the auth token, the open modal name, the fetched invoice list, and the current form draft, and nobody could tell which parts were safe to delete.
The short answer: keep local UI state in the component, put genuinely shared client state in one small store, and leave server data to a query library. Below is how I decide which bucket a piece of state belongs in, plus the folder layout and rules I use so the store does not become a junk drawer.
Before adding anything to a store, I ask one question: who owns the truth? If the server owns it, the client is holding a cache, not state. That distinction kills most of the arguments. A list of orders, a user profile, a feature flag response, all of those are server-owned. They need loading states, retries, and invalidation, which is exactly what a data-fetching library gives you. A store that reimplements that gets stale fast.
The buckets I actually use:
useState. Never leaves.That last one gets skipped constantly. A search page that keeps its query in a store means the back button does nothing and the link a support agent pastes into a ticket opens an empty page. Putting filters in the query string is less code than syncing them by hand.
For a medium app, roughly a dozen screens and a handful of developers, I reach for context plus a reducer for shared client state, and a query library for everything fetched. I do not start with a global store library unless there is a real reason: time-travel debugging on a complex editor, or a team already fluent in it. Adding one on day one is a bet you cannot easily walk back.
The trap with context is putting a value that changes often at the top of the tree. Every consumer re-renders on every keystroke. Split it: one provider for the auth session that changes twice a day, another for the wizard draft, and keep the fast-changing stuff local. If you are already feeling render pressure, a small store with selector subscriptions is the next step, and it is a contained migration.
const SessionContext = createContext(null);
function SessionProvider({ children }) {
const [session, dispatch] = useReducer(sessionReducer, null);
const value = useMemo(() => ({ session, dispatch }), [session]);
return (
<SessionContext.Provider value={value}>
{children}
</SessionContext.Provider>
);
}
function useSession() {
const ctx = useContext(SessionContext);
if (!ctx) throw new Error('useSession outside provider');
return ctx;
}
The useMemo matters. Without it the provider object is new on every render and every consumer wakes up for nothing. The throwing hook matters too, because a silent null turns into a confusing crash three components away.
For server state, the shape is boring on purpose. One query key per resource plus its parameters, one mutation that invalidates the keys it touches. If you want this wired into an existing codebase rather than rebuilt from scratch, that is the kind of work our services cover.
A store does not go bad because of the library. It goes bad because there is no rule about what may enter. Four rules have saved me more time than any refactor:
setData is a smell. Name the thing that happened: wizardStepCompleted.I also keep the store in one folder with one file per slice, and I keep the selectors next to the slice. When a new developer asks where a value comes from, the answer is a file path, not a search across the app.
You do not need to rewrite anything because a blog post said so. Migrate when you can point at a symptom: a component tree that re-renders on unrelated updates, a bug that only appears after navigating away and back, or two features writing the same field with different meanings. Those are real, and they usually trace back to state in the wrong bucket.
If none of that is happening, leave the code alone. A slightly messy store that everyone understands beats a clean architecture nobody has time to finish. If you want a second opinion on where your app sits, get in touch and we can look at the actual code.
Usually not at the start. Context plus a reducer handles shared client state fine for a dozen screens. Reach for a store library when you need selector-level subscriptions or devtools, and migrate one slice at a time rather than all at once.
In a query cache, not in your global store. Server data needs loading states, retries, and invalidation keyed by request parameters. A data-fetching library already does that, and copying responses into a store means you maintain two sources of truth.
The URL. If a user should be able to reload, bookmark, or share the current view, the state belongs in the query string. Keeping it in memory breaks the back button and makes pasted links open the wrong page.