supabase-cache-helpers
supabase-cache-helpers copied to clipboard
Optimistic UI
Is your feature request related to a problem? Please describe. When I'm waiting for my database to process a request, I'd like to see the UI already change.
Describe the solution you'd like When inserting a new row with an undefined ID, I need to wait for the server to then update my local state. It would be cool to have this feel more instant.
Describe alternatives you've considered I'm currently winging it with Jotai, but that doesn't work when inserting.
Additional context https://swr.vercel.app/blog/swr-v2.en-US#mutation-and-optimistic-ui
thanks for opening the issue! I was thinking about this already. Will have to check how to integrate it properly though, since the integration of the "smart" cache updates must be largely refactored.
@psteinroe that would be worth it though :D
Sponsored your work. Keep up the good work 🤘
Sponsored your work. Keep up the good work 🤘
thank you! ❤️ let me know if you have further feedback, or know anyone interested in building out adapters for svelte & co
What is the status here? Optimistic UI already possible with react-query?
no, and I am not working on this right now. I am not sure if this is even a good idea at this point, because we won't have a ways to revert an optimistic update if the mutation fails on the backend.
FWIW, I was able to use the "Via the UI" optimistic pattern from the react query docs by doing something like this:
const { data } = useQuery(
supabase.from('my_table').select('id,other_columns'),
)
const {
mutateAsync,
isPending,
variables,
} = useUpsertMutation(supabase.from('my_table'), ['id'])
const uiData= useMemo(
() => isPending ? merge([], data, variables) : data,
[data, isPending, variables],
)
You might need more complex merge logic for other scenarios, but this works for me with lodash merge()
because I'm always upserting the entire data set.
Is the onMutate parameter exposed somehow?
If so, we can handle our own optimistic UI logic .
See onMutate here
https://tanstack.com/query/v5/docs/framework/react/reference/useMutation
@skamensky it should be!
@psteinroe , Ah, I didn't understand that the third argument is passed to react-query as is. This library is super underated!
@Jonnotie, below is an example of how to do optimistic updates. I agree with @psteinroe that it might not be a good idea to bake this into the library, because handling failure use cases is probably going to be case by case basis. For example, do you revert the data to the non error state? Or do you keep it in the error state and let the user fix it, e.g. if it's form data.
Additionally, the library would need to double its memory usage to keep track of both the 'ui' state and the true query state.
In any case, here is a simple way of implementing optimistic updates:
import React, { useEffect, useState } from 'react';
import { QueryClient, QueryClientProvider} from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { useQuery,useSubscription,useUpdateMutation} from "@supabase-cache-helpers/postgrest-react-query";
import { supabase } from './supabaseClient';
const queryClient = new QueryClient();
const useUsers = () => {
return useQuery(
supabase
.from('users')
.select('id,name')
.order('id', { ascending: false })
.limit(50),
{
revalidateOnFocus: false,
revalidateOnReconnect: false,
}
);
};
const UserList = () => {
const { data, error, isLoading } = useUsers();
// this is the key to the idea. We maintain to copies of our data. This one is to present to the user.
const [uiData,setUiData] = useState(data);
useEffect(()=>{
setUiData(data)
},[data])
useSubscription(
supabase,
'user-insert-channel',
{
event: '*',
table: 'users',
schema: 'public',
},
['id'],
{
callback: (payload) => {
console.log('Change received!', payload);
},
}
);
const { mutateAsync } = useUpdateMutation(
supabase
.from('users'),
['id'],
"id,name",
{
onSuccess: () => console.log('Success!'),
onMutate:(updateData)=>{
console.log(uiData);
const newUiData = uiData.map(currentData=>{
if(currentData.id==updateData.id){
return updateData;
}
return currentData;
})
// before the update is even attempted, update the UI to show the new projected state of the data
setUiData(newUiData);
},
onError:(err,oldData)=>{
console.error("Error during update. Error:",err,"Data we tried updating:",oldData);
// in case of error, rollback to the "known good state"
setUiData(data);
}
}
);
const doUpdate= async (user)=>{
const userClone = Object.assign({},user)
// random dumb update
userClone.name=userClone.name+1;
await mutateAsync(userClone)
}
if (!uiData) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return (
<ul>
{uiData.map((user) => (
<><li key={user.id}>{user.id} -{user.name} <button onClick={()=>{doUpdate(user)}}>Update</button></li></>
))}
</ul>
);
};
function App() {
return (
<QueryClientProvider client={queryClient}>
<div className="App">
<h1>User List</h1>
<UserList />
</div>
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
export default App;