Skip to content
React Query+Supabase
Stack Integration

React Query + Supabase: Data Fetching Patterns

React Query manages server state in React applications — caching, background refetching, and optimistic updates — while Supabase provides the data layer. Together they eliminate boilerplate and make Supabase data feel instant.

Use Cases
  1. Cached Supabase queries with automatic background refresh in React
  2. Optimistic updates on Supabase mutations for instant UI feedback
  3. Paginated Supabase table queries with React Query's infinite scroll
  4. Query invalidation on Supabase Realtime events for live data sync
Implementation

Wrap Supabase client queries inside React Query's useQuery hook — the query key should include all filter parameters so cache invalidation is precise. For mutations, use useMutation with an onSuccess callback calling queryClient.invalidateQueries. For real-time + React Query, subscribe to Supabase Realtime channels and call queryClient.setQueryData on incoming events to update the cache without a refetch.

In detail

How the pieces connect

Supabase gives you a supabase-js client that builds queries against PostgREST (supabase.from('table').select()), plus separate Auth, Storage, and Realtime modules. React Query sits one layer above as the cache and request coordinator. It never talks to Postgres directly — it wraps the promise the Supabase client returns.

The pattern is to make each useQuery queryFn return the awaited Supabase call, then throw on error so React Query treats it as a failed query rather than caching a bad payload:

useQuery({
  queryKey: ['rooms', filters],
  queryFn: async () => {
    const { data, error } = await supabase
      .from('rooms').select('*').eq('status', filters.status)
    if (error) throw error
    return data
  },
})

React Query owns caching, retries, and staleTime; Supabase owns the data and row-level security.

Query keys, invalidation, and Realtime

The query key is the contract between your cache and your filters. Every parameter that changes the PostgREST result — eq, order, range, search text — belongs in the key array, or React Query will serve a stale slice from a different filter set. Keep keys structured (['rooms', { status, page }]) so partial invalidation works.

For mutations, call queryClient.invalidateQueries({ queryKey: ['rooms'] }) inside onSuccess to force a background refetch. For live data, subscribe with supabase.channel(...).on('postgres_changes', ...) and react to the payload. Two valid strategies: call invalidateQueries to refetch, or patch the cache directly with queryClient.setQueryData using the row in payload.new / payload.old. The setQueryData path avoids a round trip but you must merge by primary key yourself and unsubscribe the channel on unmount.

Production gotchas

Row-Level Security is the most common surprise. PostgREST applies RLS using the JWT attached to the request, so an authenticated query and an anonymous one hit different policies. If a select returns an empty array instead of an error, suspect a missing or too-strict RLS policy, not a React Query cache miss.

Session handling matters because the cache outlives the auth state. On supabase.auth.onAuthStateChange for SIGNED_OUT, call queryClient.clear() so a previous user's rows don't linger. After SIGNED_IN, invalidate user-scoped keys so they refetch under the new JWT.

Optimistic updates need the full rollback dance: onMutate cancels in-flight queries with cancelQueries, snapshots via getQueryData, applies the change, and returns the snapshot so onError can restore it. Also note count is not returned by default — pass { count: 'exact' } to select for pagination totals.

When this combo fits — and when it doesn't

It fits read-heavy React apps with frequent, cacheable Supabase queries: dashboards, tables, list-detail views, paginated feeds via useInfiniteQuery with PostgREST range(). You get deduped requests, background refresh, and clean optimistic mutations with little glue code.

It's a weaker fit when nearly everything is live. If most screens depend on Supabase Realtime broadcasting constant changes, you'll fight React Query's cache more than you benefit from it — a thinner Realtime-first store may be simpler. It also adds little for write-only flows (a single form posting to one table) or for server-rendered routes where the data is fetched on the server and never refetched on the client. Reach for React Query when client-side caching and refetching genuinely earn their keep.

Other integration guidesView all →
Related

Need this built?