Deriving Client State from Server State

red and blue lights from tower steel wool photography

No translations available.- Add translation

Just as I came back from vacation, I saw this reddit question about the biggest trade-off when it comes to using zustand. The code looked something like this (I altered it slightly for updated syntax and packed it into a custom hook):

Copymanual-sync: copy code to clipboard 1constuseSelectedUser=()=>{ 2const{ data: users }=useQuery({ 6const{ selectedUserId, setSelectedUserId }=useUserStore() 8// If the selected user gets deleted from the server, 9// Zustand won't automatically clear selectedUserId 10// You have to manually handle this: 12if(!users?.some((u)=> u.id=== selectedUserId)){ 13setSelectedUserId(null)// Manual sync required 15},[users, selectedUserId]) 17return[selectedUserId, selectedUserId]

Of course, whenever I see a useEffect, especially one that calls setSate inside it, I want to find a better solution. In my experience, there is almost always one, and it's usually worth pursuing it. So let's take a step back and try to find out what we want to achieve first.

Keeping State in Sync

In a nutshell, we want to keep our Client State - the selectedUserId, in sync with our Server State - the list of users. This makes sense: If a background refetch comes in from useQuery, and the user was deleted from our list while we still have it stored in state, that selection becomes invalid.

Note that this approach is not specific to zustand at all. We could just as easily replace useUserStore() with a local state:

const [selectedUserId, setSelectedUserId] = useState()

or with URL state like nuqs:

const [selectedUserId, setSelectedUserId] = useQueryState('userId')

It doesn't matter where that state is stored - what matters is that it's Client State that we want to "update" when Server State changes.

Since Queries don't have an onSuccess callback, and the trick to call setState during render only works with React's built-in state, it seems that the only other available option is the dreaded useEffect

After all - how else should we update the user selection?

Don't Sync State - Derive It

Remember this article by Kent C. Dodds where he takes a complex set of four different useStates and reduces them to just one by deriving the rest from the single source of truth ?

It turns out we can do something similar in our situation. The useEffect solution is a pretty imperative way of thinking:

IF the users change AND our selection is invalid, THEN re-set the selection to null.

But can't we change that thinking to be a bit more declarative:

Here is the users from the backend and the current selection, please give me the real state.

Copyderived-selection: copy code to clipboard 1constuseSelectedUser=()=>{ 2const{ data: users }=useQuery({ 6const{ selectedUserId, setSelectedUserId }=useUserStore() 8const selectedId = users?.some((u)=> u.id=== selectedUserId) 12return[selectedId, setSelectedUserId]

This code is dead simple. Instead of updating the store value, we keep the selection as it is, but just return something different from our custom hook if the id cannot be found in the Server State anymore. In places where we call useSelectedUser(), we'll get back null just like before.

And since we don't touch the store, we also get some additional benefits with this:

  • If the user gets re-added to the list of users, our selection will automatically be restored too.

  • Maybe our UX changes and we don't want to remove the selection, but we just want to visually indicate that the selection is invalid instead. That's easily doable now because we always retain the original value:

CopyisSelectionValid: copy code to clipboard 1constuseSelectedUser=()=>{ 2const{ data: users }=useQuery({ 6const{ selectedUserId, setSelectedUserId }=useUserStore() 7const isSelectionValid = users?.some((u)=> u.id=== selectedUserId) 9return[selectedUserId, setSelectedUserId, isSelectionValid]

Where's the catch?

One obvious drawback to the deriving state solution is that you can't "trust" what is stored inside the user store anymore. If you read the selectedUserId from useUserStore somewhere else, you don't get the additional check, so you always have to read it from your custom hook.

I genuinely don't mind this, since I see the store more as a record of what was actually selected in the UI, rather than a source of the final, validated values.

And since the reddit question also mentions that redux toolkit "solves this" - I don't think it would work any different there. You would likely write a selector that reads from the API slice and the slice that contains the user selection and combine the two, which is exactly what our custom hook does, too. If anything, it nudges you towards deriving state a bit more, which is great. 🎉

A different Example

The concept of not updating Client State when Server State changes can be useful in many cases. A common example is when prefilling forms with default values from the server:

Copydefault-value-effect: copy code to clipboard 1functionUserSelection(){ 2const{ data: users }=useQuery({ 6const[selection, setSelection]=useState() 8// use the first value as default selection

This effect is not only verbose, it also has a bug 🐛 where it overwrites the current selection when new data comes in from the Query. This is easily fixable by adding another check, but the better solution would still be deriving state:

Copyderived-default-value: copy code to clipboard 1functionUserSelection(){ 2const{ data: users }=useQuery({ 6const[selection, setSelection]=useState() 8const derivedSelection = selection ?? data?.[0]

All we need to do now is continue to work with derivedSelection instead of selection and we'll always get the value we want. 🚀

That's it for today. Feel free to reach out to me on bluesky if you have any questions, or just leave a comment below. ⬇️

Like the monospace font in the code blocks?

[

![](data:image/svg+xml;charset=utf-8,%3Csvg height='256' width='4096' xmlns='http://www.w3.org/2000/svg' version='1.1'%3E%3C/svg%3E)

Bytes - the JavaScript Newsletter that doesn't suck

](https://bytes.dev/?r=dom)

inicio - Wiki
Copyright © 2011-2025 iteam. Current version is 2.146.0. UTC+08:00, 2025-09-11 20:29
浙ICP备14020137号-1 $mapa de visitantes$