Querying with React Query
How to fetch data from Supabase in React — with proper loading states, caching, and mutation handling.
Why Not Just Use useEffect?
The instinct when you first learn React is to fetch data in a useEffect. It works. The data appears. You move on.
Then you build a real app. You discover:
- You need to show a loading spinner while data is fetching.
- You need to show an error message when the fetch fails.
- You navigate away from a page and come back — the data fetches again from scratch, even though nothing changed.
- Two components on the same page both need the same data — they each make separate API calls.
- You update a record and the list doesn't reflect the change until you refresh the page.
- You want to show stale data immediately while fresh data loads in the background.
useEffect solves none of these problems gracefully. You end up writing a lot of state management code (isLoading, error, data, refetch) in every component that needs data. It's repetitive, error-prone, and inconsistent.
React Query is a library that solves all of these problems with a clean API. It is the standard for data fetching in modern React apps.
The visual below traces a single data request from the moment a user clicks through to the moment the screen updates — including where RLS enforcement happens along the way.
Four stages, under 200ms. The RLS check at stage 3 is why even a technically skilled user cannot bypass your access controls.
The Basic Pattern
Every React Query data fetch uses useQuery. Here is the structure.
Always handle three states in your component:
- Loading — show a skeleton or spinner
- Error — show an error message with a retry option
- Empty — show an explicit "no results" state
Never skip any of them. A user who sees a blank screen without any explanation will assume your app is broken.
useQuery. Find the isLoading, isError, and data destructured values. You will see the three-state pattern in use on every page — no exceptions.Query Keys — The Cache Identifier
The queryKey is the identity of a piece of cached data. React Query uses it to:
- Check if data for this key already exists in the cache.
- Identify which cached data to invalidate after a mutation.
- Deduplicate requests — if two components use the same key, only one request is made.
Keys are arrays. Use meaningful, specific values.
["job-applications", jobId] is the name for "the applications data for this specific job." When jobId changes (user navigates to a different job), React Query automatically fetches fresh data for the new key — just like a formula recalculates when its named range input changes.The STALE_TIMES Pattern
Different data changes at different rates. You should cache accordingly.
From Udyogaseva's src/lib/queryConfig.ts, constants like STALE_TIMES.STATIC (30 minutes for reference data like city lists), STALE_TIMES.MEDIUM (5 minutes for dashboard stats), and STALE_TIMES.SHORT (1 minute for live application data) are defined once and referenced everywhere.
Never hardcode millisecond values. Reference these named constants in every hook.
This way, if you decide all public data should cache for 10 minutes instead of 5, you change one line and every hook picks it up.
Mutations — Writing Data
useQuery is for reading. useMutation is for writing (create, update, delete).
The key advantage of useMutation is the onSuccess callback — after a successful write, you invalidate the relevant query cache, which triggers a fresh fetch and updates the UI automatically.
useMutation. Find one that updates application status. Look at its onSuccess callback — you will see queryClient.invalidateQueries being called. That one line is what makes the table update immediately after you click "Approve" without refreshing the page.Joining Related Tables — The Select Pattern
When you need data from multiple tables in one query, use Supabase's nested select syntax. This runs a single query with a JOIN instead of multiple separate requests.
This is one network request. It returns the candidate row plus their related locations, qualifications, and skills — all joined by the foreign key relationships you defined in your schema.
The !current_location_id syntax tells Supabase to use a specific foreign key when joining (in case a table has multiple foreign keys to the same target table).
Parallel Queries — The Right Way to Fetch Multiple Things
When a page needs data from multiple independent sources, fetch them in parallel — not one after another.
React Query fires all three queries simultaneously. The page renders as soon as all three complete. The total load time is the slowest of the three, not the sum of all three.
From Udyogaseva's job detail page, data from six different tables is fetched in parallel using Promise.all inside a single query function:
The enabled Option — Conditional Queries
Sometimes a query depends on data that might not be available yet. The enabled option controls whether the query runs:
Without enabled: !!jobId, the query would run with jobId = undefined and fail. This is a common bug in hooks that depend on asynchronously loaded parent data.
Error Handling — Never Swallow Errors
Every query can fail. Network timeout, database error, RLS denial, invalid data. Always handle errors explicitly.
The refetch function lets users retry a failed query without refreshing the whole page.
Query Invalidation — Keeping the UI in Sync
After any write operation (create, update, delete), the cached data is stale. You must invalidate the relevant queries so React Query fetches fresh data.
Invalidation marks the data as stale. The next time a component that uses that query key is visible, React Query fetches fresh data. If you want to force an immediate fetch, use queryClient.refetchQueries instead.
Match the specificity of your invalidation to the scope of your change:
- Invalidating
["job-applications"](withoutjobId) invalidates applications for all jobs — usually too broad. - Invalidating
["job-applications", jobId]only invalidates applications for the specific job that was modified — correct.