Skip to content
AK.
All articles
State Management15 May 202410 min read

Designing a Typed, Invalidation-Aware React Query Data Layer

How to eliminate stale-cache bugs and duplicated fetch logic with typed query keys, invalidation maps and optimistic update patterns — the data layer conventions I've used across production healthcare and fintech applications.

Why React Query Alone Is Not Enough

React Query solves the mechanics of server state: caching, background refetching, loading and error states. But on any team larger than two people, the mechanics alone are not enough. Without conventions around query keys, you get duplicated fetch logic where two components request the same endpoint with different keys and React Query treats them as separate cache entries. Without invalidation strategy, a mutation succeeds but the list it affects stays stale. I've seen both patterns cause production bugs in healthcare applications where correctness matters.

Typed Query Key Factory

The foundation is a query key factory that makes every cache key discoverable, type-safe and impossible to mistype. This eliminates the class of bug where a mutation invalidates 'patients' but the query is cached under 'patient' (singular).

typescript
// lib/queryKeys.ts
export const queryKeys = {
  patients: {
    all: () => ['patients'] as const,
    lists: () => [...queryKeys.patients.all(), 'list'] as const,
    list: (filters: PatientFilters) =>
      [...queryKeys.patients.lists(), filters] as const,
    details: () => [...queryKeys.patients.all(), 'detail'] as const,
    detail: (id: string) =>
      [...queryKeys.patients.details(), id] as const,
  },
  appointments: {
    all: () => ['appointments'] as const,
    byPatient: (patientId: string) =>
      [...queryKeys.appointments.all(), patientId] as const,
  },
} as const;

// Usage: queryKeys.patients.detail('p-123')
// Returns: ['patients', 'detail', 'p-123']

Typed Custom Hooks

Every query is wrapped in a typed custom hook that co-locates the fetcher, the key and the cache options. This is the only place the fetch logic lives. No component ever calls fetch() directly.

typescript
// hooks/api/usePatient.ts
import { useQuery } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { patientSchema } from '@/lib/schemas';
import type { Patient } from '@/types';

async function fetchPatient(id: string): Promise<Patient> {
  const res = await fetch(`/api/patients/${id}`);
  if (!res.ok) throw new Error(`Failed to fetch patient ${id}`);
  const data = await res.json();
  return patientSchema.parse(data); // Zod validation at the boundary
}

export function usePatient(id: string) {
  return useQuery({
    queryKey: queryKeys.patients.detail(id),
    queryFn: () => fetchPatient(id),
    staleTime: 2 * 60 * 1000,   // 2 minutes
    gcTime: 5 * 60 * 1000,       // 5 minutes
    enabled: Boolean(id),
  });
}

Invalidation Maps

The most important convention is the invalidation map — a record that documents which mutations invalidate which queries. This is the single source of truth for cache coherence and makes the data relationships explicit rather than scattered across mutation callbacks.

typescript
// lib/invalidationMap.ts
// What does each mutation need to invalidate?
export const invalidationMap = {
  createAppointment: (patientId: string) => [
    queryKeys.appointments.byPatient(patientId),
    queryKeys.patients.detail(patientId), // patient's appointment count changes
  ],
  updatePatientRecord: (patientId: string) => [
    queryKeys.patients.detail(patientId),
    queryKeys.patients.lists(),            // list shows summary fields
  ],
  dischargePatient: (patientId: string) => [
    queryKeys.patients.all(),              // broad invalidation: affects all views
    queryKeys.appointments.byPatient(patientId),
  ],
} as const;

// Used in mutation hooks:
const queryClient = useQueryClient();
const keys = invalidationMap.createAppointment(patientId);
await Promise.all(
  keys.map(key => queryClient.invalidateQueries({ queryKey: key }))
);

Optimistic Updates for Perceived Performance

For mutations where the server response is predictable (toggling a status, updating a label), optimistic updates make the UI feel instant. The pattern is always the same: save the previous state, apply the update immediately, roll back on error.

typescript
export function useUpdateAppointmentStatus() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: ({ id, status }: { id: string; status: AppointmentStatus }) =>
      api.appointments.updateStatus(id, status),

    onMutate: async ({ id, status }) => {
      // Cancel in-flight refetches
      await queryClient.cancelQueries({ queryKey: queryKeys.appointments.all() });
      // Snapshot current value
      const previous = queryClient.getQueryData(queryKeys.appointments.all());
      // Optimistically update the cache
      queryClient.setQueryData(queryKeys.appointments.all(), (old: Appointment[]) =>
        old.map(a => a.id === id ? { ...a, status } : a)
      );
      return { previous }; // returned as context
    },

    onError: (_err, _vars, context) => {
      // Roll back on failure
      if (context?.previous) {
        queryClient.setQueryData(queryKeys.appointments.all(), context.previous);
      }
    },

    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: queryKeys.appointments.all() });
    },
  });
}

The Payoff

After adopting these conventions across the Genomas platform: stale-cache bugs went from a recurring complaint in sprint retrospectives to a non-issue. New engineers can find every query and mutation in their respective hooks directories — there is no global state to understand. The invalidation map has caught three instances of missing invalidation during code review that would have caused data consistency bugs in production. The data layer is boring, which is exactly what a data layer should be.

Building something similar? I'd like to hear about it.

Start a conversation