Skip to main content

Migrating from react-query

This guide maps react-query concepts to use-remote-data equivalents. The migration can be incremental: both libraries can coexist in the same app while you move queries over one at a time.

The key difference

react-query gives you data: T | undefined and boolean flags (isLoading, isError, isFetching). You check the flags before accessing data, but TypeScript can't enforce this; a forgotten check is a bug that compiles.

use-remote-data gives you a RemoteDataStore<T> that is always in exactly one state. Inside <Await>, your data is T, not T | undefined. The type system enforces that you can't access data that doesn't exist.

Concept mapping

react-queryuse-remote-dataNotes
QueryClientProviderSharedStoreProviderOnly needed if you use useSharedRemoteData for global deduplication. useRemoteData needs no provider.
useQuery({ queryKey, queryFn })useSharedRemoteData(name, queryFn)For global deduplication. Or useRemoteData(queryFn) for component-scoped data.
queryKey: ['users', id]name: 'users' + dependencies: [id]Shared stores use a string name for deduplication. Dependency changes trigger re-fetch.
data: T | undefined<Await store={store}>{(data: T) => ...}</Await>Data is T inside <Await>, never undefined.
isLoadingstore.current.type === 'pending'Or just use <Await>; it handles loading for you.
isFetching / isValidatingisStale (second arg in <Await>){(data, isStale) => ...}; stale data stays visible during background refresh.
errorFailed state with retry callbackErrors are data, not exceptions. <Await> renders them automatically.
refetch()store.refresh()Manual re-fetch.
gcTime / cacheTimegcTime option on useSharedRemoteDataGrace period before cleanup after last subscriber unmounts.
staleTimeRefreshStrategy.afterMillis(ms)How long data stays fresh before a background re-fetch.
refetchIntervalRefreshStrategy.afterMillis(ms)Same mechanism, automatically re-fetches on schedule.
enabled: falseDon't render the <Await>Stores are lazy; they only fetch when rendered.
select: (data) => data.namestore.map((data) => data.name)Transform the success value.
placeholderDatainitial: RemoteData.success(placeholder)Seed the store with placeholder data.
useMutationuseRemoteUpdateFirst-class mutations with run(), reset(), typed state machine.
invalidateQueries(['users'])refreshes: [usersStore] on useRemoteUpdateDeclare which stores refresh after a successful mutation. Or call store.refresh() manually.
useQueries([...])RemoteDataStore.all(store1, store2)Combine into a typed tuple. Retry only re-fetches failed requests.
useInfiniteQueryuseRemoteDataMap + local page listEach page is a key in the map. See Infinite Scroll.
QueryClient devtools<RemoteDataDevtools />Fiber-scanning panel that auto-discovers all stores. See Debugging.

Step-by-step migration

1. Add the provider

If you're using deduplication (most react-query apps are), add <SharedStoreProvider> alongside your existing <QueryClientProvider>:

import { SharedStoreProvider } from 'use-remote-data';

function App() {
return (
<QueryClientProvider client={queryClient}>
<SharedStoreProvider>
<Router />
</SharedStoreProvider>
</QueryClientProvider>
);
}

2. Migrate one query at a time

Before (react-query):

import { useQuery } from '@tanstack/react-query';

function UserProfile({ id }) {
const {
data: user,
isLoading,
error,
} = useQuery({
queryKey: ['user', id],
queryFn: () => fetch(`/api/users/${id}`).then((r) => r.json()),
staleTime: 30_000,
});

if (isLoading) return <Spinner />;
if (error) return <p>Error: {error.message}</p>;

return <h1>{user.name}</h1>;
// user is User | undefined; TypeScript doesn't know
// you checked isLoading and error first
}

After (use-remote-data with shared stores):

import { Await, RefreshStrategy, useSharedRemoteData } from 'use-remote-data';

const fetchUser = (id: string) => (signal: AbortSignal) => fetch(`/api/users/${id}`, { signal }).then((r) => r.json());

function UserProfile({ id }) {
const store = useSharedRemoteData(`user-${id}`, fetchUser(id), {
refresh: RefreshStrategy.afterMillis(30_000),
});

return (
<Await
store={store}
loading={() => <Spinner />}
error={({ retry }) => (
<div>
<p>Something went wrong.</p>
<button onClick={retry}>Retry</button>
</div>
)}
>
{(user) => <h1>{user.name}</h1>}
{/* user is User. Always */}
</Await>
);
}

3. Migrate mutations

Before:

const mutation = useMutation({
mutationFn: (data) => updateUser(id, data),
onSuccess: () => queryClient.invalidateQueries(['user', id]),
});

mutation.mutate({ name: 'Alice' });

After:

const userStore = useSharedRemoteData(`user-${id}`, fetchUser(id));

const saveStore = useRemoteUpdate((data: { name: string }) => updateUser(id, data), { refreshes: [userStore] });

saveStore.run({ name: 'Alice' });

The refreshes array replaces invalidateQueries. After a successful mutation, each listed store re-fetches automatically.

4. Remove the react-query provider

Once all queries are migrated, remove QueryClientProvider and uninstall @tanstack/react-query.

What you gain

  • Type safety: Data is T inside <Await>, not T | undefined. A forgotten null check is a compile error, not a runtime bug.
  • Zero dependencies: No @tanstack/query-core. Just React.
  • Simpler model: No query client, no cache configuration, no dehydration/hydration for SSR. Stores are values.
  • Testability: RemoteDataStore.always(RemoteData.success(data)). No mock servers, no providers, no async coordination.
  • Surgical retry: When you combine stores and one fails, retry() only re-fetches the broken one.

What you give up

  • Devtools: react-query's devtools is a browser extension with richer filtering and timeline views. use-remote-data's <RemoteDataDevtools /> is a smaller in-app panel that needs no extension install.
  • Automatic deduplication by default: useRemoteData is component-scoped by default. You opt into deduplication by passing stores as props or with useSharedRemoteData.
  • Optimistic updates: Not built-in. You can implement them by updating local state before the mutation completes.

Infinite scroll

react-query has a dedicated useInfiniteQuery hook. In use-remote-data, infinite scroll is just a useRemoteDataMap where you grow the list of page keys:

Before (react-query):

const { data, fetchNextPage, hasNextPage } = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => fetchPosts(pageParam),
getNextPageParam: (lastPage) => lastPage.nextCursor,
initialPageParam: 0,
});

After (use-remote-data):

function PostFeed() {
const [cursors, setCursors] = useState([0]);
const pages = useRemoteDataMap<number, PostPage>((cursor, signal) => fetchPosts(cursor, signal));

const addPage = (nextCursor: number) => setCursors((prev) => [...prev, nextCursor]);

return (
<div>
{cursors.map((cursor) => (
<Await key={cursor} store={pages.get(cursor)}>
{(page) => (
<>
{page.posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
{page.nextCursor && <button onClick={() => addPage(page.nextCursor)}>Load more</button>}
</>
)}
</Await>
))}
</div>
);
}

Each page is an independent store entry, so already-loaded pages stay rendered while the next page loads. See the Infinite Scroll page for a runnable example.

Coexistence

Both libraries can coexist. A common migration pattern:

  1. New features use use-remote-data
  2. Existing features stay on react-query
  3. When you touch a file for other reasons, migrate its queries
  4. Eventually remove react-query

The two libraries don't share state, so a query in react-query and a shared store in use-remote-data with the same data will fetch independently. This is fine during migration. Avoid having both active for the same data in the same component.