I fixed login being lost on refresh with Zustand persist
Admin login was lost on every refresh, and Zustand persist fixed it. What to store, what not to, and the problem in my current code where the token sits in localStorage too.
#Frontend #zustand #State management #React
I was logged in as admin, refreshed the page, and the login was simply gone. Back to the login screen. When I moved to React I used Zustand for global state, and I liked it because it's light and has almost no boilerplate, but this part tripped me up early on. Refresh, and all the state is wiped. In this post I go over how I solved it with the persist middleware , and what I ran into after persisting anything and everything because it was convenient. Why does it get wiped? It's to be expected, really. Whether it's Zustand or Redux, global state is in the end a JavaScript variable in memory. On a refresh the whole page loads again and that variable goes back to its initial value. So the state disappears with it. Loading state or one-off state, for example whether a modal is open, can disappear without any harm. If anything, it would be odd to refresh and find the modal still up. The problem was state that has to survive a refresh, like login info. That has to be written down somewhere outside memory and read back in. One layer of persist middleware is enough Zustand has a middleware called persist. Wrap a store in it, and it automatically saves to localStorage every time the state changes and automatically restores from there when the page loads. I don't have to touch localStorage myself. // store/data.ts import { create } from "zustand"; import { persist } from "zustand/middleware"; const useAdminStore = create AdminState ()( persist( (set) = ({ admin: '', setAdmin: (newAdmin) = set({ admin: newAdmin }), resetAdmin: () = set({ admin: '' }), }), { name: 'admin-storage', // the key name it's stored under in localStorage } ) ); The only differences from ordinary store code are the one wrap in persist(...) and the name. But with just that, the admin state is saved automatically under the admin-storage key in localStorage and restored after a refresh. Calling setAdmin takes care of the saving too. If you open the Application tab in the developer tools, you'll find a value like this. admin-storage: {"state":{"admin":"..."},"version":0} The version goes in alongside for the migrate option, which converts old data when the shape of the state changes later. I haven't used it yet, but if I ever need to change the storage format, I think I'll be glad it's there. You shouldn't persist everything At first I got carried away and nearly put persist on every store, but this needs some thought. Staying in localStorage means it's still there after the browser is closed and reopened. So you have to separate "things that need to be kept" from "things that always need to be fresh". Login and admin state need to be kept, so I put persist on them. On the other hand, the data for the post currently being viewed and short-lived state have to be fetched fresh from the server each time, so I left persist off. Here's what actually happened when I didn't separate them. I persisted the post list, and then after I published a new post, the old list was restored from localStorage and the new post didn't show. It was definitely on the server but not on the screen, so I was lost for quite a while. That's when I learned that stored state can be older than what's on the server. If I open my localStorage right now Because persist was convenient, I put the login info in wholesale. This is what the store for this blog's GitHub comment login looks like right now. // store/data.ts const useGithubUserStore = create GithubUserState ()( persist( (set) = ({ githubUser: null, githubToken: null, setGithub: (user, token) = set({ githubUser: user, githubToken: token }), resetGithub: () = set({ githubUser: null, githubToken: null }), }), { name: 'github-user-storage', } ) ); I didn't use the partialize option, which picks which fields get stored, so even githubToken goes into localStorage as is. Open the Application tab in the developer tools and it's visible in plain text. localStorage can be read by any script running on the same domain. This is a site with ad scripts on it, so I should have been more careful, but I went with the convenient option first. What I can do right away is use partialize to leave the token out and keep only the user info. { name: 'github-user-storage', partialize: (state) = ({ githubUser: state.githubUser }), // the token is not stored } To do it properly, the token has to move to an httpOnly cookie. That means setting the cookie from the server and reworking the CORS settings along with it, so I've set it aside as a task to handle all in one go. Until then, just keeping the token expiry short already cuts the risk quite a bit. As convenient as persist is, it makes you store everything without thinking. When you add it, I'd recommend asking both "does this need to stay after the browser is closed" and "is this information that's okay to leave lying around". If the answer to either is no, the right move is to drop persist or filter with partialize.