Why use Supabase with React Query?
Supabase offers a Javascript SDK for interacting with the Supabase services, such as its hosted Postgres database. As such, it doesn't seem like you would need to use a library like React Query to fetch data from the server.
However, there are a few reasons why you might want to use React Query with Supabase:
- Data re-fetching with React Query. By using React Query, you can easily refetch data after a mutation, or update the cache and add optimistic updates to your application.
- Caching data. React Query can cache data in various ways, such as in-memory, local storage, or using HTTP caching. This can be useful if you want to reduce the number of requests to your database, and it's arguably necessary if you want to reduce your database costs and scale your application.
While there can be more valid reasons to use React Query with Supabase, these are the two main reasons why I use it, and why I think you should too.
In this post, I want to show you how to use React Query with Supabase. I'll be using the Remix framework, but the concepts should apply to any React application.
NB: This post has been updated to work with React Query v5 and the new Supabase SSR package.
Why is React Query ideal for Supabase?
Since the realtime database does not support the streaming of snapshots, React Query is my preferred way to fetch data from the database without having to write a lot of code to manage the client-side state.
By re-fetching data after a mutation, you can easily keep your client-side state in sync with the database without having to merge the data yourself.
How to use Supabase with React Query
Installing the dependencies with NPM
First, we want to install the following dependencies in your project:
npm install @supabase/supabase-js @tanstack/react-query @supabase/ssr
Creating a React Query client
In the root o your component tree, you want to create a React Query client. This is where you can configure the cache and other options.
For simplicity, we will just create a default client but do check out the API to see what other options you can configure.
In Remix, we would wrap the root outlet with the QueryClientProvider
component:
const queryClient = new QueryClient();
<QueryClientProvider client={queryClient}>
<Outlet />
</QueryClientProvider>
If you're using Next.js, the above will be placed either in your _app.tsx
file or in your pages/root.tsx
layout component.
Creating a hook to create a Supabase client
Next, we want to create a hook to create a Supabase client. This is so that we can easily create a Supabase client in any component using the useSupabase
hook.
First, we create a function to create a Supabase client:
import { createBrowserClient } from '@supabase/ssr';
import invariant from 'tiny-invariant';
import getEnv from './get-env';
import type { Database } from '../../database.types';
let client: ReturnType<typeof createBrowserClient<Database>> | undefined;
export function getSupabaseBrowserClient() {
if (client) {
return client;
}
const env = getEnv();
invariant(env.SUPABASE_URL, `Supabase URL was not provided`);
invariant(env.SUPABASE_ANON_KEY, `Supabase Anon key was not provided`);
client = createBrowserClient<Database>(
env.SUPABASE_URL,
env.SUPABASE_ANON_KEY,
);
return client;
}
While your code will certainly be different, it's only important to notice that you need to pass the correct environment variables to the browser client. In our implementation, we use an abstract getEnv
function to get the environment variables.
Next, we can wrap the above in a useMemo
hook to prevent the client from being recreated on every render:
import { useMemo } from 'react';
import { getSupabaseBrowserClient } from '~/core/supabase/browser-client';
function useSupabase() {
return useMemo(getSupabaseBrowserClient, []);
}
export default useSupabase;
Creating a hook to fetch data
Now that we have a Supabase client, we can create a hook to fetch data from the database.
My favorite pattern for fetching data is to create a hook that returns a useQuery
hook from react-query
. Additionally, I like to pass the client as a parameter to a function that builds the query. This allows us to reuse the same query in both client and server code when creating different Supabase clients.
For example, we assume that we have a organizations
table in our database, and we want to fetch an organization by its ID.
We can create a hook like this:
function useOrganizationQuery(organizationId: number) {
const client = useSupabase();
const queryKey = ['organization', organizationId];
const queryFn = async () => {
return getOrganizationById(client, organizationId).then(
(result) => result.data
);
};
return useQuery({ queryKey, queryFn });
}
export default useOrganizationQuery;
- first, we imported the Supabase client using the
useSupabase
hook. - we then created a key for the query. This is used to identify the query in the cache and to refetch the query after a mutation.
- we return the
useQuery
hook fromreact-query
. This hook takes a key and a function that returns a promise with the data.
Instead, the function getOrganizationById
is a function that builds the query. This function is used in both client and server code, and it looks like this:
export function getOrganizationById(
client: Client,
organizationId: number
) {
return client
.from('organizations')
.select(`
id,
name
`)
.eq('id', organizationId)
.throwOnError()
.single();
}
Fetching data in a component
Now that we have a hook to fetch data, we can use it in a component. In this example, we will fetch an organization by its ID:
function OrganizationPage({
organizationId
}: {
organizationId: number;
}) {
const {
data: organization,
isLoading,
isError
} = useOrganizationQuery(organizationId);
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>Error</div>;
}
return (
<div>
<h1>{organization.name}</h1>
</div>
);
}
The hook from react-query
returns an object that we can use to determine the state of the query. In the example above we are checking if the query is loading or if there was an error.
Creating a hook to mutate data
Now that we have a hook to fetch data, we can create a hook to mutate data. Mutating data typically happens upon a user action, such as submitting a form.
The concept is similar to the above, but instead of using useQuery
we will use useMutation
from react-query
.
For example, we assume that we have a organizations
table in our database, and we want to update an organization by its ID.
We can create a hook like this:
function useUpdateOrganizationMutation() {
const client = useSupabase();
const mutationFn = async (organization: Organization) => {
return updateOrganizationById(client, organization).then(
(result) => result.data
);
};
return useMutation({ mutationFn });
}
- first, we imported the Supabase client using the
useSupabase
hook. - we return the
useMutation
hook fromreact-query
. This hook takes a function that returns a promise with the data.
export async function updateOrganization(
client: Client,
params: {
id: number;
data: Partial<Organization>;
}
) {
return client
.from('organizations')
.update({
name: params.data.name,
})
.match({ id: params.id })
.throwOnError()
.select<string, Organization>('*')
.throwOnError()
.single();
}
Mutating data in a component
Now that we have a hook to mutate data, we can use it in a component. In this example, we will update an organization by its ID:
export function OrganizationForm({
organizationId
}: {
organizationId: number;
}) {
const updateOrganizationMutation = useUpdateOrganizationMutation();
const onsSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get('name') as string;
updateOrganizationMutation.mutate({
id: organizationId,
data: {
name,
},
});
};
return <form onSubmit={onSubmit}></form>;
}
We have abstracted the form's UI, but the concept is the same. We call the mutate
function from the hook to update the data.
If you want to turn the request into a promise, you can use the mutateAsync
function:
updateOrganizationMutation.mutate({
id: organizationId,
data: {
name,
},
});
This is incredibly useful if you want to do something after the mutation has completed or if you use a library that accepts a promise. In the case of Makerkit, we use react-hot-toast
to show a toast during the mutation.
Re-fetching data after a mutation
When we mutate data, we want to re-fetch the data to display the newly updated data within the UI. To coordinate data reefetching across queries, we can use the query client from react-query
to reresh queries by referring to them using their IDs.
Let's rewrite the useUpdateOrganizationMutation
hook to re-fetch the query after the mutation has completed:
function useUpdateOrganizationMutation() {
const client = useSupabase();
const queryClient = useQueryClient();
const mutationFn = async (organization: Organization) => {
return updateOrganizationById(client, organization).then(
(result) => result.data
);
}, {
onSuccess: (data, variables) => {
queryClient.refetchQueries(['organization', variables.id]);
}
};
return useMutation({ mutationFn });
}
Updating the cache after a mutation
When we mutate data, we want to update the cache to display the newly updated data within the UI.
To coordinate data refetching across queries, we can use the query client from react-query
to update the cache by referring to them using their IDs.
const queryClient = useQueryClient()
const promise = updateOrganizationMutation.mutateAsync(organizationData, {
onSuccess: () => {
const newOrganizationData = {
...organization,
...organizationData,
};
setOrganization(newOrganizationData);
const userId = session?.data?.id;
const query = ['organizations', { userId }];
queryClient.setQueryData<UserOrganizationData[]>(query, (data) => {
if (data) {
return data.map((item) => {
if (organization.id === item.organization.id) {
const mergedOrganization = {
...item.organization,
...newOrganizationData,
};
return { ...item, organization: mergedOrganization };
}
return item;
});
}
});
},
});
Invalidating a query after a mutation
Similarly, we can invalidate a query after a mutation to force a re-fetch of the data. This is useful if we want to update the cache with the latest data.
const queryClient = useQueryClient()
const promise = updateOrganizationMutation.mutateAsync(organizationData, {
onSuccess: () => {
queryClient.invalidateQueries(['organization', organization.id]);
}
});
Optimistic updates with React Query
It can be useful in terms of UX to update the UI before the mutation has completed. This is called an optimistic update.
React Query makes it a breeze to do this. We can use the onMutate
function to update the cache before the mutation has completed. If the mutation fails, we can use the onError
function to roll back the cache to its previous state.
const queryClient = useQueryClient()
useMutation(updateOrganization, {
onMutate: async organization => {
// Cancel any outgoing refetches
await queryClient.cancelQueries('organizations');
// Snapshot the previous value
const snanpshot = queryClient.getQueryData('organizations');
// Optimistically update to the new value
queryClient.setQueryData('organizations', old => [...old, organization]);
// Return a context object with the snapshotted value
return { snanpshot };
},
onError: (err, newTodo, context) => {
// If the mutation fails, use the context returned from onMutate to roll back
queryClient.setQueryData('todos', context.snanpshot)
},
onSettled: () => {
// Always refetch after error or success
queryClient.invalidateQueries('organizations')
},
});
Conclusion
In this article, we have learned how to use Supabase with React Query.
I hope the above has given you some ideas on how to use Supabase with React Query. If you have any questions, please feel free to reach out!