One of my projects uses Zustand and another uses Redux. Why did the same person pick differently?
How I chose between Redux Toolkit, Zustand and React Query differently depending on the kind of project.
#Redux #zustand #React Query #State management #React
I opened the store folders of the React projects running on my server one by one. State management is different in every project. K-Geul (education service) -> React Query + Zustand Crypto (real-time prices) -> React Query + Redux Toolkit Blog -> React Query + Zustand Portfolio -> nothing (useState) The same person built all of them, so why are they different? I used to think "I guess I just used whatever was at hand each time" myself, but looking again, there were reasons. In this post I'll write down what those criteria were. The first thing to sort out isn't the library, it's the kind of state Before picking a state management library, I have to sort out what kind of state I'm dealing with first. Without that split, everything gets shoved into a single Redux store. That's what I used to do. Server state The server holds the original and my screen is a copy e.g. post list, user info, coin list Traits: it can go stale. It has to be fetched again. It has loading/error. Client state Born and dies inside the browser e.g. modal open, dark mode, login token, selected filter Traits: the server doesn't know about it. There's no concept of loading. What these two need is completely different. Server state needs caching, refetching, invalidation and request deduplication , while client state just needs values put in and taken out . But if you put server state in Redux, you have to write all the caching and refetching by hand. Anyone who has made the loading, error, data trio for createAsyncThunk every single time knows. That code gets copied identically into every slice . So my default became this. Server state goes to React Query, and I choose a tool only for the client state that's left. Server state -> React Query (TanStack Query) Client state -> Zustand or useState Why I didn't use Redux in K-Geul When I was tearing out the legacy code and rewriting it in React, I went back and forth on whether to use Redux. I didn't. The reason is that the service only had a few pieces of state that truly had to be global . What needed to be global in K-Geul - logged-in user info - selected language (the value set during onboarding) - whether a lesson is in progress That was it. Everything else either stayed inside one screen or was server data. Install Redux for these three and store, slice, action, reducer, provider, selector, dispatch all come along with it. The team has to know all those rules, and every time you add one piece of state you have to touch several files. Considering the headcount and schedule at the time, that cost outweighed what we'd gain. With Zustand, this is all there is. export const useAuthStore = create((set) = ({ user: null, setUser: (user) = set({ user }), logout: () = set({ user: null }), })); // when using it const user = useAuthStore((s) = s.user); There's no Provider and it's a single file. And if you scope the subscription with a selector, it re-renders only when that value changes . For handling a few small globals, this was exactly right. But in the crypto project I used Redux Toolkit This is where it flipped. The kimchi premium service, which tracks the price gap on Korean exchanges, uses Redux Toolkit . I didn't start with Zustand and move over, I chose it that way from the beginning. The reason is that the shape of the state was completely different. Put the global state of the two projects side by side and you see it right away. K-Geul global state Crypto global state - logged-in user - KRW prices for 200 coins - selected language - USD prices for 200 coins - lesson in progress - exchange rate - paper trading holdings - price alert settings A few values A big object updated hundreds of times per second On the crypto side, the state is big, changes often, and has several update paths . It comes in over WebSocket, it comes in through the initial REST load, and it changes through user actions too. With something like this, debugging turns into hell if you can't trace "who changed this value and when" . Watching actions pile up in time order in Redux DevTools was a real help here. And having several update paths also means it pays to write the reducers out separately and explicitly. syncKRWPrice: replace everything (initial load) syncKRWPrice2: replace a single coin (early implementation, not used now) syncKRWPriceBatch: replace several coins at once (rAF batch) initBinanceSuccess: replace the whole thing when switching the source to Binance That's four paths touching the same coins object. Nothing here is impossible with Zustand, but with this many paths, having action names attached is kinder to me six months from now . For reference, React Query handles server state here too. Redux holds only what comes in as a real-time stream . Things that are done once they're fetched over REST, like the news list and coin metadata, are React Query. So what are the criteria? Summed up, this is how it split. Zustand is better Redux Toolkit is better Global state count few (around 5) many Update frequency as often as user actions tens to hundreds of times per second Update paths per state one or two several Debugging console is enough needs a timeline Team size small big or changes often Boilerplate almost none some, but the rules are clear There are also cases where I use neither. This portfolio site right now has no state management library at all . The theme (dark mode) and the open modal are about all there is, and useState and the URL were enough for that. The project modal isn't held as state at all, it's derived from the /projects/:id route . // whether the modal is open isn't held in state. The URL is the state. const selectedProject = section === 'projects' Number.isInteger(projectIndex) ? projects[projectIndex] ?? null : null; This way the back button closes the modal, and copying the link shares it with that project open. If I had made it global state, I would have had to write both of those myself . One regret When I brought React Query into K-Geul, I regret not deciding on a queryKey convention first . At first everyone put in whatever strings they found convenient, and later, when I went to set up invalidation, it was hard to see in one place which key was used where. // it started like this useQuery(['words', level], ...) useQuery(['wordList', level], ...) // the same thing under a different name // keeping the keys in one place would have been better export const queryKeys = { words: (level) = ['words', level], wordDetail: (id) = ['words', 'detail', id], }; The same kind of problem is still there in the crypto project today. Open the store folder and the slice has these sitting together. syncKRWPrice replace everything syncKRWPrice2 replace a single coin - nobody calls this now syncKRWPriceBatch several coins at once syncKRWPrice2 stopped being used when I switched to batch processing. Checking again now as I revise this post, coinKrwPriceSlice.ts still has only the definition and the export, and nothing calls it anywhere. Deleting it means going through every reference, so I left it to be removed when I clean up the slice, and I still haven't done that cleanup. It has ended up as a codebase with a function that has a 2 stuck on the end of its name . Zustand or Redux is something I can change later. But things that pile up like this don't get deleted easily.