Skip to content
TkDodo's blog
BlueskyGithub

Deriving Client State from Server State

โ€” ReactJs, React Query, TypeScript โ€” 3 min read

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):

manual-sync
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 selectedUserId
10 // You have to manually handle this:
11 useEffect(() => {
12 if (!users?.some((u) => u.id === selectedUserId)) {
13 setSelectedUserId(null) // Manual sync required
14 }
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 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.

derived-selection
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 ? selectedUserId
10 : null
11
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:

isSelectionValid
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:

default-value-effect
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 selection
9 useEffect(() => {
10 if (data?.[0]) {
11 setSelection(data[0])
12 }
13 }, [data])
14
15 // return markup
16}

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:

derived-default-value
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 markup
11}

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. โฌ‡๏ธ

ยฉ 2025 by TkDodo's blog. All rights reserved.
Theme by LekoArts