jotai-tanstack-query is a Jotai extension library for TanStack Query. It provides a wonderful interface with all of the TanStack Query features, providing you the ability to use those features in combination with your existing Jotai state.
- Support
- Install
- Usage
- Incremental Adoption
- Exported Provider
- Exported Functions
- SSR Support
- Error Handling
- Dev Tools
- FAQ
- Migrate to v0.8.0
jotai-tanstack-query currently supports Jotai v2 and TanStack Query v5.
npm i jotai jotai-tanstack-query @tanstack/react-queryimport { QueryClient } from '@tanstack/react-query'
import { useAtom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
import { QueryClientAtomProvider } from 'jotai-tanstack-query/react'
const queryClient = new QueryClient()
export const Root = () => {
return (
<QueryClientAtomProvider client={queryClient}>
<App />
</QueryClientAtomProvider>
)
}
const todosAtom = atomWithQuery(() => ({
queryKey: ['todos'],
queryFn: fetchTodoList,
}))
const App = () => {
const [{ data, isPending, isError }] = useAtom(todosAtom)
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error</div>
return <div>{JSON.stringify(data)}</div>
}You can incrementally adopt jotai-tanstack-query in your app. It's not an all or nothing solution. You just have to ensure you are using the same QueryClient instance.
// existing useQueryHook
const { data, isPending, isError } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodoList,
})
// jotai-tanstack-query
const todosAtom = atomWithQuery(() => ({
queryKey: ['todos'],
}))
const [{ data, isPending, isError }] = useAtom(todosAtom)QueryClientAtomProvider is a ready-to-use wrapper that combines Jotai Provider and TanStack Query QueryClientProvider.
import { QueryClient } from '@tanstack/react-query'
import { QueryClientAtomProvider } from 'jotai-tanstack-query/react'
const queryClient = new QueryClient()
export const Root = () => {
return (
<QueryClientAtomProvider client={queryClient}>
<App />
</QueryClientAtomProvider>
)
}Yes, you can absolutely combine them yourself.
- import { QueryClient } from '@tanstack/react-query'
- import { QueryClientAtomProvider } from 'jotai-tanstack-query/react'
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+ import { Provider } from 'jotai/react'
+ import { useHydrateAtoms } from 'jotai/react/utils'
+ import { queryClientAtom } from 'jotai-tanstack-query'
const queryClient = new QueryClient()
+ const HydrateAtoms = ({ children }) => {
+ useHydrateAtoms([[queryClientAtom, queryClient]])
+ return children
+ }
export const Root = () => {
return (
- <QueryClientAtomProvider client={queryClient}>
+ <QueryClientProvider client={queryClient}>
+ <Provider>
+ <HydrateAtoms>
<App />
+ </HydrateAtoms>
+ </Provider>
+ </QueryClientProvider>
- </QueryClientAtomProvider>
)
}atomWithQueryfor useQueryatomWithQueriesfor useQueriesatomWithInfiniteQueryfor useInfiniteQueryatomWithMutationfor useMutationatomWithSuspenseQueryfor useSuspenseQueryatomWithSuspenseInfiniteQueryfor useSuspenseInfiniteQueryatomWithMutationStatefor useMutationState
All functions follow the same signature.
const dataAtom = atomWithSomething(getOptions, getQueryClient)The first getOptions parameter is a function that returns an input to the observer.
The second optional getQueryClient parameter is a function that return QueryClient.
atomWithQuery creates a new atom that implements a standard Query from TanStack Query.
import { atom, useAtom } from 'jotai'
import { atomWithQuery } from 'jotai-tanstack-query'
const idAtom = atom(1)
const userAtom = atomWithQuery((get) => ({
queryKey: ['users', get(idAtom)],
queryFn: async ({ queryKey: [, id] }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
return res.json()
},
}))
const UserData = () => {
const [{ data, isPending, isError }] = useAtom(userAtom)
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error</div>
return <div>{JSON.stringify(data)}</div>
}atomWithQueries creates a new atom that implements Dynamic Parallel Queries from TanStack Query. It allows you to run multiple queries concurrently and optionally combine their results. You can read more about Dynamic Parallel Queries here.
There are two ways to use atomWithQueries:
import { Atom, atom, useAtom } from 'jotai'
import { type AtomWithQueryResult, atomWithQueries } from 'jotai-tanstack-query'
const userIdsAtom = atom([1, 2, 3])
// Independent atom - encapsulates query logic
const userQueryAtomsAtom = atom((get) => {
const userIds = get(userIdsAtom)
return atomWithQueries({
queries: userIds.map((id) => () => ({
queryKey: ['user', id],
queryFn: async ({ queryKey: [, userId] }) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
)
return res.json()
},
})),
})
})
// Independent UI component
const UserData = ({ queryAtom }: { queryAtom: Atom<AtomWithQueryResult> }) => {
const [{ data, isPending, isError }] = useAtom(queryAtom)
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error</div>
if (!data) return null
return (
<div>
{data.name} - {data.email}
</div>
)
}
// Component only needs one useAtom call
const UsersData = () => {
const [userQueryAtoms] = useAtom(userQueryAtomsAtom)
return (
<div>
{userQueryAtoms.map((queryAtom, index) => (
<UserData key={index} queryAtom={queryAtom} />
))}
</div>
)
}import { Atom, atom, useAtom } from 'jotai'
import { atomWithQueries } from 'jotai-tanstack-query'
const userIdsAtom = atom([1, 2, 3])
// Independent atom - encapsulates combined query logic
const combinedUsersDataAtom = atom((get) => {
const userIds = get(userIdsAtom)
return atomWithQueries({
queries: userIds.map((id) => () => ({
queryKey: ['user', id],
queryFn: async ({ queryKey: [, userId] }) => {
const res = await fetch(
`https://jsonplaceholder.typicode.com/users/${userId}`
)
return res.json()
},
})),
combine: (results) => ({
data: results.map((result) => result.data),
isPending: results.some((result) => result.isPending),
isError: results.some((result) => result.isError),
}),
})
})
// Component only needs one useAtom call
const CombinedUsersData = () => {
const [combinedUsersDataAtomValue] = useAtom(combinedUsersDataAtom)
const [{ data, isPending, isError }] = useAtom(combinedUsersDataAtomValue)
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error</div>
return (
<div>
{data.map((user) => (
<div key={user.id}>
{user.name} - {user.email}
</div>
))}
</div>
)
}atomWithInfiniteQuery is very similar to atomWithQuery, however it is for an InfiniteQuery, which is used for data that is meant to be paginated. You can read more about Infinite Queries here.
Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. React Query supports a useful version of useQuery called useInfiniteQuery for querying these types of lists.
A notable difference between a standard query atom is the additional option getNextPageParam and getPreviousPageParam, which is what you'll use to instruct the query on how to fetch any additional pages.
import { atom, useAtom } from 'jotai'
import { atomWithInfiniteQuery } from 'jotai-tanstack-query'
const postsAtom = atomWithInfiniteQuery(() => ({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts?_page=${pageParam}`)
return res.json()
},
getNextPageParam: (lastPage, allPages, lastPageParam) => lastPageParam + 1,
initialPageParam: 1,
}))
const Posts = () => {
const [{ data, fetchNextPage, isPending, isError, isFetching }] =
useAtom(postsAtom)
if (isPending) return <div>Loading...</div>
if (isError) return <div>Error</div>
return (
<>
{data.pages.map((page, index) => (
<div key={index}>
{page.map((post: any) => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}
<button onClick={() => fetchNextPage()}>Next</button>
</>
)
}atomWithMutation creates a new atom that implements a standard Mutation from TanStack Query.
Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects.
atomWithMutation supports all options from TanStack Query's useMutation, including:
mutationKey- A unique key for the mutationmutationFn- The function that performs the mutationonMutate- Called before the mutation is executed (useful for optimistic updates)onSuccess- Called when the mutation succeedsonError- Called when the mutation failsonSettled- Called when the mutation is settled (either success or error)retry- Number of retry attemptsretryDelay- Delay between retriesgcTime- Time until inactive mutations are garbage collected- And all other MutationOptions
import { useAtom } from 'jotai/react'
import { atomWithMutation } from 'jotai-tanstack-query'
const postAtom = atomWithMutation(() => ({
mutationKey: ['posts'],
mutationFn: async ({ title }: { title: string }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts`, {
method: 'POST',
body: JSON.stringify({
title,
body: 'body',
userId: 1,
}),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
})
const data = await res.json()
return data
},
}))
const Posts = () => {
const [{ mutate, isPending, status }] = useAtom(postAtom)
return (
<div>
<button onClick={() => mutate({ title: 'foo' })} disabled={isPending}>
{isPending ? 'Creating...' : 'Create Post'}
</button>
<pre>{JSON.stringify(status, null, 2)}</pre>
</div>
)
}atomWithMutation fully supports optimistic updates through the onMutate, onError, and onSettled callbacks. This allows you to update the UI immediately before the server responds, and roll back if the mutation fails.
import { Getter } from 'jotai'
import { useAtom } from 'jotai/react'
import { atomWithMutation, atomWithQuery, queryClientAtom } from 'jotai-tanstack-query'
interface Post {
id: number
title: string
body: string
userId: number
}
interface NewPost {
title: string
}
interface OptimisticContext {
previousPosts: Post[] | undefined
}
// Query to fetch posts list
const postsQueryAtom = atomWithQuery(() => ({
queryKey: ['posts'],
queryFn: async () => {
const res = await fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
return res.json() as Promise<Post[]>
},
}))
// Mutation with optimistic updates
const postAtom = atomWithMutation<Post, NewPost, Error, OptimisticContext>(
(get) => {
const queryClient = get(queryClientAtom)
return {
mutationKey: ['addPost'],
mutationFn: async ({ title }: NewPost) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts`, {
method: 'POST',
body: JSON.stringify({
title,
body: 'body',
userId: 1,
}),
headers: {
'Content-type': 'application/json; charset=UTF-8',
},
})
const data = await res.json()
return data as Post
},
// When mutate is called:
onMutate: async (newPost: NewPost) => {
// Cancel any outgoing refetches
// (so they don't overwrite our optimistic update)
await queryClient.cancelQueries({ queryKey: ['posts'] })
// Snapshot the previous value
const previousPosts = queryClient.getQueryData<Post[]>(['posts'])
// Optimistically update to the new value
queryClient.setQueryData<Post[]>(['posts'], (old) => {
const optimisticPost: Post = {
id: Date.now(), // Temporary ID
title: newPost.title,
body: 'body',
userId: 1,
}
return old ? [...old, optimisticPost] : [optimisticPost]
})
// Return a result with the snapshotted value
return { previousPosts }
},
// If the mutation fails, use the result returned from onMutate to roll back
onError: (
_err: Error,
_newPost: NewPost,
onMutateResult: OptimisticContext | undefined
) => {
if (onMutateResult?.previousPosts) {
queryClient.setQueryData(['posts'], onMutateResult.previousPosts)
}
},
// Always refetch after error or success:
onSettled: (
_data: Post | undefined,
_error: Error | null,
_variables: NewPost,
_onMutateResult: OptimisticContext | undefined
) => {
queryClient.invalidateQueries({ queryKey: ['posts'] })
},
}
}
)
const PostsList = () => {
const [{ data: posts, isPending }] = useAtom(postsQueryAtom)
if (isPending) return <div>Loading posts...</div>
return (
<div>
<h3>Posts:</h3>
<ul>
{posts?.map((post: Post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
</div>
)
}
const AddPost = () => {
const [{ mutate, isPending }] = useAtom(postAtom)
const [title, setTitle] = React.useState('')
return (
<div>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Enter post title"
/>
<button
onClick={() => {
if (title) {
mutate({ title })
setTitle('')
}
}}
disabled={isPending}
>
{isPending ? 'Adding...' : 'Add Post'}
</button>
</div>
)
}For more details on optimistic updates, see the TanStack Query Optimistic Updates guide.
atomWithMutationState creates a new atom that gives you access to all mutations in the MutationCache.
const mutationStateAtom = atomWithMutationState((get) => ({
filters: {
mutationKey: ['posts'],
},
}))jotai-tanstack-query can also be used with React's Suspense.
import { atom, useAtom } from 'jotai'
import { atomWithSuspenseQuery } from 'jotai-tanstack-query'
const idAtom = atom(1)
const userAtom = atomWithSuspenseQuery((get) => ({
queryKey: ['users', get(idAtom)],
queryFn: async ({ queryKey: [, id] }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`)
return res.json()
},
}))
const UserData = () => {
const [{ data }] = useAtom(userAtom)
return <div>{JSON.stringify(data)}</div>
}import { atom, useAtom } from 'jotai'
import { atomWithSuspenseInfiniteQuery } from 'jotai-tanstack-query'
const postsAtom = atomWithSuspenseInfiniteQuery(() => ({
queryKey: ['posts'],
queryFn: async ({ pageParam }) => {
const res = await fetch(`https://jsonplaceholder.typicode.com/posts?_page=${pageParam}`)
return res.json()
},
getNextPageParam: (lastPage, allPages, lastPageParam) => lastPageParam + 1,
initialPageParam: 1,
}))
const Posts = () => {
const [{ data, fetchNextPage, isPending, isError, isFetching }] =
useAtom(postsAtom)
return (
<>
{data.pages.map((page, index) => (
<div key={index}>
{page.map((post: any) => (
<div key={post.id}>{post.title}</div>
))}
</div>
))}
<button onClick={() => fetchNextPage()}>Next</button>
</>
)
}All atoms can be used within the context of a server side rendered app, such as a next.js app or Gatsby app. You can use both options that React Query supports for use within SSR apps, hydration or initialData.
Fetch error will be thrown and can be caught with ErrorBoundary. Refetching may recover from a temporary error.
See a working example to learn more.
In order to use the Devtools, you need to install it additionally.
$ npm i @tanstack/react-query-devtools --save-devAll you have to do is put the <ReactQueryDevtools /> within <QueryClientAtomProvider />.
import { QueryClient, QueryCache } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { QueryClientAtomProvider } from 'jotai-tanstack-query/react'
const queryClient = new QueryClient()
export const Root = () => {
return (
<QueryClientAtomProvider client={queryClient}>
<App />
<ReactQueryDevtools />
</QueryClientAtomProvider>
)
}Explicitly declare the get:Getter to get proper type inference for queryKey.
import { Getter } from 'jotai'
// ❌ Without explicit Getter type, queryKey type might be unknown
const userAtom = atomWithQuery((get) => ({
queryKey: ['users', get(idAtom).toString()],
queryFn: async ({ queryKey: [, id] }) => {
// typeof id = unknown
},
}))
// ✅ With explicit Getter type, queryKey gets proper type inference
const userAtom = atomWithQuery((get: Getter) => ({
queryKey: ['users', get(idAtom).toString()],
queryFn: async ({ queryKey: [, id] }) => {
// typeof id = string
},
}))All atom signatures have changed to be more consistent with TanStack Query.
v0.8.0 returns only a single atom, instead of a tuple of atoms, and hence the name change from atomsWithSomething toatomWithSomething.
- const [dataAtom, statusAtom] = atomsWithSomething(getOptions, getQueryClient)
+ const dataAtom = atomWithSomething(getOptions, getQueryClient)
In the previous version of jotai-tanstack-query, the query atoms atomsWithQuery and atomsWithInfiniteQuery returned a tuple of atoms: [dataAtom, statusAtom]. This design separated the data and its status into two different atoms.
dataAtomwas used to access the actual data (TData).statusAtomprovided the status object (QueryObserverResult<TData, TError>), which included additional attributes likeisPending,isError, etc.
In v0.8.0, they have been replaced by atomWithQuery and atomWithInfiniteQuery to return only a single dataAtom. This dataAtom now directly provides the QueryObserverResult<TData, TError>, aligning it closely with the behavior of Tanstack Query's bindings.
To migrate to the new version, replace the separate dataAtom and statusAtom usage with the unified dataAtom that now contains both data and status information.
- const [dataAtom, statusAtom] = atomsWithQuery(/* ... */);
- const [data] = useAtom(dataAtom);
- const [status] = useAtom(statusAtom);
+ const dataAtom = atomWithQuery(/* ... */);
+ const [{ data, isPending, isError }] = useAtom(dataAtom);Similar to atomsWithQuery and atomsWithInfiniteQuery, atomWithMutation also returns a single atom instead of a tuple of atoms. The return type of the atom value is MutationObserverResult<TData, TError, TVariables, TContext>.
- const [, postAtom] = atomsWithMutation(/* ... */);
- const [post, mutate] = useAtom(postAtom); // Accessing mutation status from post; and mutate() to execute the mutation
+ const postAtom = atomWithMutation(/* ... */);
+ const [{ data, error, mutate }] = useAtom(postAtom); // Accessing mutation result and mutate method from the same atom