Deriving Client State from Server State
โ ReactJs, React Query, TypeScript โ 3 min read

- 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):
1const useSelectedUser = () => {2 const { data: users } = useQuery({3 queryKey: ['users'],4 queryFn: fetchUsers,5 })6 const { selectedUserId, setSelectedUserId } = useUserStore()7
8 // If the selected user gets deleted from the server,9 // Zustand won't automatically clear selectedUserId10 // You have to manually handle this:11 useEffect(() => {12 if (!users?.some((u) => u.id === selectedUserId)) {13 setSelectedUserId(null) // Manual sync required14 }15 }, [users, selectedUserId])16
17 return [selectedUserId, selectedUserId]18}
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.
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 tonull
.
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.
1const useSelectedUser = () => {2 const { data: users } = useQuery({3 queryKey: ['users'],4 queryFn: fetchUsers,5 })6 const { selectedUserId, setSelectedUserId } = useUserStore()7
8 const selectedId = users?.some((u) => u.id === selectedUserId)9 ? selectedUserId10 : null11
12 return [selectedId, setSelectedUserId]13}
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:
1const useSelectedUser = () => {2 const { data: users } = useQuery({3 queryKey: ['users'],4 queryFn: fetchUsers,5 })6 const { selectedUserId, setSelectedUserId } = useUserStore()7 const isSelectionValid = users?.some((u) => u.id === selectedUserId)8
9 return [selectedUserId, setSelectedUserId, isSelectionValid]10}
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 se 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:
1function UserSelection() {2 const { data: users } = useQuery({3 queryKey: ['users'],4 queryFn: fetchUsers,5 })6 const [selection, setSelection] = useState()7
8 // use the first value as default selection9 useEffect(() => {10 if (data?.[0]) {11 setSelection(data[0])12 }13 }, [data])14
15 // return markup16}
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:
1function UserSelection() {2 const { data: users } = useQuery({3 queryKey: ['users'],4 queryFn: fetchUsers,5 })6 const [selection, setSelection] = useState()7
8 const derivedSelection = selection ?? data?.[0]9
10 // return markup11}
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. โฌ๏ธ
