Hello and welcome to another situation where things were confusing me at first, but slowly and surely the solution to a problem arrives if you stick with it and truly care about your craft. On today's agenda, what happens when your backend presents you with the truth, but that truth is not what the user wants or needs to see. In case you're not in the mood to read my last entry, the project was a React Native app for managing a smart watch for health and safety purposes. The watch is meant to help caregivers keep track of someone’s well-being through location tracking, safe zones, fall alerts, SOS contacts, reminders, alarms, and remote device settings.

One of those remote settings was the GPS tracking frequency. In simple terms, the caregiver could choose how often the watch should report its location. Maybe every 10 minutes, maybe every hour or even not at all if the GPS was disabled altogether. At first glance, this sounds like another boring CRUD feature. User selects a value, the app sends it to the server, the server saves it and sends the response back, the app re-fetches the new data and the UI updates. Beautiful, civilized, but, nonetheless, nothing new under the Sun. Just the kind of thing our industry is built on where people move JSON data from one place to another and someone gives you a paycheck for it.

Unfortunately, or fortunately, depending on how you want to sit, the watch is not only a new layer besides the usual frontend-backend combo, but it's also a real physical device. That means, as my team and I soon found out, that changing a setting (and there are many settings to change) is not always instant. In fact, most of the time it wasn't. The thing is, even if the backend accepts a request instantly, there's nothing it can do until it itself receives a response from the watch, which can take anywhere from instantly to a couple of minutes. This limitation is further exacerbated by other factors, such as the signal strength of the watch, its location, the thickness of the walls inside the watch wearer's watch, and so on.

To put it simply, the server might still return the old value. For example:

mermaidjs app-flow graph

Notice how the backend says "command accepted" and not "understood, modification made, here's the new data". From the user's perspective, this looks like the app ignored them. They chose the 10 minutes option, the app briefly looked alright and then it went back to "1 hour" seemingly by itself. Very inspiring stuff. And, like I stated in the first paragraph, the server is not even wrong. It just reports back the last confirmed state of the device. The new command might still be pending or queued. So the main idea is that the user's intent, the backend state and the confirmed command can and will temporarily be 3 different things.

The solution? To put it shortly, TanStack Query. Now, a small lessons for anyone who's not that familiar with this library. TanStack Query (formerly known as React Query) is a server-state manager. Normally, React state is for local UI things.

const [isOpen, setIsOpen] = useState(false);

But TanStack Query is used for data that comes from the backend:

const { data } = useQuery({
	queryKey:["device", deviceId, "tracking-frequency"],
	queryFn: fetchTrackingFrequency
});

Looks simple enough. Of course, besides the data you also get acces to error and loading states and a whole bunch of other very useful stuff, but they're not important for the point I am trying to make. What is important, however, is the queryKey. You can think of it as the address where TanStack Query stores that piece of backend data in its cache. So, if another component asks for the same key, TanStack Query will reuse the cached data instead of blindly re-fetching everything.

Normally, after updating something, you would invalidate or re-fetch he query and show whatever the server returns. A solid default for normal app data. If I update my profile name from "Alex" to "Alexandru" and the server still returns "Alex", then probably something has failed. But this watch setting (and not only it, most of them) was different. The backend could accept the command while the actual device did not yet receive the command. In this case, always trusting the immediate refetch will just make a mess of the UI.

Ok then, what is plan B, if trusting the server fails immediately? Well, at that time, the next obvious step was to not re-fetch immediately. However, this was a short-lived idea because now I have a different problem. The UI can become disconnected from reality entirely, any reality, be it device or server reality. What if the command fails? What if the watch never receives it? There are more failure points than before. Sure, it made the app feel responsive as it should, but if it's faulty, what's the point?

Moving on, plan C, or step 3: optimistic updates. TanStack Query allows you to use a pattern that helps you implement optimistic updates.

queryClient.setQueryData(
	["device", deviceId, "tracking-frequency"],
	newFrequency
);

This makes the UI update instantly. Nice. Buuuut, I still had the refetch problem. If I were to invalidate/refetch and the server returns the old value (which it did quite often), the main cache gets overwritten again. Which means that optimistic updates, while being the good direction to take, are not enough. A bunch of time has already passed and I couldn't really think of a more proper solution, but at some point during one of my breaks it clicked. I need to track 2 different concepts. I had a most-elusive "aha" moment. Confirmed server value is not pending user intent and the app needs to know both. If I store only one value I'd keep fighting myself. The choice was between trusting the server and negatively impacting the UX or lie to the user and hope for the best. So, I just kept a second cache entry. The normal query key stores the confirmed tracking frequency: ["device", deviceId, "tracking-frequency"]. The pending query key stores the temporary value that represents the user's action: ["device", deviceId, "tracking-frequency-pending"].

At this point, when the user changes the tracking frequency, the app does two things instead of one. It updates the normal tracking frequency cache so that the UI responds instantly.

queryClient.setQueryData(
  [...DEVICE_QUERY_KEY, deviceId, "tracking-frequency"],
  newFrequency
);

And then it stores the pending value separately, alongside a timestamp.

queryClient.setQueryData(pendingKey, {
  value: newFrequency,
  ts: Date.now(),
});

In simple terms, it shows the new value now, but remembers that this value is still pending. Schrodinger's value. Then, when the app receives the server value again, the UI value is derived by comparing the confirmed server value with the pending value using the select function. Select is Tanstack's Query way of transforming data before your component receives it.

select: (serverValue: number) => {
  const pending = queryClient.getQueryData<{ value: number, ts: number } | undefined>(pendingKey);
  const pendingMaximumAge = 1000 * 60 * 30;

  if (pending !== undefined && pending !== null) {
    const age = Date.now() - pending.ts;

    if (age > pendingMaximumAge) {
      queryClient.setQueryData(pendingKey, undefined);
      return serverValue;
    }

    if (serverValue === pending.value) {
      queryClient.setQueryData(pendingKey, undefined);
      return serverValue;
    }

    return pending.value;
  }

  return serverValue;
}

This is a lot, so let's unpack it, because at this moment it looks like ancient spellcasting, but bear with me, it's simpler than it looks. First, I ask "is there a pending value?" If the answer is no, just return the serverValue. If there is, the code checks whether it is too old:

if (age > pendingMaximumAge) {
  queryClient.setQueryData(pendingKey, undefined);
  return serverValue;
}

This prevents the app from showing the "wrong" value forever. If 30 minutes pass and the server still has not confirmed the new value, the app gives up and goes back to the server state. A temporary optimistic state is perfectly fine, but a permanent hallucination is not unless you are building the next generation of LLMs. Next, it checks if the server has caught up.

if (serverValue === pending.value) {
  queryClient.setQueryData(pendingKey, undefined);
  return serverValue;
}

Finally, if the pending value is still recent and the server still returns the old value, the app keeps showing the pending one. Now, could this be improved? Absolutely. For example, a more polished version could also show a small "syncing" or "pending" indicator in the UI. This would make things more explicit instead of hiding the ugliness under the rug. The cache logic could also probably be extracted into a reusable hook. Did I do it? Absolutely, not. I can't remember why, either I didn't deem it important at that time and forgot about it, maybe lack of time (the task was kinda past its deadline at this point), or another reason. It's just the reality of developing something for a client instead of for yourself.

This article was all over the place, so I truly thank you if you stuck with it until the end. It was about how problem solving happens in real time and the immense satisfaction one can get from this process instead of prompting. The main lesson was simple: when server state, device state, and user intent temporarily disagree, the frontend should not blindly trust whichever one responds first. It should model the disagreement explicitly. That is where frontend work becomes more than moving JSON around. Sometimes the job is not just to show data, but to represent uncertainty in a way that still feels stable to the user.

Verified Agency by DesignRush badge
Top Clutch Companies Romania 2022 badge
Tip NodeJS Developers Timisoara 2023 badge
Top IT Services Companies Education Romania 2023 badge
Top Software Developer Timisoara 2023 badge