
The F5 that logged the walker out
The ghost had a name
The previous post ended with the status button actually working: clicks changed the state, the dot responded, the color followed. Mission accomplished — until someone pressed F5.
After a page reload, the walker who had just gone “Online” was back offline for the whole system. The state lived only in the hook’s useState. The server — the one that actually decides whether the walker shows up as available to receive requests — still held the old value. The button had stopped lying within the session, but it lied across sessions.
// Before: local state only, dies on refresh
onToggleStatus={() =>
setWalkerStatus(walkerStatus === 'online' ? 'offline' : 'online')
}
This is a subtle bug because nobody complains right away. The walker clicks, sees a green button, closes the app happy. The system never finds out. Meanwhile, the pet owner browses available walkers — and the best walker in the neighborhood is invisible because they clicked a button that only existed for them.
Local state vs. server truth
The rule that guided the fix: the source of truth is the server; the frontend is an optimistic projection. If the online status decides who gets requests, it cannot be a UI detail — it has to be profile data.
The backend side was deliberately small. PATCH /profiles/me already accepted profile updates; what was missing was the contract returning the field the frontend needs to read back:
# backend/app/routers/profiles.py — PATCH response
return {
"service_radius": profile.service_radius,
"vehicle_type": profile.vehicle_type,
"available": profile.available,
"is_online": profile.is_online, # new: frontend must read it back
"message": "Perfil atualizado com sucesso",
}
Without is_online in the response, the frontend persists in the dark: the PATCH may or may not have worked, and the UI has no way to sync with what the server actually stored. One line in the contract beats three layers of client-side retry.
In the hook: optimistic projection with truth coming back
Two changes in useWalkerDashboard. First, initialization: when the profile loads, the status is born from what the server knows — not from a hardcoded default:
const data = await withRetry(() => api.get('/profiles/me'));
setProfile(data || { name: user.email?.split('@')[0] || 'Passeador' });
if (data?.is_online != null) {
setWalkerStatus(data.is_online ? 'online' : 'offline');
}
Second, the toggle: update the UI first (responsiveness), persist afterwards, and let the server confirm in the response:
const toggleStatus = useCallback(async () => {
const next = walkerStatus === 'online' ? 'offline' : 'online';
setWalkerStatus(next);
try {
await api.patch('/profiles/me', { is_online: next === 'online' });
} catch (err) {
if (import.meta.env.DEV) console.error('[WalkerDashboard] falha ao persistir status:', err);
}
}, [walkerStatus]);
And WalkerDashboard stopped manufacturing its own handler: onToggleStatus={toggleStatus} — the same function the hook exposes, which the tests can exercise in isolation. The era of anonymous, untestable callbacks is over.
The 4 tests that lock the door
The ghost button post showed how an empty handler slipped through. The answer this time was not trusting the eye — it was 4 tests in useWalkerDashboard covering both directions of each path:
The scenarios are deliberately boring: initialize online, initialize offline, persist there, persist back. Boring is good — each one is a bug that already happened or was about to. A boot reading is_online: false and overwriting the state with true is exactly the class of bug the F5 exposed; now there is a test that fails if anyone regresses.
The lesson in the sequence
- State that changes people’s availability is business data. If the value influences what another user sees, it lives on the server — the frontend only projects it.
- Optimistic persistence has two halves: updating the UI instantly and syncing with the response. One half without the other is the ghost button again, just more convincing.
- An API contract ends at what the frontend reads. A PATCH that doesn’t return the updated field pushes the problem into the client.
The ghost button was chapter 1. The F5 that logged the walker out was chapter 2. And both were born in the same place: a test loop that demands observable behavior, not convincing visuals.