Repository: jotaijs/jotai-tanstack-query Branch: main Commit: 06759979819e Files: 111 Total size: 153.1 KB Directory structure: gitextract_m_gm3mha/ ├── .github/ │ └── workflows/ │ ├── cd.yml │ ├── ci.yml │ └── pr.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── __tests__/ │ ├── 01_basic.spec.tsx │ ├── atomWithInfiniteQuery.spec.tsx │ ├── atomWithMutation.spec.tsx │ ├── atomWithMutationState.spec.tsx │ ├── atomWithQuery.spec.tsx │ ├── atomWithSuspenseInfiniteQuery.spec.tsx │ └── atomWithSuspenseQuery.spec.tsx ├── eslint.config.js ├── examples/ │ ├── 01_query/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── 02_suspense/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── 03_infinite/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── 04_infinite_suspense/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── 05_mutation/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── 06_refetch/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── 07_queries/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── 08_query_client_atom_provider/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ ├── 09_error_boundary/ │ │ ├── index.html │ │ ├── package.json │ │ ├── src/ │ │ │ ├── App.tsx │ │ │ └── index.tsx │ │ ├── tsconfig.json │ │ ├── tsconfig.node.json │ │ └── vite.config.ts │ └── 11_nextjs_app_router/ │ ├── .gitignore │ ├── README.md │ ├── eslint.config.mjs │ ├── next.config.ts │ ├── package.json │ ├── postcss.config.mjs │ ├── src/ │ │ └── app/ │ │ ├── api.ts │ │ ├── layout.tsx │ │ ├── page.tsx │ │ ├── posts/ │ │ │ ├── [postId]/ │ │ │ │ ├── _components/ │ │ │ │ │ └── post.tsx │ │ │ │ └── page.tsx │ │ │ └── page.tsx │ │ ├── providers.tsx │ │ └── stores.ts │ └── tsconfig.json ├── package.json ├── pnpm-workspace.yaml ├── src/ │ ├── _queryClientAtom.ts │ ├── atomWithInfiniteQuery.ts │ ├── atomWithMutation.ts │ ├── atomWithMutationState.ts │ ├── atomWithQueries.ts │ ├── atomWithQuery.ts │ ├── atomWithSuspenseInfiniteQuery.ts │ ├── atomWithSuspenseQuery.ts │ ├── baseAtomWithQuery.ts │ ├── index.ts │ ├── react.ts │ ├── types.ts │ └── utils.ts ├── tsconfig.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/cd.yml ================================================ name: CD on: release: types: [published] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 registry-url: 'https://registry.npmjs.org' cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - run: pnpm install --frozen-lockfile - run: pnpm run compile - run: pnpm test - run: pnpm publish --no-git-checks env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - run: pnpm install --frozen-lockfile - run: pnpm compile - run: pnpm test ================================================ FILE: .github/workflows/pr.yml ================================================ name: Publish Preview on: pull_request: types: [opened, synchronize] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: node-version: 22 cache: 'pnpm' cache-dependency-path: '**/pnpm-lock.yaml' - run: pnpm install --frozen-lockfile - run: pnpm run compile - run: pnpm dlx pkg-pr-new publish --template './examples/*' ================================================ FILE: .gitignore ================================================ *~ *.swp node_modules /dist ================================================ FILE: .prettierrc ================================================ { "semi": false, "trailingComma": "es5", "singleQuote": true, "bracketSameLine": true, "tabWidth": 2, "printWidth": 80 } ================================================ FILE: CHANGELOG.md ================================================ # Change Log ## [Unreleased] ## [0.11.0] - 2025-08-01 ### Changed - feat: `QueryClientAtomProvider` is a ready-to-use wrapper that combines Jotai Provider and TanStack Query QueryClientProvider. - breaking: removed `QueryClientProvider` ## [0.10.1] - 2025-07-24 ### Changed - fix: '/react' import path for QueryClientProvider utils ## [0.10.0] - 2025-07-23 ### Changed - feat: atomWithQueries for tanstack/query's useQueries - feat: add QueryClientProvider utils for react ## [0.9.2] - 2025-07-10 ### Changed - upgrade @tankstack/query-core to 5.81.5 ## [0.9.1] - 2025-07-09 ### Changed - upgrade @tankstack/query-core to 5.81.2 ## [0.9.0] - 2024-09-29 ### Added - add: change atom signature to return a setter function to force re-evaluate an atom. This is to help the atom able to reevaluate when it hits an error boundary by throwing a rejected promise. ## [0.8.8] - 2024-09-15 ### Changed - fix: use query.reset on error instead of client.resetQueries. ## [0.8.7] - 2024-08-18 ### Changed - add: batch calls to improve perf - fix: return appropriate cleanup functions - fix: cleanup - remove unused code ## [0.8.6] - 2024-08-05 ### Changed - fix: remove wonka as a peer dependency ## [0.8.5] - 2024-02-04 ### Changed - fix: include types.ts in build ## [0.8.4] - 2024-01-31 ### Changed - fix: fix generics ## [0.8.3] - 2024-01-29 ### Changed - fix: update types ## [0.8.2] - 2024-01-15 ### Changed - fix: update jotai peer dependency to v2 ## [0.8.1] - 2023-12-23 ### Changed - fix: add default staletime for suspense atoms - fix: suspense example ## [0.8.0] - 2023-12-09 ### Added - breaking: update atom api to resemble tanstack/query api - add: atomWithSuspenseQuery, atomWithSuspenseInfiniteQuery, atomWithMutationState ## [0.7.2] - 2023-09-08 ### Changed - fix: loading mutation does not call refresh on unmount #38 ## [0.7.1] - 2023-05-25 ### Changed - Fix result of statusAtom sometimes not updated #35 ## [0.7.0] - 2023-04-03 ### Added - feat: atomsWithQueryAsync, plus example #30 ## [0.6.0] - 2023-03-03 ### Added - feat: mark internal atoms as private ## [0.5.0] - 2023-01-31 ### Added - Migrate to Jotai v2 API #18 ## [0.4.0] - 2022-10-21 ### Changed - fix: status should change #10 - breaking: simplify api names #11 ## [0.3.0] - 2022-10-11 ### Changed - make mutation atom type correct #7 - update jotai and fix types #8 ## [0.2.1] - 2022-10-04 ### Changed - fix setOptions not to delay #6 ## [0.2.0] - 2022-09-27 ### Changed - for dataAtom, re-create observer when options change #5 ## [0.1.0] - 2022-09-24 ### Added - implement refetch #1 - feat: observer cache #2 - feat: infinite query #3 - feat: mutation api #4 ## [0.0.1] - 2022-09-20 ### Added - Initial experimental release ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2022 Daishi Kato Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Jotai Query 🚀 👻 [jotai-tanstack-query](https://github.com/jotai-labs/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. # Table of contents - [Support](#support) - [Install](#install) - [Usage](#usage) - [Incremental Adoption](#incremental-adoption) - [Exported Provider](#exported-provider) - [Exported Functions](#exported-functions) - [atomWithQuery](#atomwithquery-usage) - [atomWithQueries](#atomwithqueries-usage) - [atomWithInfiniteQuery](#atomwithinfinitequery-usage) - [atomWithMutation](#atomwithmutation-usage) - [atomWithMutationState](#atomwithmutationstate-usage) - [Suspense](#suspense) - [atomWithSuspenseQuery](#atomwithsuspensequery-usage) - [atomWithSuspenseInfiniteQuery](#atomwithsuspenseinfinitequery-usage) - [SSR Support](#ssr-support) - [Next.js App Router Example](#nextjs-app-router-example) - [Error Handling](#error-handling) - [Dev Tools](#devtools) - [FAQ](#faq) - [Migrate to v0.8.0](#migrate-to-v080) ### Support jotai-tanstack-query currently supports [Jotai v2](https://jotai.org) and [TanStack Query v5](https://tanstack.com/query/v5). ### Install ```bash npm i jotai jotai-tanstack-query @tanstack/react-query ``` ### Usage ```jsx import { QueryClient } from '@tanstack/react-query' import { useAtomValue } from 'jotai' import { atomWithQuery } from 'jotai-tanstack-query' import { QueryClientAtomProvider } from 'jotai-tanstack-query/react' const queryClient = new QueryClient() export const Root = () => { return ( ) } const todosAtom = atomWithQuery(() => ({ queryKey: ['todos'], queryFn: fetchTodoList, })) const App = () => { const { data, isPending, isError } = useAtomValue(todosAtom) if (isPending) return
Loading...
if (isError) return
Error
return
{JSON.stringify(data)}
} ``` ### Incremental Adoption 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](#exported-provider). ```jsx // TanStack/Query const { data, isPending, isError } = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList, }) // jotai-tanstack-query const todosAtom = atomWithQuery(() => ({ queryKey: ['todos'], })) const { data, isPending, isError } = useAtomValue(todosAtom) ``` ### Exported provider [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/08_query_client_atom_provider) `QueryClientAtomProvider` is a ready-to-use wrapper that combines Jotai Provider and TanStack Query QueryClientProvider. ```jsx import { QueryClient } from '@tanstack/react-query' import { QueryClientAtomProvider } from 'jotai-tanstack-query/react' const queryClient = new QueryClient() export const Root = () => { return ( ) } ``` Yes, you can absolutely combine them yourself. ```js import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Provider } from 'jotai/react' import { queryClientAtom } from 'jotai-tanstack-query' import { useHydrateAtoms } from 'jotai/react/utils' const queryClient = new QueryClient() const HydrateAtoms = ({ children }) => { useHydrateAtoms([[queryClientAtom, queryClient]]) return children } export const Root = () => { return ( ) } ``` ### Exported functions - `atomWithQuery` for [useQuery](https://tanstack.com/query/v5/docs/react/reference/useQuery) - `atomWithQueries` for [useQueries](https://tanstack.com/query/v5/docs/react/reference/useQueries) - `atomWithInfiniteQuery` for [useInfiniteQuery](https://tanstack.com/query/v5/docs/react/reference/useInfiniteQuery) - `atomWithMutation` for [useMutation](https://tanstack.com/query/v5/docs/react/reference/useMutation) - `atomWithSuspenseQuery` for [useSuspenseQuery](https://tanstack.com/query/v5/docs/react/reference/useSuspenseQuery) - `atomWithSuspenseInfiniteQuery` for [useSuspenseInfiniteQuery](https://tanstack.com/query/v5/docs/react/reference/useSuspenseInfiniteQuery) - `atomWithMutationState` for [useMutationState](https://tanstack.com/query/v5/docs/react/reference/useMutationState) All functions follow the same signature. ```ts 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](https://tanstack.com/query/v5/docs/reference/QueryClient). ### atomWithQuery usage [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/01_query) `atomWithQuery` creates a new atom that implements a standard [`Query`](https://tanstack.com/query/v5/docs/react/guides/queries) from TanStack Query. ```jsx 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
Loading...
if (isError) return
Error
return
{JSON.stringify(data)}
} ``` ### atomWithQueries usage [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/07_queries) `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](https://tanstack.com/query/v5/docs/framework/react/guides/parallel-queries#dynamic-parallel-queries-with-usequeries). There are two ways to use `atomWithQueries`: #### Basic usage - Returns an array of query atoms ```jsx 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 }) => { const [{ data, isPending, isError }] = useAtom(queryAtom) if (isPending) return
Loading...
if (isError) return
Error
if (!data) return null return (
{data.name} - {data.email}
) } // Component only needs one useAtom call const UsersData = () => { const [userQueryAtoms] = useAtom(userQueryAtomsAtom) return (
{userQueryAtoms.map((queryAtom, index) => ( ))}
) } ``` #### Advanced usage - Combine multiple query results ```jsx 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
Loading...
if (isError) return
Error
return (
{data.map((user) => (
{user.name} - {user.email}
))}
) } ``` ### atomWithInfiniteQuery usage [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/03_infinite) `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](https://tanstack.com/query/v5/docs/guides/infinite-queries). > 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. ```jsx 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
Loading...
if (isError) return
Error
return ( <> {data.pages.map((page, index) => (
{page.map((post: any) => (
{post.title}
))}
))} ) } ``` ### atomWithMutation usage [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/05_mutation) `atomWithMutation` creates a new atom that implements a standard [`Mutation`](https://tanstack.com/query/v5/docs/guides/mutations) 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`](https://tanstack.com/query/v5/docs/react/reference/useMutation), including: - `mutationKey` - A unique key for the mutation - `mutationFn` - The function that performs the mutation - `onMutate` - Called before the mutation is executed (useful for optimistic updates) - `onSuccess` - Called when the mutation succeeds - `onError` - Called when the mutation fails - `onSettled` - Called when the mutation is settled (either success or error) - `retry` - Number of retry attempts - `retryDelay` - Delay between retries - `gcTime` - Time until inactive mutations are garbage collected - And all other [MutationOptions](https://tanstack.com/query/v5/docs/react/reference/useMutation#options) #### Basic usage ```tsx 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 (
{JSON.stringify(status, null, 2)}
) } ``` #### Optimistic Updates `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. ```tsx 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 }, })) // Mutation with optimistic updates const postAtom = atomWithMutation( (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(['posts']) // Optimistically update to the new value queryClient.setQueryData(['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
Loading posts...
return (

Posts:

    {posts?.map((post: Post) => (
  • {post.title}
  • ))}
) } const AddPost = () => { const [{ mutate, isPending }] = useAtom(postAtom) const [title, setTitle] = React.useState('') return (
setTitle(e.target.value)} placeholder="Enter post title" />
) } ``` For more details on optimistic updates, see the [TanStack Query Optimistic Updates guide](https://tanstack.com/query/v5/docs/framework/react/guides/optimistic-updates). ### atomWithMutationState usage `atomWithMutationState` creates a new atom that gives you access to all mutations in the [`MutationCache`](https://tanstack.com/query/v5/docs/react/reference/useMutationState). ```jsx const mutationStateAtom = atomWithMutationState((get) => ({ filters: { mutationKey: ['posts'], }, })) ``` ### Suspense jotai-tanstack-query can also be used with React's Suspense. ### atomWithSuspenseQuery usage [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/02_suspense) ```jsx 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
{JSON.stringify(data)}
} ``` ### atomWithSuspenseInfiniteQuery usage [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/04_infinite_suspense) ```jsx 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) => (
{page.map((post: any) => (
{post.title}
))}
))} ) } ``` ### SSR support To understand if your application can benefit from React Query when also using Server Components, see the article [You Might Not Need React Query](https://tkdodo.eu/blog/you-might-not-need-react-query). 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](https://tanstack.com/query/v5/docs/framework/react/guides/ssr) that React Query supports for use within SSR apps, [hydration](https://tanstack.com/query/v5/docs/framework/react/guides/ssr#using-the-hydration-apis) or [`initialData`](https://tanstack.com/query/v5/docs/framework/react/guides/ssr#get-started-fast-with-initialdata). #### Next.js App Router Example [![Open in StackBlitz](https://developer.stackblitz.com/img/open_in_stackblitz.svg)](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/11_nextjs_app_router) ### Error handling Fetch error will be thrown and can be caught with ErrorBoundary. Refetching may recover from a temporary error. See [a working example](https://stackblitz.com/github/jotaijs/jotai-tanstack-query/tree/main/examples/09_error_boundary) to learn more. ### Devtools In order to use the Devtools, you need to install it additionally. ```bash $ npm i @tanstack/react-query-devtools --save-dev ``` All you have to do is put the `` within ``. ```tsx 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 ( ) } ``` ## FAQ ### atomsWithQuery `queryKey` type is always `unknown`? Explicitly declare the `get:Getter` to get proper type inference for `queryKey`. ```tsx 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 }, })) ``` ### Why “tracked properties” are not supported TanStack Query provides a feature called [tracked properties](https://tanstack.com/query/v5/docs/framework/react/guides/render-optimizations#tracked-properties), which only triggers a re-render when a property that was actually accessed changes. For example: ```tsx const { data } = useQuery(...) ``` If only data is accessed, changes to isFetching or status will not cause a re-render. However, atomWithQuery intentionally does **not** implement this behavior. The atom approach is to create derived atoms rather than automatic tracking. ```js const queryAtom = atomWithQuery(...) const dataAtom = atom((get) => get(queryAtom).data) const Component = () => { const data = useAtomValue(dataAtom) ... } ``` The component only subscribes to data, changes to other properties (e.g. isFetching) won’t trigger re-renders ## Migrate to v0.8.0 ### Change in atom signature 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` to`atomWithSomething`. ```diff - const [dataAtom, statusAtom] = atomsWithSomething(getOptions, getQueryClient) + const dataAtom = atomWithSomething(getOptions, getQueryClient) ``` ### Simplified Return Structure 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. #### atomWithQuery and atomWithInfiniteQuery - `dataAtom` was used to access the actual data (`TData`). - `statusAtom` provided the status object (`QueryObserverResult`), which included additional attributes like `isPending`, `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`, 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. ```diff - const [dataAtom, statusAtom] = atomsWithQuery(/* ... */); - const [data] = useAtom(dataAtom); - const [status] = useAtom(statusAtom); + const dataAtom = atomWithQuery(/* ... */); + const [{ data, isPending, isError }] = useAtom(dataAtom); ``` #### atomWithMutation 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`. ```diff - 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 ``` ================================================ FILE: __tests__/01_basic.spec.tsx ================================================ import { atomWithInfiniteQuery, atomWithMutation, atomWithMutationState, atomWithQuery, atomWithSuspenseInfiniteQuery, atomWithSuspenseQuery, queryClientAtom, } from '../src/index' describe('basic spec', () => { it('should export functions', () => { expect(queryClientAtom).toBeDefined() expect(atomWithQuery).toBeDefined() expect(atomWithInfiniteQuery).toBeDefined() expect(atomWithMutation).toBeDefined() expect(atomWithSuspenseQuery).toBeDefined() expect(atomWithSuspenseInfiniteQuery).toBeDefined() expect(atomWithMutationState).toBeDefined() }) }) ================================================ FILE: __tests__/atomWithInfiniteQuery.spec.tsx ================================================ import React, { Component, StrictMode, Suspense } from 'react' import type { ReactNode } from 'react' import { fireEvent, render } from '@testing-library/react' import { useAtom } from 'jotai/react' import { atom } from 'jotai/vanilla' import { vi } from 'vitest' import { atomWithInfiniteQuery } from '../src/index' let originalConsoleError: typeof console.error beforeEach(() => { originalConsoleError = console.error console.error = vi.fn() }) afterEach(() => { console.error = originalConsoleError }) it('infinite query basic test', async () => { let resolve = () => {} type DataResponse = { response: { count: number } } const countAtom = atomWithInfiniteQuery(() => ({ initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.response.count + 1, queryKey: ['countInfinite'], queryFn: async ({ pageParam }) => { await new Promise((r) => (resolve = r)) return { response: { count: pageParam as number } } }, })) const Counter = () => { const [countData] = useAtom(countAtom) const { data, isPending, isError } = countData if (isPending) return <>loading if (isError) return <>error return ( <>
page count: {data.pages.length}
) } const { findByText } = render( ) await findByText('loading') resolve() await findByText('page count: 1') }) it('infinite query next page test', async () => { const mockFetch = vi.fn((response: { count: number }) => ({ response })) let resolve = () => {} const countAtom = atomWithInfiniteQuery<{ response: { count: number } }>( () => ({ initialPageParam: 1, queryKey: ['nextPageAtom'], queryFn: async ({ pageParam }) => { await new Promise((r) => (resolve = r)) return mockFetch({ count: pageParam as number }) }, getNextPageParam: (lastPage) => { const { response: { count }, } = lastPage return (count + 1).toString() }, getPreviousPageParam: (lastPage) => { const { response: { count }, } = lastPage return (count - 1).toString() }, }) ) const Counter = () => { const [countData] = useAtom(countAtom) const { isPending, isError, data, fetchNextPage, fetchPreviousPage } = countData if (isPending) return <>loading if (isError) return <>error return ( <>
page count: {data.pages.length}
) } const { findByText, getByText } = render( <> ) await findByText('loading') resolve() await findByText('page count: 1') expect(mockFetch).toBeCalledTimes(1) fireEvent.click(getByText('next')) resolve() await findByText('page count: 2') expect(mockFetch).toBeCalledTimes(2) fireEvent.click(getByText('prev')) resolve() await findByText('page count: 3') expect(mockFetch).toBeCalledTimes(3) }) it('infinite query with enabled', async () => { const slugAtom = atom(null) let resolve = () => {} type DataResponse = { response: { slug: string currentPage: number } } const slugQueryAtom = atomWithInfiniteQuery((get) => { const slug = get(slugAtom) return { initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.response.currentPage + 1, enabled: !!slug, queryKey: ['disabled_until_value', slug], queryFn: async ({ pageParam }) => { await new Promise((r) => (resolve = r)) return { response: { slug: `hello-${slug}`, currentPage: pageParam as number }, } }, } }) const Slug = () => { const [slugQueryData] = useAtom(slugQueryAtom) const { data, isPending, isError, fetchStatus } = slugQueryData if (isPending && fetchStatus === 'idle') return
not enabled
if (isPending) return <>loading if (isError) return <>error return
slug: {data.pages[0]?.response?.slug}
} const Parent = () => { const [, setSlug] = useAtom(slugAtom) return (
) } const { getByText, findByText } = render( ) await findByText('not enabled') fireEvent.click(getByText('set slug')) await findByText('loading') resolve() await findByText('slug: hello-world') }) it('infinite query with enabled 2', async () => { const enabledAtom = atom(true) const slugAtom = atom('first') type DataResponse = { response: { slug: string currentPage: number } } let resolve = () => {} const slugQueryAtom = atomWithInfiniteQuery((get) => { const slug = get(slugAtom) const isEnabled = get(enabledAtom) return { getNextPageParam: (lastPage) => lastPage.response.currentPage + 1, initialPageParam: 1, enabled: isEnabled, queryKey: ['enabled_toggle'], queryFn: async ({ pageParam }) => { await new Promise((r) => (resolve = r)) return { response: { slug: `hello-${slug}`, currentPage: pageParam as number }, } }, } }) const Slug = () => { const [slugQueryData] = useAtom(slugQueryAtom) const { data, isPending, isError, fetchStatus } = slugQueryData if (isPending && fetchStatus === 'idle') return
not enabled
if (isPending) return <>loading if (isError) return <>error return
slug: {data.pages[0]?.response.slug}
} const Parent = () => { const [, setSlug] = useAtom(slugAtom) const [, setEnabled] = useAtom(enabledAtom) return (
) } const { getByText, findByText } = render( ) await findByText('loading') resolve() await findByText('slug: hello-first') fireEvent.click(getByText('set disabled')) fireEvent.click(getByText('set slug')) await findByText('slug: hello-first') fireEvent.click(getByText('set enabled')) resolve() await findByText('slug: hello-world') }) describe('error handling', () => { class ErrorBoundary extends Component< { message?: string; retry?: () => void; children: ReactNode }, { hasError: boolean } > { constructor(props: { message?: string; children: ReactNode }) { super(props) this.state = { hasError: false } } static getDerivedStateFromError() { return { hasError: true } } render() { return this.state.hasError ? (
{this.props.message || 'errored'} {this.props.retry && ( )}
) : ( this.props.children ) } } it('can catch error in error boundary', async () => { let resolve = () => {} const countAtom = atomWithInfiniteQuery(() => ({ initialPageParam: 1, getNextPageParam: (lastPage) => lastPage.response.count + 1, queryKey: ['error test', 'count1Infinite'], retry: false, queryFn: async (): Promise<{ response: { count: number } }> => { await new Promise((r) => (resolve = r)) throw new Error('fetch error') }, throwOnError: true, })) const Counter = () => { const [{ data, isPending }] = useAtom(countAtom) if (isPending) return <>loading const pages = data?.pages return ( <>
count: {pages?.[0]?.response.count}
) } const { findByText } = render( ) await findByText('loading') resolve() await findByText('errored') }) }) ================================================ FILE: __tests__/atomWithMutation.spec.tsx ================================================ import React, { useState } from 'react' import { fireEvent, render } from '@testing-library/react' import { useAtom } from 'jotai/react' import { atomWithMutation } from '../src/index' it('atomWithMutation should be refreshed on unmount (#2060)', async () => { let resolve: (() => void) | undefined const mutateAtom = atomWithMutation(() => ({ mutationKey: ['test-atom'], mutationFn: async (a) => { await new Promise((r) => { resolve = r }) return a }, })) function App() { const [mount, setMount] = useState(true) return (
{mount && }
) } function TestView() { const [{ mutate, isPending, status }] = useAtom(mutateAtom) return (

status: {status}

) } const { findByText, getByText } = render() await findByText('status: idle') fireEvent.click(getByText('mutate')) await findByText('status: pending') resolve?.() await findByText('status: success') fireEvent.click(getByText('unmount')) fireEvent.click(getByText('mount')) await findByText('status: idle') resolve?.() }) ================================================ FILE: __tests__/atomWithMutationState.spec.tsx ================================================ import React from 'react' import { QueryClient } from '@tanstack/query-core' import { fireEvent, render } from '@testing-library/react' import { Provider, useAtom } from 'jotai' import { atomWithMutation, atomWithMutationState } from '../src' it('atomWithMutationState multiple', async () => { const client = new QueryClient() let resolve1: (() => void) | undefined const mutateAtom1 = atomWithMutation( () => ({ mutationKey: ['test-atom'], mutationFn: async (a) => { await new Promise((r) => { resolve1 = r }) return a }, }), () => client ) let resolve2: (() => void) | undefined const mutateAtom2 = atomWithMutation( () => ({ mutationKey: ['test-atom'], mutationFn: async (a) => { await new Promise((r) => { resolve2 = r }) return a }, }), () => client ) const pendingMutationStateAtom = atomWithMutationState( () => ({ filters: { mutationKey: ['test-atom'], status: 'pending' } }), () => client ) const allMutationStatesAtom = atomWithMutationState( () => ({ filters: { mutationKey: ['test-atom'] } }), () => client ) function App() { const [{ mutate: mutate1 }] = useAtom(mutateAtom1) const [{ mutate: mutate2 }] = useAtom(mutateAtom2) const [pendingMutationState] = useAtom(pendingMutationStateAtom) const [allMutationStates] = useAtom(allMutationStatesAtom) return (

all: {allMutationStates.length}

pending: {pendingMutationState.length}

) } const { findByText, getByText } = render( ) await findByText('all: 0') await findByText('pending: 0') fireEvent.click(getByText('mutate')) await findByText('all: 2') await findByText('pending: 2') resolve1?.() await findByText('all: 2') await findByText('pending: 1') resolve2?.() await findByText('all: 2') await findByText('pending: 0') }) ================================================ FILE: __tests__/atomWithQuery.spec.tsx ================================================ import React, { StrictMode, Suspense, useState } from 'react' import { QueryClient } from '@tanstack/query-core' import { fireEvent, render } from '@testing-library/react' import { Getter, atom, useAtom, useSetAtom } from 'jotai' import { unwrap } from 'jotai/utils' import { ErrorBoundary } from 'react-error-boundary' import { vi } from 'vitest' import { atomWithQuery } from '../src' let originalConsoleError: typeof console.error beforeEach(() => { originalConsoleError = console.error console.error = vi.fn() }) afterEach(() => { console.error = originalConsoleError }) it('query basic test', async () => { let resolve = () => {} const countAtom = atomWithQuery(() => ({ queryKey: ['test1'], queryFn: async () => { await new Promise((r) => (resolve = r)) return { response: { count: 0 } } }, })) const Counter = () => { const [countData] = useAtom(countAtom) const { data, isPending, isError } = countData if (isPending) { return <>loading } if (isError) { return <>errored } return ( <>
count: {data.response.count}
) } const { findByText } = render( ) await findByText('loading') resolve() await findByText('count: 0') }) it('async query basic test', async () => { const fn = vi.fn(() => Promise.resolve(2)) const queryFn = vi.fn((id) => { return Promise.resolve({ response: { id } }) }) const userIdAtom = atom(async () => { return await fn() }) const userAtom = atomWithQuery((get) => { const userId = get(unwrap(userIdAtom)) return { queryKey: ['userId', userId], queryFn: async ({ queryKey: [, id] }) => { const res = await queryFn(id) return res }, enabled: !!userId, } }) const User = () => { const [userData] = useAtom(userAtom) const { data, isPending, isError } = userData if (isPending) return <>loading if (isError) return <>errored return ( <>
id: {data.response.id}
) } const { findByText } = render( ) await findByText('loading') await findByText('id: 2') expect(queryFn).toHaveBeenCalledTimes(1) }) it('query refetch', async () => { let count = 0 const mockFetch = vi.fn((response: { count: number }) => ({ response, })) let resolve = () => {} const countAtom = atomWithQuery(() => ({ queryKey: ['test3'], queryFn: async () => { await new Promise((r) => (resolve = r)) const response = mockFetch({ count }) ++count return response }, })) const Counter = () => { const [{ data, isPending, isError, refetch }] = useAtom(countAtom) if (isPending) { return <>loading } if (isError) { return <>errored } return ( <>
count: {data?.response.count}
) } const { findByText, getByText } = render( ) await findByText('loading') resolve() await findByText('count: 0') expect(mockFetch).toBeCalledTimes(1) fireEvent.click(getByText('refetch')) await expect(() => findByText('loading')).rejects.toThrow() //refetch implementation in tanstack doesn't trigger loading state resolve() await findByText('count: 1') expect(mockFetch).toBeCalledTimes(2) }) it('query with enabled', async () => { const slugAtom = atom(null) const mockFetch = vi.fn((response) => ({ response })) let resolve = () => {} const slugQueryAtom = atomWithQuery((get) => { const slug = get(slugAtom) return { enabled: !!slug, queryKey: ['disabled_until_value', slug], queryFn: async () => { await new Promise((r) => (resolve = r)) return mockFetch({ slug: `hello-${slug}` }) }, } }) const Slug = () => { const [slugQueryData] = useAtom(slugQueryAtom) const { data, isPending, isError, status, fetchStatus } = slugQueryData //ref: https://tanstack.com/query/v4/docs/react/guides/dependent-queries if (status === 'pending' && fetchStatus === 'idle') { return
not enabled
} if (isPending) { return <>loading } if (isError) { return <>errored } return
slug: {data.response.slug}
} const Parent = () => { const [, setSlug] = useAtom(slugAtom) return (
) } const { getByText, findByText } = render( ) await findByText('not enabled') expect(mockFetch).toHaveBeenCalledTimes(0) fireEvent.click(getByText('set slug')) await findByText('loading') resolve() await findByText('slug: hello-world') expect(mockFetch).toHaveBeenCalledTimes(1) }) it('query with enabled 2', async () => { const mockFetch = vi.fn((response) => ({ response })) const enabledAtom = atom(true) const slugAtom = atom('first') let resolve = () => {} const slugQueryAtom = atomWithQuery((get: Getter) => { const slug = get(slugAtom) const enabled = get(enabledAtom) return { enabled: enabled, queryKey: ['enabled_toggle'], queryFn: async () => { await new Promise((r) => (resolve = r)) return mockFetch({ slug: `hello-${slug}` }) }, } }) const Slug = () => { const [slugQueryAtomData] = useAtom(slugQueryAtom) const { data, isError, isPending, status, fetchStatus } = slugQueryAtomData if (status === 'pending' && fetchStatus === 'idle') { return
not enabled
} if (isPending) { return <>loading } if (isError) { return <>errored } return
slug: {data.response.slug}
} const Parent = () => { const [, setSlug] = useAtom(slugAtom) const [, setEnabled] = useAtom(enabledAtom) return (
) } const { getByText, findByText } = render( ) await findByText('loading') resolve() await findByText('slug: hello-first') expect(mockFetch).toHaveBeenCalledTimes(1) fireEvent.click(getByText('set disabled')) fireEvent.click(getByText('set slug')) await findByText('slug: hello-first') expect(mockFetch).toHaveBeenCalledTimes(1) fireEvent.click(getByText('set enabled')) await expect(() => findByText('loading')).rejects.toThrow() //refetch implementation in tanstack doesn't trigger loading state resolve() await findByText('slug: hello-world') expect(mockFetch).toHaveBeenCalledTimes(2) }) it('query with enabled (#500)', async () => { const enabledAtom = atom(true) let resolve = () => {} const countAtom = atomWithQuery((get) => { const enabled = get(enabledAtom) return { enabled, queryKey: ['count_500_issue'], queryFn: async () => { await new Promise((r) => (resolve = r)) return { response: { count: 1 } } }, } }) const Counter = () => { const [countData] = useAtom(countAtom) const { data, isPending, isError } = countData if (isPending) { return <>loading } if (isError) { return <>errored } return
count: {data.response.count}
} const Parent = () => { const [showChildren, setShowChildren] = useState(true) const [, setEnabled] = useAtom(enabledAtom) return (
{showChildren ? :
hidden
}
) } const { getByText, findByText } = render( ) await findByText('loading') resolve() await findByText('count: 1') fireEvent.click(getByText('toggle')) resolve() await findByText('hidden') fireEvent.click(getByText('toggle')) resolve() await findByText('count: 1') }) it('query with initialData test', async () => { const mockFetch = vi.fn((response: { count: number }) => ({ response, })) let resolve = () => {} const countAtom = atomWithQuery(() => ({ queryKey: ['initialData_count1'], queryFn: async () => { await new Promise((r) => (resolve = r)) return mockFetch({ count: 10 }) }, initialData: { response: { count: 0 } }, })) const Counter = () => { const [ { data: { response: { count }, }, }, ] = useAtom(countAtom) return ( <>
count: {count}
) } const { findByText } = render( ) // NOTE: the atom is never loading await expect(() => findByText('loading')).rejects.toThrow() await findByText('count: 0') resolve() await findByText('count: 10') expect(mockFetch).toHaveBeenCalledTimes(1) }) it('query dependency test', async () => { const baseCountAtom = atom(0) const incrementAtom = atom(null, (_get, set) => set(baseCountAtom, (c) => c + 1) ) let resolve = () => {} const countAtom = atomWithQuery((get) => ({ queryKey: ['count_with_dependency', get(baseCountAtom)], queryFn: async () => { await new Promise((r) => (resolve = r)) return { response: { count: get(baseCountAtom) } } }, })) const Counter = () => { const [countData] = useAtom(countAtom) const { data, isPending, isError } = countData if (isPending) { return <>loading } if (isError) { return <>errored } return ( <>
count: {data.response.count}
) } const Controls = () => { const [, increment] = useAtom(incrementAtom) return } const { getByText, findByText } = render( ) await findByText('loading') resolve() await findByText('count: 0') fireEvent.click(getByText('increment')) await findByText('loading') resolve() await findByText('count: 1') }) it('query expected QueryCache test', async () => { const queryClient = new QueryClient() let resolve = () => {} const countAtom = atomWithQuery( () => ({ queryKey: ['count6'], queryFn: async () => { await new Promise((r) => (resolve = r)) return { response: { count: 0 } } }, }), () => queryClient ) const Counter = () => { const [countData] = useAtom(countAtom) const { data, isPending, isError } = countData if (isPending) { return <>loading } if (isError) { return <>errored } return ( <>
count: {data.response.count}
) } const { findByText } = render( ) await findByText('loading') resolve() await findByText('count: 0') expect(queryClient.getQueryCache().getAll().length).toBe(1) }) describe('error handling', () => { it('can catch error in error boundary', async () => { let resolve = () => {} const countAtom = atomWithQuery(() => ({ queryKey: ['catch'], retry: false, queryFn: async (): Promise<{ response: { count: number } }> => { await new Promise((r) => (resolve = r)) throw new Error('fetch error') }, throwOnError: true, })) const Counter = () => { const [countData] = useAtom(countAtom) if ('data' in countData) { if (countData.isPending) { return <>loading } return
count: {countData.data?.response.count}
} return null } const { findByText } = render( errored}> ) await findByText('loading') resolve() await findByText('errored') }) it('can recover from error', async () => { let count = -1 let willThrowError = false let resolve = () => {} const countAtom = atomWithQuery(() => ({ queryKey: ['error test', 'count2'], retry: false, queryFn: async () => { willThrowError = !willThrowError ++count await new Promise((r) => (resolve = r)) if (willThrowError) { throw new Error('fetch error') } return { response: { count } } }, throwOnError: true, })) const Counter = () => { const [countData] = useAtom(countAtom) if (countData.isFetching) return <>loading return ( <>
count: {countData.data?.response.count}
) } const App = () => { return ( { return ( <>

errored

) }}>
) } const { findByText, getByText } = render( <> ) await findByText('loading') resolve() await findByText('errored') fireEvent.click(getByText('retry')) await findByText('loading') resolve() await findByText('count: 1') fireEvent.click(getByText('refetch')) await findByText('loading') resolve() await findByText('errored') fireEvent.click(getByText('retry')) await findByText('loading') resolve() await findByText('count: 3') }) }) // Test for bug described here: // https://github.com/jotaijs/jotai-tanstack-query/issues/34 // Note: If error handling tests run after this test, they are failing. Not sure why. it('renews the result when the query changes and a non stale cache is available', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000 } }, }) queryClient.setQueryData(['currentCount', 2], 2) const currentCountAtom = atom(1) const countAtom = atomWithQuery( (get) => { const currentCount = get(currentCountAtom) return { queryKey: ['currentCount', currentCount], queryFn: () => currentCount, } }, () => queryClient ) const Counter = () => { const setCurrentCount = useSetAtom(currentCountAtom) const [countData] = useAtom(countAtom) const { data, isPending, isError } = countData if (isPending) { return <>loading } if (isError) { return <>errored } return ( <>
count: {data}
) } const { findByText } = render( ) await findByText('loading') await findByText('count: 1') fireEvent.click(await findByText('Set count to 2')) await expect(() => findByText('loading')).rejects.toThrow() await findByText('count: 2') }) // https://github.com/jotaijs/jotai-tanstack-query/pull/40 it(`ensure that setQueryData for an inactive query updates its atom state`, async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnMount: false, }, }, }) const extraKey = 'uniqueKey' const pageAtom = atom(1) const queryFn = vi.fn(() => { return Promise.resolve('John Doe') }) const userAtom = atomWithQuery( () => { return { queryKey: [extraKey], queryFn: async () => { const name = await queryFn() return { response: { name } } }, } }, () => queryClient ) const User = () => { const [{ data, isPending }] = useAtom(userAtom) if (isPending) return <>loading return <>Name: {data?.response.name} } const Controls = () => { const [, setPage] = useAtom(pageAtom) return ( <> ) } const App = () => { const [page] = useAtom(pageAtom) return ( <> {page === 1 && } ) } const { findByText } = render( ) await findByText('loading') await findByText('Name: John Doe') fireEvent.click(await findByText('Set page 2')) queryClient.setQueryData([extraKey], { response: { name: 'Alex Smith' } }) fireEvent.click(await findByText('Set page 1')) await expect(() => findByText('loading')).rejects.toThrow() await findByText('Name: Alex Smith') }) ================================================ FILE: __tests__/atomWithSuspenseInfiniteQuery.spec.tsx ================================================ import React, { StrictMode, Suspense } from 'react' import { fireEvent, render } from '@testing-library/react' import { useAtom } from 'jotai' import { atomWithSuspenseInfiniteQuery } from '../src' it('suspense basic, suspends', async () => { let resolve = () => {} type DataResponse = { response: { count: number } } const countAtom = atomWithSuspenseInfiniteQuery(() => ({ getNextPageParam: (lastPage) => { const nextPageParam = lastPage.response.count + 1 return nextPageParam }, initialPageParam: 1, queryKey: ['test1'], queryFn: async ({ pageParam }) => { await new Promise((r) => (resolve = r)) return { response: { count: pageParam as number } } }, })) const Counter = () => { const [countData] = useAtom(countAtom) const { data, fetchNextPage } = countData return ( <>
count: {data?.pages?.[data.pages.length - 1]?.response.count}
) } const { findByText, getByText } = render( ) await findByText('loading') resolve() await findByText('count: 1') fireEvent.click(getByText('fetchNextPage')) await expect(() => findByText('loading')).rejects.toThrow() //refetch implementation in tanstack doesn't trigger loading state resolve() await findByText('count: 2') }) ================================================ FILE: __tests__/atomWithSuspenseQuery.spec.tsx ================================================ import React, { StrictMode, Suspense } from 'react' import { QueryClient } from '@tanstack/query-core' import { fireEvent, render } from '@testing-library/react' import { Provider, atom, useAtom, useSetAtom } from 'jotai' import { ErrorBoundary } from 'react-error-boundary' import { vi } from 'vitest' import { atomWithSuspenseQuery } from '../src' let originalConsoleError: typeof console.error beforeEach(() => { originalConsoleError = console.error console.error = vi.fn() }) afterEach(() => { console.error = originalConsoleError }) it('suspense basic, suspends', async () => { let resolve = () => {} const countAtom = atomWithSuspenseQuery(() => ({ queryKey: ['test1', 'suspends'], queryFn: async () => { await new Promise((r) => (resolve = r)) return { response: { count: 0 } } }, })) const Counter = () => { const [{ data }] = useAtom(countAtom) return ( <>
count: {data.response.count}
) } const { findByText } = render( ) await findByText('Loading...') resolve() await findByText('count: 0') }) it('query refetch', async () => { const mockFetch = vi.fn((response: { message: string }) => ({ response, })) let resolve = () => {} const greetingAtom = atomWithSuspenseQuery(() => ({ queryKey: ['test3'], queryFn: async () => { await new Promise((r) => (resolve = r)) const response = mockFetch({ message: 'helloWorld' }) return response }, })) const Greeting = () => { const [{ data, refetch }] = useAtom(greetingAtom) return ( <>
message: {data?.response.message}
) } const { findByText, getByText } = render( ) await findByText('loading') resolve() await findByText('message: helloWorld') expect(mockFetch).toBeCalledTimes(1) fireEvent.click(getByText('refetch')) await expect(() => findByText('loading')).rejects.toThrow() //refetch implementation in tanstack doesn't trigger loading state resolve() await findByText('message: helloWorld') expect(mockFetch).toBeCalledTimes(2) //this ensures we are actually running the query function again }) describe('intialData test', () => { it('query with initialData test', async () => { const mockFetch = vi.fn((response) => ({ response })) let resolve = () => {} const countAtom = atomWithSuspenseQuery(() => ({ queryKey: ['initialData_count1'], queryFn: async () => { await new Promise((r) => (resolve = r)) return mockFetch({ count: 10 }) }, initialData: { response: { count: 0 } }, staleTime: 0, })) const Counter = () => { const [countData] = useAtom(countAtom) const { data, isError } = countData if (isError) { return <>errored } const count = data?.response.count return ( <>
count: {count}
) } const { findByText } = render( ) // NOTE: the atom is never loading await expect(() => findByText('loading')).rejects.toThrow() await findByText('count: 0') resolve() await findByText('count: 10') expect(mockFetch).toHaveBeenCalledTimes(1) }) it('query with initialData test with dependency', async () => { const mockFetch = vi.fn((response) => ({ response })) let resolve = () => {} const numberAtom = atom(10) const countAtom = atomWithSuspenseQuery((get) => ({ queryKey: ['initialData_count1', get(numberAtom)], queryFn: async ({ queryKey: [, myNumber] }) => { await new Promise((r) => (resolve = r)) return mockFetch({ count: myNumber }) }, initialData: { response: { count: 0 } }, staleTime: 0, })) const Counter = () => { const [countData] = useAtom(countAtom) const { data, isError } = countData if (isError) { return <>errored } const count = data?.response.count return ( <>
count: {count}
) } const Increment = () => { const setNumber = useSetAtom(numberAtom) return } const { findByText } = render( ) // NOTE: the atom is never loading await expect(() => findByText('loading')).rejects.toThrow() await findByText('count: 0') resolve() await findByText('count: 10') expect(mockFetch).toHaveBeenCalledTimes(1) await findByText('increment') fireEvent.click(await findByText('increment')) await findByText('count: 0') resolve() await findByText('count: 11') expect(mockFetch).toHaveBeenCalledTimes(2) }) }) it('query dependency test', async () => { const baseCountAtom = atom(0) const incrementAtom = atom(null, (_get, set) => set(baseCountAtom, (c) => c + 1) ) let resolve = () => {} const countAtom = atomWithSuspenseQuery((get) => ({ queryKey: ['count_with_dependency', get(baseCountAtom)], queryFn: async () => { await new Promise((r) => (resolve = r)) return { response: { count: get(baseCountAtom) } } }, })) const Counter = () => { const [{ data }] = useAtom(countAtom) return ( <>
count: {data.response.count}
) } const Controls = () => { const [, increment] = useAtom(incrementAtom) return } const { getByText, findByText } = render( ) await findByText('loading') resolve() await findByText('count: 0') fireEvent.click(getByText('increment')) await findByText('loading') resolve() await findByText('count: 1') }) it('query expected QueryCache test', async () => { const queryClient = new QueryClient() let resolve = () => {} const countAtom = atomWithSuspenseQuery( () => ({ queryKey: ['count6'], queryFn: async () => { await new Promise((r) => (resolve = r)) return { response: { count: 0 } } }, }), () => queryClient ) const Counter = () => { const [{ data }] = useAtom(countAtom) return ( <>
count: {data.response.count}
) } const { findByText } = render( ) await findByText('loading') resolve() await findByText('count: 0') expect(queryClient.getQueryCache().getAll().length).toBe(1) }) describe('error handling', () => { it('can catch error in error boundary', async () => { let resolve = () => {} const countAtom = atomWithSuspenseQuery(() => ({ queryKey: ['catch'], retry: false, queryFn: async (): Promise<{ response: { count: number } }> => { await new Promise((r) => (resolve = r)) throw new Error('fetch error') }, })) const Counter = () => { const [{ data }] = useAtom(countAtom) return
count: {data.response.count}
} const { findByText } = render( errored}> ) await findByText('loading') resolve() await findByText('errored') }) it('can recover from error', async () => { let count = -1 let willThrowError = false let resolve = () => {} const countAtom = atomWithSuspenseQuery(() => ({ queryKey: ['error test', 'count2'], retry: false, queryFn: async () => { willThrowError = !willThrowError ++count await new Promise((r) => (resolve = r)) if (willThrowError) { throw new Error('fetch error') } return { response: { count } } }, throwOnError: true, })) const Counter = () => { const [{ data, refetch, error, isFetching }] = useAtom(countAtom) if (error && !isFetching) { throw error } return ( <>
count: {data?.response.count}
) } const FallbackComponent: React.FC<{ resetErrorBoundary: () => void }> = ({ resetErrorBoundary, }) => { const refresh = useSetAtom(countAtom) return ( <>

errored

) } const App = () => { return ( ) } const { findByText, getByText } = render() await findByText('loading') resolve() await findByText('errored') fireEvent.click(getByText('retry')) await findByText('loading') resolve() await findByText('count: 1') fireEvent.click(getByText('refetch')) resolve() await findByText('errored') fireEvent.click(getByText('retry')) resolve() await findByText('count: 3') }) }) it('renews the result when the query changes and a non stale cache is available', async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000 } }, }) queryClient.setQueryData([2], 2) const currentCountAtom = atom(1) const countAtom = atomWithSuspenseQuery( (get) => { const currentCount = get(currentCountAtom) return { queryKey: [currentCount], queryFn: () => currentCount, } }, () => queryClient ) const Counter = () => { const setCurrentCount = useSetAtom(currentCountAtom) const [{ data }] = useAtom(countAtom) return ( <>
count: {data}
) } const { findByText } = render( ) await findByText('loading') await findByText('count: 1') fireEvent.click(await findByText('Set count to 2')) await expect(() => findByText('loading')).rejects.toThrow() await findByText('count: 2') }) it('on reset, throws suspense', async () => { const queryClient = new QueryClient() let count = 0 let resolve = () => {} const countAtom = atomWithSuspenseQuery( () => ({ queryKey: ['test1', count], queryFn: async () => { await new Promise((r) => (resolve = r)) count++ return { response: { count } } }, }), () => queryClient ) const Counter = () => { const [{ data }] = useAtom(countAtom) return ( <>
count: {data.response.count}
) } const { findByText, getByText } = render( ) await findByText('loading') resolve() await findByText('count: 1') fireEvent.click(getByText('reset')) await findByText('loading') resolve() await findByText('count: 2') }) // https://github.com/jotaijs/jotai-tanstack-query/pull/40 it(`ensure that setQueryData for an inactive query updates its atom state`, async () => { const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnMount: false, }, }, }) const extraKey = 'uniqueKey' const pageAtom = atom(1) const queryFn = vi.fn(() => { return Promise.resolve('John Doe') }) const userAtom = atomWithSuspenseQuery( () => { return { queryKey: [extraKey], queryFn: async () => { const name = await queryFn() return { response: { name } } }, } }, () => queryClient ) const User = () => { const [ { data: { response: { name }, }, }, ] = useAtom(userAtom) return <>Name: {name} } const Controls = () => { const [, setPage] = useAtom(pageAtom) return ( <> ) } const App = () => { const [page] = useAtom(pageAtom) return ( <> {page === 1 && } ) } const { findByText } = render( ) await findByText('loading') await findByText('Name: John Doe') fireEvent.click(await findByText('Set page 2')) queryClient.setQueryData([extraKey], { response: { name: 'Alex Smith' } }) fireEvent.click(await findByText('Set page 1')) await expect(() => findByText('loading')).rejects.toThrow() await findByText('Name: Alex Smith') }) ================================================ FILE: eslint.config.js ================================================ import js from '@eslint/js' import typescript from '@typescript-eslint/eslint-plugin' import typescriptParser from '@typescript-eslint/parser' import importPlugin from 'eslint-plugin-import' import prettier from 'eslint-plugin-prettier' import react from 'eslint-plugin-react' import reactHooks from 'eslint-plugin-react-hooks' export default [ js.configs.recommended, { files: ['**/*.{js,jsx,ts,tsx}'], languageOptions: { parser: typescriptParser, parserOptions: { ecmaVersion: 2018, sourceType: 'module', ecmaFeatures: { jsx: true, }, }, globals: { // Browser globals window: 'readonly', document: 'readonly', console: 'readonly', fetch: 'readonly', URL: 'readonly', URLSearchParams: 'readonly', // Node.js globals process: 'readonly', Buffer: 'readonly', __dirname: 'readonly', __filename: 'readonly', global: 'readonly', // ES6 globals Promise: 'readonly', Symbol: 'readonly', Map: 'readonly', Set: 'readonly', WeakMap: 'readonly', WeakSet: 'readonly', }, }, plugins: { '@typescript-eslint': typescript, react, 'react-hooks': reactHooks, import: importPlugin, prettier, }, rules: { // ESLint recommended rules are included via js.configs.recommended // Custom rules eqeqeq: 'error', 'no-var': 'error', 'prefer-const': 'error', curly: ['warn', 'multi-line', 'consistent'], 'no-console': 'off', // Import rules 'import/no-unresolved': ['error', { commonjs: true, amd: true }], 'import/export': 'error', 'import/no-duplicates': ['error'], 'import/order': [ 'error', { alphabetize: { order: 'asc', caseInsensitive: true }, groups: [ 'builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', ], 'newlines-between': 'never', pathGroups: [ { pattern: 'react', group: 'builtin', position: 'before', }, ], pathGroupsExcludedImportTypes: ['builtin'], }, ], // TypeScript rules '@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/no-unused-vars': [ 'warn', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }, ], '@typescript-eslint/no-use-before-define': 'off', '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-redeclare': 'off', 'no-unused-vars': 'off', // Use TypeScript version instead 'no-redeclare': 'off', // Use TypeScript version instead // React rules 'react/jsx-uses-react': 'off', 'react/react-in-jsx-scope': 'off', // React hooks rules 'react-hooks/rules-of-hooks': 'error', 'react-hooks/exhaustive-deps': 'warn', // Sort imports 'sort-imports': [ 'error', { ignoreDeclarationSort: true, }, ], }, settings: { react: { version: 'detect', }, 'import/extensions': ['.js', '.jsx', '.ts', '.tsx'], 'import/parsers': { '@typescript-eslint/parser': ['.js', '.jsx', '.ts', '.tsx'], }, 'import/resolver': { node: { extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], paths: ['src'], }, alias: { extensions: ['.js', '.jsx', '.ts', '.tsx', '.json'], map: [['jotai-tanstack-query', './src/index.ts']], }, }, }, }, { files: ['src/**/*.{ts,tsx}'], languageOptions: { parserOptions: { project: './tsconfig.json', }, }, }, { files: ['tests/**/*.tsx', '__tests__/**/*'], languageOptions: { globals: { describe: 'readonly', it: 'readonly', test: 'readonly', expect: 'readonly', beforeEach: 'readonly', afterEach: 'readonly', beforeAll: 'readonly', afterAll: 'readonly', vi: 'readonly', }, }, }, { files: ['./*.js'], rules: { '@typescript-eslint/no-var-requires': 'off', }, }, { files: ['examples/**/*.{ts,tsx}'], rules: { 'import/no-unresolved': 'off', }, }, { ignores: ['dist/**', 'src/vendor/**', 'node_modules/**', '**/.next/**'], }, ] ================================================ FILE: examples/01_query/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/01_query/package.json ================================================ { "name": "jotai-tanstack-query-example-query", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/01_query/src/App.tsx ================================================ import { Getter } from 'jotai' import { useAtom } from 'jotai/react' import { atom } from 'jotai/vanilla' import { atomWithQuery } from 'jotai-tanstack-query' const idAtom = atom(1) const userAtom = atomWithQuery((get: Getter) => ({ queryKey: ['users', get(idAtom).toString()], 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
Loading...
if (isError) return
Error
return
{JSON.stringify(data)}
} const Controls = () => { const [id, setId] = useAtom(idAtom) return (
ID: {id}{' '} {' '}
) } const App = () => ( <> ) export default App ================================================ FILE: examples/01_query/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/01_query/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/01_query/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/01_query/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/02_suspense/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/02_suspense/package.json ================================================ { "name": "jotai-tanstack-query-example-suspense", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/02_suspense/src/App.tsx ================================================ import { Suspense } from 'react' import { useAtom } from 'jotai/react' import { atom } from 'jotai/vanilla' 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
{JSON.stringify(data)}
} const Controls = () => { const [id, setId] = useAtom(idAtom) return (
ID: {id}{' '} {' '}
) } const App = () => ( <> ) export default App ================================================ FILE: examples/02_suspense/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/02_suspense/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/02_suspense/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/02_suspense/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/03_infinite/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/03_infinite/package.json ================================================ { "name": "jotai-tanstack-query-example-infinite", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/03_infinite/src/App.tsx ================================================ import { 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: (...args) => { return args[2] + 1 }, initialPageParam: 0, })) const Posts = () => { const [{ data, fetchNextPage, isPending, isError, isFetching }] = useAtom(postsAtom) if (isFetching || isPending) return
Loading...
if (isError) return
Error
return ( <> {data?.pages.map((page, index) => (
{page.map((post: any) => (
{post.title}
))}
))} ) } const App = () => ( <> ) export default App ================================================ FILE: examples/03_infinite/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/03_infinite/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/03_infinite/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/03_infinite/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/04_infinite_suspense/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/04_infinite_suspense/package.json ================================================ { "name": "jotai-tanstack-query-example-infinite-suspense", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/04_infinite_suspense/src/App.tsx ================================================ import { Suspense } from 'react' import { useAtom } from 'jotai/react' import { atomWithSuspenseInfiniteQuery } from 'jotai-tanstack-query' const postsAtom = atomWithSuspenseInfiniteQuery(() => ({ initialPageParam: 1, queryKey: ['posts'], queryFn: async ({ pageParam = 1 }) => { const res = await fetch( `https://jsonplaceholder.typicode.com/posts/${pageParam}` ) const data: { id: number; title: string } = await res.json() return data }, getNextPageParam: (lastPage) => lastPage.id + 1, })) const Posts = () => { const [{ data, fetchNextPage }] = useAtom(postsAtom) return (
    {data.pages.map((item) => (
  • {item.title}
  • ))}
) } const App = () => ( <> ) export default App ================================================ FILE: examples/04_infinite_suspense/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/04_infinite_suspense/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/04_infinite_suspense/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/04_infinite_suspense/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/05_mutation/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/05_mutation/package.json ================================================ { "name": "jotai-tanstack-query-example-mutation", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/05_mutation/src/App.tsx ================================================ import React from 'react' 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 }, })) // Mutation with optimistic updates const postAtom = atomWithMutation( (get) => { const queryClient = get(queryClientAtom) return { mutationKey: ['addPost'], mutationFn: async ({ title }: NewPost) => { // Randomly fail for testing error handling (30% failure rate) if (Math.random() < 0.3) { throw new Error('Randomly simulated API error') } 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(['posts']) // Optimistically update to the new value queryClient.setQueryData(['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 ) => { console.debug('onError', onMutateResult) 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
Loading posts...
return (

Posts:

    {posts?.map((post: Post) => (
  • {post.title}
  • ))}
) } const AddPost = () => { const [{ mutate, isPending, status }] = useAtom(postAtom) const [title, setTitle] = React.useState('') return (
setTitle(e.target.value)} placeholder="Enter post title" />
Status: {status}
) } const App = () => ( <>

atomWithMutation with optimistic updates

) export default App ================================================ FILE: examples/05_mutation/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/05_mutation/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/05_mutation/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/05_mutation/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/06_refetch/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/06_refetch/package.json ================================================ { "name": "jotai-tanstack-query-example-refetch", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/06_refetch/src/App.tsx ================================================ import { useAtom } from 'jotai/react' import { atom } from 'jotai/vanilla' import { atomWithQuery } from 'jotai-tanstack-query' import { ErrorBoundary } from 'react-error-boundary' import type { FallbackProps } from 'react-error-boundary' 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, refetch, isPending }] = useAtom(userAtom) if (isPending) return
Loading...
return (
  • ID: {data.id}
  • Username: {data.username}
  • Email: {data.email}
) } const Controls = () => { const [id, setId] = useAtom(idAtom) return (
ID: {id}{' '} {' '}
) } const Fallback = ({ error, resetErrorBoundary }: FallbackProps) => { return (

Something went wrong:

{error.message}
) } const App = () => ( ) export default App ================================================ FILE: examples/06_refetch/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/06_refetch/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/06_refetch/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/06_refetch/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/07_queries/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/07_queries/package.json ================================================ { "name": "jotai-tanstack-query-example-queries", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/07_queries/src/App.tsx ================================================ import { Atom, atom } from 'jotai' import { useAtom } from 'jotai/react' import { type AtomWithQueryResult, atomWithQueries } from 'jotai-tanstack-query' const userIdsAtom = atom([1, 2, 3]) interface User { id: number name: string email: string } 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() }, })), }) }) const UsersData = () => { const [userQueryAtoms] = useAtom(userQueryAtomsAtom) return (

Users:

{userQueryAtoms.map((queryAtom, index) => ( ))}
) } const combinedUsersDataAtom = atom((get) => { const userIds = get(userIdsAtom) return atomWithQueries<{ data: User[] isPending: boolean isError: boolean }>({ 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) => { return { data: results.map((result) => result.data as User), isPending: results.some((result) => result.isPending), isError: results.some((result) => result.isError), } }, }) }) const CombinedUsersData = () => { const [combinedUsersDataAtomValue] = useAtom(combinedUsersDataAtom) const [{ data, isPending, isError }] = useAtom(combinedUsersDataAtomValue) return (

Users: (combinedQueries)

{isPending &&
Loading...
} {isError &&
Error
} {!isPending && !isError && (
{data.map((user) => ( ))}
)}
) } const Data = ({ queryAtom, }: { queryAtom: Atom> }) => { const [{ data, isPending, isError }] = useAtom(queryAtom) if (isPending) return
Loading...
if (isError) return
Error
if (!data) return null return } const UserDisplay = ({ user }: { user: User }) => { return (
ID: {user.id}
{user.name} - {user.email}
) } const Controls = () => { const [userIds, setUserIds] = useAtom(userIdsAtom) return (
User IDs: {userIds.join(', ')}
) } const App = () => { return (

) } export default App ================================================ FILE: examples/07_queries/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/07_queries/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/07_queries/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/07_queries/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/08_query_client_atom_provider/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/08_query_client_atom_provider/package.json ================================================ { "name": "jotai-tanstack-query-example-query-client-atom-provider", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "@tanstack/react-query": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/08_query_client_atom_provider/src/App.tsx ================================================ import { QueryClient, useQuery } from '@tanstack/react-query' import { useAtom } from 'jotai/react' import { atom } from 'jotai/vanilla' import { atomWithQuery } from 'jotai-tanstack-query' import { QueryClientAtomProvider } from 'jotai-tanstack-query/react' const queryClient = new QueryClient() const idAtom = atom(1) const userAtom = atomWithQuery( (get) => ({ queryKey: ['users', get(idAtom)], }), () => queryClient ) const UserDataRawFetch = () => { const [id] = useAtom(idAtom) const { data, isPending, isError } = useQuery({ queryKey: ['users', id], queryFn: async ({ queryKey: [, id] }) => { const res = await fetch( `https://jsonplaceholder.typicode.com/users/${id}` ) return res.json() }, }) if (isPending) return
Loading...
if (isError) return
Error
return ( <>

Tanstack Query

{JSON.stringify(data)}
) } const UserData = () => { const [{ data, isPending, isError }] = useAtom(userAtom) if (isPending) return
Loading...
if (isError) return
Error
return ( <>

Jotai-Tanstack-Query

{JSON.stringify(data)}
) } const Controls = () => { const [id, setId] = useAtom(idAtom) return ( <>
ID: {id}{' '} {' '}
) } const App = () => ( <> ) export default App ================================================ FILE: examples/08_query_client_atom_provider/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/08_query_client_atom_provider/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/08_query_client_atom_provider/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/08_query_client_atom_provider/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/09_error_boundary/index.html ================================================ jotai-tanstack-query example
================================================ FILE: examples/09_error_boundary/package.json ================================================ { "name": "jotai-tanstack-query-example-error-boundary", "version": "0.1.0", "private": true, "dependencies": { "@tanstack/query-core": "latest", "jotai": "latest", "jotai-tanstack-query": "*", "react": "latest", "react-dom": "latest", "react-error-boundary": "latest" }, "devDependencies": { "@types/react": "latest", "@types/react-dom": "latest", "@vitejs/plugin-react": "latest", "typescript": "latest", "vite": "latest" }, "scripts": { "dev": "vite", "build": "vite build", "preview": "vite preview" } } ================================================ FILE: examples/09_error_boundary/src/App.tsx ================================================ import { Suspense } from 'react' import { atom, useAtom, useSetAtom } from 'jotai' import { atomWithSuspenseQuery } from 'jotai-tanstack-query' import { ErrorBoundary, type FallbackProps } from 'react-error-boundary' const idAtom = atom(1) const userAtom = atomWithSuspenseQuery((get) => ({ queryKey: ['user', get(idAtom)], queryFn: async ({ queryKey: [, id] }) => { const randomNumber = Math.floor(Math.random() * 10) if (randomNumber % 3 === 0) { await fetch(`https://jsonplaceholder.typicode.com/users/error`) return await Promise.reject('fetch failed') } const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`) return res.json() }, retry: false, })) const UserData = () => { const [{ data }] = useAtom(userAtom) return ( <> ) } interface User { id: number name: string email: string } const UserDisplay = ({ user }: { user: User }) => { return (
ID: {user.id}
{user.name} - {user.email}
) } const Controls = () => { const [id, setId] = useAtom(idAtom) return ( <>
ID: {id}{' '} {' '}
) } const Fallback = ({ error, resetErrorBoundary }: FallbackProps) => { const reset = useSetAtom(userAtom) const retry = () => { reset() resetErrorBoundary() } return (

Something went wrong:

{error.message}
) } const App = () => { return ( ) } export default App ================================================ FILE: examples/09_error_boundary/src/index.tsx ================================================ import React from 'react' import { createRoot } from 'react-dom/client' import App from './App' const ele = document.getElementById('app') if (ele) { createRoot(ele).render(React.createElement(App)) } ================================================ FILE: examples/09_error_boundary/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: examples/09_error_boundary/tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: examples/09_error_boundary/vite.config.ts ================================================ import react from '@vitejs/plugin-react' import { defineConfig } from 'vite' export default defineConfig({ plugins: [react()], }) ================================================ FILE: examples/11_nextjs_app_router/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.* .yarn/* !.yarn/patches !.yarn/plugins !.yarn/releases !.yarn/versions # testing /coverage # next.js /.next/ /out/ # production /build # misc .DS_Store *.pem # debug npm-debug.log* yarn-debug.log* yarn-error.log* .pnpm-debug.log* # env files (can opt-in for committing if needed) .env* # vercel .vercel # typescript *.tsbuildinfo next-env.d.ts ================================================ FILE: examples/11_nextjs_app_router/README.md ================================================ This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started First, run the development server: ```bash npm run dev # or yarn dev # or pnpm dev # or bun dev ``` Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## Learn More To learn more about Next.js, take a look at the following resources: - [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. - [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## Deploy on Vercel The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details. ================================================ FILE: examples/11_nextjs_app_router/eslint.config.mjs ================================================ import { defineConfig, globalIgnores } from "eslint/config"; import nextVitals from "eslint-config-next/core-web-vitals"; import nextTs from "eslint-config-next/typescript"; const eslintConfig = defineConfig([ ...nextVitals, ...nextTs, // Override default ignores of eslint-config-next. globalIgnores([ // Default ignores of eslint-config-next: ".next/**", "out/**", "build/**", "next-env.d.ts", ]), ]); export default eslintConfig; ================================================ FILE: examples/11_nextjs_app_router/next.config.ts ================================================ import type { NextConfig } from "next"; const nextConfig: NextConfig = { /* config options here */ }; export default nextConfig; ================================================ FILE: examples/11_nextjs_app_router/package.json ================================================ { "name": "jotai-tanstack-query-example-nextjs-app-router", "version": "0.1.0", "private": true, "scripts": { "dev": "next dev", "build": "next build", "start": "next start", "lint": "eslint" }, "dependencies": { "@tanstack/react-query": "^5.90.10", "jotai": "^2.15.1", "jotai-tanstack-query": "*", "next": "~15.4.7", "react": "19.2.0", "react-dom": "19.2.0" }, "devDependencies": { "@tailwindcss/postcss": "^4", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "~15.4.7", "tailwindcss": "^4", "typescript": "^5" } } ================================================ FILE: examples/11_nextjs_app_router/postcss.config.mjs ================================================ const config = { plugins: { "@tailwindcss/postcss": {}, }, }; export default config; ================================================ FILE: examples/11_nextjs_app_router/src/app/api.ts ================================================ export async function getPost(postId: string) { console.debug('getPost called with postId:', postId) const res = await fetch( `https://jsonplaceholder.typicode.com/posts/${postId}` ) return res.json() } ================================================ FILE: examples/11_nextjs_app_router/src/app/layout.tsx ================================================ import type { ReactNode } from 'react' import Providers from './providers' export default function RootLayout({ children, }: Readonly<{ children: ReactNode }>) { return ( {children} ) } ================================================ FILE: examples/11_nextjs_app_router/src/app/page.tsx ================================================ import { redirect } from 'next/navigation' export default function Home() { redirect('/posts/1') } ================================================ FILE: examples/11_nextjs_app_router/src/app/posts/[postId]/_components/post.tsx ================================================ 'use client' import { useEffect } from 'react' import { useAtomValue, useSetAtom } from 'jotai' import Link from 'next/link' import { postIdAtom, postQueryAtom } from '../../../stores' export const Post = ({ postId }: { postId: string }) => { const setPostIdValue = useSetAtom(postIdAtom) useEffect(() => { setPostIdValue(postId) }, [postId, setPostIdValue]) const { data, isPending, isError, refetch } = useAtomValue(postQueryAtom) if (isPending) return
Loading...
if (isError) return
Error
return (
Back to posts
ID: {data?.id}

Title: {data?.title}

Body: {data?.body}
Next post - only server-side
) } ================================================ FILE: examples/11_nextjs_app_router/src/app/posts/[postId]/page.tsx ================================================ // app/posts/[postId]/page.tsx import { HydrationBoundary, QueryClient, dehydrate, } from '@tanstack/react-query' import { Post } from './_components/post' import { getPost } from '@/app/api' export default async function PostPage({ params, }: { params: Promise<{ postId: string }> }) { const { postId } = await params const queryClient = new QueryClient() await queryClient.prefetchQuery({ queryKey: ['posts', postId], queryFn: ({ queryKey: [, postId] }) => getPost(postId as string), }) return ( // Neat! Serialization is now as easy as passing props. // HydrationBoundary is a Client Component, so hydration will happen there. ) } ================================================ FILE: examples/11_nextjs_app_router/src/app/posts/page.tsx ================================================ import Link from 'next/link' export default function PostsPage() { return (
Posts
Post 1
Post 2
Post 3
Post 4
Post 5
Post 6
Post 7
Post 8
Post 9
Post 10
) } ================================================ FILE: examples/11_nextjs_app_router/src/app/providers.tsx ================================================ // In Next.js, this file would be called: app/providers.tsx 'use client' // Since QueryClientProvider relies on useContext under the hood, we have to put 'use client' on top import type { ReactNode } from 'react' import { QueryClient, QueryClientProvider, QueryClientProviderProps, isServer, } from '@tanstack/react-query' import { Provider } from 'jotai/react' import { useHydrateAtoms } from 'jotai/react/utils' import { queryClientAtom } from 'jotai-tanstack-query' const HydrateAtoms = ({ client, children }: QueryClientProviderProps) => { useHydrateAtoms(new Map([[queryClientAtom, client]])) return children } function makeQueryClient() { return new QueryClient({ defaultOptions: { queries: { // With SSR, we usually want to set some default staleTime // above 0 to avoid refetching immediately on the client staleTime: 60 * 1000, }, }, }) } let browserQueryClient: QueryClient | undefined = undefined function getQueryClient() { if (isServer) { // Server: always make a new query client return makeQueryClient() } else { // Browser: make a new query client if we don't already have one // This is very important, so we don't re-make a new client if React // suspends during the initial render. This may not be needed if we // have a suspense boundary BELOW the creation of the query client if (!browserQueryClient) browserQueryClient = makeQueryClient() return browserQueryClient } } export default function Providers({ children }: { children: ReactNode }) { // NOTE: Avoid useState when initializing the query client if you don't // have a suspense boundary between this and the code that may // suspend because React will throw away the client on the initial // render if it suspends and there is no boundary const queryClient = getQueryClient() return ( {children} ) } ================================================ FILE: examples/11_nextjs_app_router/src/app/stores.ts ================================================ import { atom } from 'jotai' import { atomWithQuery } from 'jotai-tanstack-query' import { getPost } from './api' export const postIdAtom = atom('1') export const postQueryAtom = atomWithQuery((get) => ({ queryKey: ['posts', get(postIdAtom)], queryFn: ({ queryKey: [, postId] }) => getPost(postId as string), staleTime: Infinity, refetchOnMount: false, refetchOnWindowFocus: false, })) ================================================ FILE: examples/11_nextjs_app_router/tsconfig.json ================================================ { "compilerOptions": { "target": "ES2017", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, "noEmit": true, "esModuleInterop": true, "module": "esnext", "moduleResolution": "bundler", "resolveJsonModule": true, "isolatedModules": true, "jsx": "preserve", "incremental": true, "plugins": [ { "name": "next" } ], "paths": { "@/*": ["./src/*"] } }, "include": [ "next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts" ], "exclude": ["node_modules", ".next"] } ================================================ FILE: package.json ================================================ { "name": "jotai-tanstack-query", "description": "👻🌺", "version": "0.11.0", "type": "module", "author": "Daishi Kato", "contributors": [ "Thaddeus Jiang", "Mohammad Bagher Abiat", "Kali Charan Reddy Jonna" ], "repository": { "type": "git", "url": "https://github.com/jotaijs/jotai-tanstack-query.git" }, "source": "./src/index.ts", "main": "./dist/index.js", "module": "./dist/index.mjs", "types": "./dist/index.d.ts", "exports": { "./package.json": "./package.json", ".": { "types": "./dist/index.d.ts", "module": "./dist/index.mjs", "import": "./dist/index.mjs", "default": "./dist/index.js" }, "./react": { "types": "./dist/react.d.ts", "module": "./dist/react.js", "import": "./dist/react.mjs", "default": "./dist/react.js" } }, "sideEffects": false, "files": [ "src", "dist" ], "scripts": { "compile": "bunchee --sourcemap", "test": "run-s eslint tsc-test vitest", "eslint": "eslint --ext .js,.ts,.tsx .", "vitest": "vitest run", "test:dev": "vitest", "tsc-test": "tsc --project . --noEmit", "examples:01_query": "pnpm --filter ./examples/01_query run dev", "examples:02_suspense": "pnpm --filter ./examples/02_suspense run dev", "examples:03_infinite": "pnpm --filter ./examples/03_infinite run dev", "examples:04_infinite_suspense": "pnpm --filter ./examples/04_infinite_suspense run dev", "examples:05_mutation": "pnpm --filter ./examples/05_mutation run dev", "examples:06_refetch": "pnpm --filter ./examples/06_refetch run dev", "examples:07_queries": "pnpm --filter ./examples/07_queries run dev", "examples:08_query_client_atom_provider": "pnpm --filter ./examples/08_query_client_atom_provider run dev", "examples:09_error_boundary": "pnpm --filter ./examples/09_error_boundary run dev", "examples:11_nextjs_app_router": "pnpm --filter ./examples/11_nextjs_app_router run dev" }, "keywords": [ "jotai", "react", "tanstack/query", "state management", "data fetching" ], "license": "MIT", "devDependencies": { "@eslint/js": "^9.32.0", "@tanstack/query-core": "^5.83.0", "@tanstack/react-query": "^5.83.0", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.0", "@types/jsdom": "^21.1.7", "@types/node": "^22.13.14", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@typescript-eslint/eslint-plugin": "^8.38.0", "@typescript-eslint/parser": "^8.38.0", "@vitest/ui": "^3.2.4", "bunchee": "^6.5.4", "eslint": "^9.32.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-alias": "^1.1.2", "eslint-plugin-import": "^2.31.0", "eslint-plugin-prettier": "^5.2.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^5.1.0", "jotai": "^2.4.2", "jsdom": "^25.0.1", "npm-run-all": "^4.1.5", "prettier": "^3.0.3", "react": "^18.2.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.11", "typescript": "^5.8.3", "vitest": "^3.2.4" }, "peerDependencies": { "@tanstack/query-core": "*", "@tanstack/react-query": "*", "jotai": ">=2.0.0", "react": "^18.0.0 || ^19.0.0" }, "peerDependenciesMeta": { "@tanstack/react-query": { "optional": true }, "react": { "optional": true } }, "packageManager": "pnpm@10.13.1" } ================================================ FILE: pnpm-workspace.yaml ================================================ linkWorkspacePackages: true preferWorkspacePackages: true preferFrozenLockfile: true packages: - examples/* onlyBuiltDependencies: - '@swc/core' - esbuild ================================================ FILE: src/_queryClientAtom.ts ================================================ import { QueryClient } from '@tanstack/query-core' import { atom } from 'jotai/vanilla' export const queryClientAtom = atom(new QueryClient()) if (process.env.NODE_ENV !== 'production') { queryClientAtom.debugPrivate = true } ================================================ FILE: src/atomWithInfiniteQuery.ts ================================================ import { type DefaultError, type InfiniteData, InfiniteQueryObserver, QueryClient, type QueryKey, QueryObserver, } from '@tanstack/query-core' import { Getter, WritableAtom } from 'jotai' import { queryClientAtom } from './_queryClientAtom' import { baseAtomWithQuery } from './baseAtomWithQuery' import { AtomWithInfiniteQueryOptions, AtomWithInfiniteQueryResult, DefinedAtomWithInfiniteQueryResult, DefinedInitialDataInfiniteOptions, UndefinedInitialDataInfiniteOptions, } from './types' export function atomWithInfiniteQuery< TQueryFnData, TError = DefaultError, TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, >( getOptions: ( get: Getter ) => UndefinedInitialDataInfiniteOptions< TQueryFnData, TError, TData, TQueryKey, TPageParam >, getQueryClient?: (get: Getter) => QueryClient ): WritableAtom, [], void> export function atomWithInfiniteQuery< TQueryFnData, TError = DefaultError, TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, >( getOptions: ( get: Getter ) => DefinedInitialDataInfiniteOptions< TQueryFnData, TError, TData, TQueryKey, TPageParam >, getQueryClient?: (get: Getter) => QueryClient ): WritableAtom, [], void> export function atomWithInfiniteQuery< TQueryFnData, TError = DefaultError, TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, >( getOptions: ( get: Getter ) => AtomWithInfiniteQueryOptions< TQueryFnData, TError, TData, TQueryKey, TPageParam >, getQueryClient?: (get: Getter) => QueryClient ): WritableAtom, [], void> export function atomWithInfiniteQuery( getOptions: (get: Getter) => AtomWithInfiniteQueryOptions, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ) { return baseAtomWithQuery( getOptions, InfiniteQueryObserver as typeof QueryObserver, getQueryClient ) } ================================================ FILE: src/atomWithMutation.ts ================================================ import { MutationObserver, type MutationOptions, QueryClient, notifyManager, } from '@tanstack/query-core' import { Atom, Getter, atom } from 'jotai' import { queryClientAtom } from './_queryClientAtom' import { AtomWithMutationResult, MutateFunction } from './types' import { shouldThrowError } from './utils' export function atomWithMutation< TData = unknown, TVariables = void, TError = unknown, TContext = unknown, >( getOptions: ( get: Getter ) => MutationOptions, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ): Atom> { const IN_RENDER = Symbol() const optionsAtom = atom((get) => { const client = getQueryClient(get) const options = getOptions(get) return client.defaultMutationOptions(options) }) if (process.env.NODE_ENV !== 'production') { optionsAtom.debugPrivate = true } const observerCacheAtom = atom( () => new WeakMap< QueryClient, MutationObserver >() ) if (process.env.NODE_ENV !== 'production') { observerCacheAtom.debugPrivate = true } const observerAtom = atom((get) => { const options = get(optionsAtom) const client = getQueryClient(get) const observerCache = get(observerCacheAtom) const observer = observerCache.get(client) if (observer) { ;(observer as any)[IN_RENDER] = true observer.setOptions(options) delete (observer as any)[IN_RENDER] return observer } const newObserver = new MutationObserver(client, options) observerCache.set(client, newObserver) return newObserver }) if (process.env.NODE_ENV !== 'production') { observerAtom.debugPrivate = true } const dataAtom = atom((get) => { const observer = get(observerAtom) const currentResult = observer.getCurrentResult() const resultAtom = atom(currentResult) resultAtom.onMount = (set) => { observer.subscribe(notifyManager.batchCalls(set)) return () => { observer.reset() } } if (process.env.NODE_ENV !== 'production') { resultAtom.debugPrivate = true } return resultAtom }) const mutateAtom = atom((get) => { const observer = get(observerAtom) const mutate: MutateFunction = ( variables, options ) => { observer.mutate(variables, options).catch(noop) } return mutate }) if (process.env.NODE_ENV !== 'production') { mutateAtom.debugPrivate = true } return atom((get) => { const observer = get(observerAtom) const resultAtom = get(dataAtom) const result = get(resultAtom) const mutate = get(mutateAtom) if ( result.isError && shouldThrowError(observer.options.throwOnError, [result.error]) ) { throw result.error } return { ...result, mutate, mutateAsync: result.mutate } }) } function noop() {} ================================================ FILE: src/atomWithMutationState.ts ================================================ import { type DefaultError, Mutation, MutationCache, type MutationFilters, type MutationState, QueryClient, } from '@tanstack/query-core' import { Getter, atom } from 'jotai' import { queryClientAtom } from './_queryClientAtom' type MutationStateOptions = { filters?: MutationFilters select?: ( mutation: Mutation ) => TResult } function getResult( mutationCache: MutationCache, options: MutationStateOptions ): Array { return mutationCache .findAll(options.filters) .map( (mutation): TResult => (options.select ? options.select( mutation as Mutation ) : mutation.state) as TResult ) } export const atomWithMutationState = ( getOptions: (get: Getter) => MutationStateOptions, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ) => { const resultsAtom = atom([]) if (process.env.NODE_ENV !== 'production') { resultsAtom.debugPrivate = true } const observableAtom = atom((get) => { const queryClient = getQueryClient(get) const mutationCache = queryClient.getMutationCache() resultsAtom.onMount = (set) => { const unsubscribe = mutationCache.subscribe(() => { set(getResult(getQueryClient(get).getMutationCache(), getOptions(get))) }) return unsubscribe } }) if (process.env.NODE_ENV !== 'production') { observableAtom.debugPrivate = true } return atom((get) => { get(observableAtom) return get(resultsAtom) }) } ================================================ FILE: src/atomWithQueries.ts ================================================ import { type DefaultError, QueryClient, QueryObserver, } from '@tanstack/query-core' import { Getter, WritableAtom, atom } from 'jotai' import { queryClientAtom } from './_queryClientAtom' import { baseAtomWithQuery } from './baseAtomWithQuery' import { AtomWithQueryOptions, AtomWithQueryResult } from './types' export function atomWithQueries( { queries, combine, }: { queries: Array<(get: Getter) => AtomWithQueryOptions> combine: (results: AtomWithQueryResult[]) => TCombinedResult }, getQueryClient?: (get: Getter) => QueryClient ): WritableAtom export function atomWithQueries< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, >( { queries, }: { queries: Array<(get: Getter) => AtomWithQueryOptions> }, getQueryClient?: (get: Getter) => QueryClient ): Array, [], void>> export function atomWithQueries( { queries, combine, }: { queries: Array<(get: Getter) => AtomWithQueryOptions> combine?: (results: AtomWithQueryResult[]) => any }, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ): any { if (!combine) { return queries.map((getOptions) => baseAtomWithQuery(getOptions, QueryObserver, getQueryClient) ) } const queryAtoms = queries.map((getOptions) => baseAtomWithQuery(getOptions, QueryObserver, getQueryClient) ) return atom((get) => { const results = queryAtoms.map((queryAtom) => { const result = get(queryAtom) return result as AtomWithQueryResult }) return combine(results) }) } ================================================ FILE: src/atomWithQuery.ts ================================================ import { type DefaultError, QueryClient, type QueryKey, QueryObserver, } from '@tanstack/query-core' import { Getter, WritableAtom } from 'jotai' import { queryClientAtom } from './_queryClientAtom' import { baseAtomWithQuery } from './baseAtomWithQuery' import { AtomWithQueryOptions, AtomWithQueryResult, DefinedAtomWithQueryResult, DefinedInitialDataOptions, UndefinedInitialDataOptions, } from './types' export function atomWithQuery< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( getOptions: ( get: Getter ) => UndefinedInitialDataOptions, getQueryClient?: (get: Getter) => QueryClient ): WritableAtom, [], void> export function atomWithQuery< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( getOptions: ( get: Getter ) => DefinedInitialDataOptions, getQueryClient?: (get: Getter) => QueryClient ): WritableAtom, [], void> export function atomWithQuery< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( getOptions: ( get: Getter ) => AtomWithQueryOptions, getQueryClient?: (get: Getter) => QueryClient ): WritableAtom, [], void> export function atomWithQuery( getOptions: (get: Getter) => AtomWithQueryOptions, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ) { return baseAtomWithQuery(getOptions, QueryObserver, getQueryClient) } ================================================ FILE: src/atomWithSuspenseInfiniteQuery.ts ================================================ import { type DefaultError, type InfiniteData, InfiniteQueryObserver, QueryClient, type QueryKey, QueryObserver, } from '@tanstack/query-core' import { Getter, WritableAtom, atom } from 'jotai' import { queryClientAtom } from './_queryClientAtom' import { baseAtomWithQuery } from './baseAtomWithQuery' import { AtomWithSuspenseInfiniteQueryOptions, AtomWithSuspenseInfiniteQueryResult, } from './types' import { defaultThrowOnError } from './utils' export function atomWithSuspenseInfiniteQuery< TQueryFnData, TError = DefaultError, TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, >( getOptions: ( get: Getter ) => AtomWithSuspenseInfiniteQueryOptions< TQueryFnData, TError, TData, TQueryKey, TPageParam >, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ): WritableAtom, [], void> { const suspenseOptionsAtom = atom((get) => { const options = getOptions(get) return { ...options, enabled: true, suspense: true, throwOnError: defaultThrowOnError, } }) return baseAtomWithQuery( (get: Getter) => get(suspenseOptionsAtom), InfiniteQueryObserver as typeof QueryObserver, getQueryClient ) as unknown as WritableAtom< AtomWithSuspenseInfiniteQueryResult, [], void > } ================================================ FILE: src/atomWithSuspenseQuery.ts ================================================ import { type DefaultError, QueryClient, type QueryKey, QueryObserver, } from '@tanstack/query-core' import { Getter, WritableAtom, atom } from 'jotai' import { queryClientAtom } from './_queryClientAtom' import { baseAtomWithQuery } from './baseAtomWithQuery' import { AtomWithSuspenseQueryOptions, AtomWithSuspenseQueryResult, } from './types' import { defaultThrowOnError } from './utils' export function atomWithSuspenseQuery< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( getOptions: ( get: Getter ) => AtomWithSuspenseQueryOptions, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ): WritableAtom, [], void> { const suspenseOptions = atom((get) => { const options = getOptions(get) return { ...options, suspense: true, enabled: true, throwOnError: defaultThrowOnError, } }) return baseAtomWithQuery( (get: Getter) => get(suspenseOptions), QueryObserver, getQueryClient ) as WritableAtom, [], void> } ================================================ FILE: src/baseAtomWithQuery.ts ================================================ import { QueryClient, type QueryKey, QueryObserver, type QueryObserverResult, notifyManager, } from '@tanstack/query-core' import { Getter, WritableAtom, atom } from 'jotai' import { queryClientAtom } from './_queryClientAtom' import { BaseAtomWithQueryOptions } from './types' import { ensureStaleTime, getHasError, shouldSuspend } from './utils' export function baseAtomWithQuery< TQueryFnData, TError, TData, TQueryData, TQueryKey extends QueryKey, >( getOptions: ( get: Getter ) => BaseAtomWithQueryOptions< TQueryFnData, TError, TData, TQueryData, TQueryKey >, Observer: typeof QueryObserver, getQueryClient: (get: Getter) => QueryClient = (get) => get(queryClientAtom) ): WritableAtom< | QueryObserverResult | Promise>, [], void > { const refreshAtom = atom(0) const clientAtom = atom(getQueryClient) if (process.env.NODE_ENV !== 'production') { clientAtom.debugPrivate = true } const observerCacheAtom = atom( () => new WeakMap< QueryClient, QueryObserver >() ) if (process.env.NODE_ENV !== 'production') { observerCacheAtom.debugPrivate = true } const defaultedOptionsAtom = atom((get) => { const client = get(clientAtom) const options = getOptions(get) const defaultedOptions = client.defaultQueryOptions(options) const cache = get(observerCacheAtom) const cachedObserver = cache.get(client) defaultedOptions._optimisticResults = 'optimistic' if (cachedObserver) { cachedObserver.setOptions(defaultedOptions) } return ensureStaleTime(defaultedOptions) }) if (process.env.NODE_ENV !== 'production') { defaultedOptionsAtom.debugPrivate = true } const observerAtom = atom((get) => { const client = get(clientAtom) const defaultedOptions = get(defaultedOptionsAtom) const observerCache = get(observerCacheAtom) const cachedObserver = observerCache.get(client) if (cachedObserver) return cachedObserver const newObserver = new Observer(client, defaultedOptions) observerCache.set(client, newObserver) return newObserver }) if (process.env.NODE_ENV !== 'production') { observerAtom.debugPrivate = true } const dataAtom = atom((get) => { const observer = get(observerAtom) const defaultedOptions = get(defaultedOptionsAtom) const result = observer.getOptimisticResult(defaultedOptions) const resultAtom = atom(result) if (process.env.NODE_ENV !== 'production') { resultAtom.debugPrivate = true } resultAtom.onMount = (set) => { const unsubscribe = observer.subscribe(notifyManager.batchCalls(set)) return () => { if (observer.getCurrentResult().isError) { observer.getCurrentQuery().reset() } unsubscribe() } } return resultAtom }) if (process.env.NODE_ENV !== 'production') { dataAtom.debugPrivate = true } return atom( (get) => { get(refreshAtom) const observer = get(observerAtom) const defaultedOptions = get(defaultedOptionsAtom) const result = get(get(dataAtom)) if (shouldSuspend(defaultedOptions, result, false)) { return observer.fetchOptimistic(defaultedOptions) } if ( getHasError({ result, query: observer.getCurrentQuery(), throwOnError: defaultedOptions.throwOnError, }) ) { throw result.error } return result }, (_get, set) => { set(refreshAtom, (c) => c + 1) } ) } ================================================ FILE: src/index.ts ================================================ export { queryClientAtom } from './_queryClientAtom' export { atomWithQuery } from './atomWithQuery' export { atomWithQueries } from './atomWithQueries' export { atomWithSuspenseQuery } from './atomWithSuspenseQuery' export { atomWithInfiniteQuery } from './atomWithInfiniteQuery' export { atomWithMutation } from './atomWithMutation' export { atomWithSuspenseInfiniteQuery } from './atomWithSuspenseInfiniteQuery' export { atomWithMutationState } from './atomWithMutationState' export * from './types' ================================================ FILE: src/react.ts ================================================ import { createElement } from 'react' import { QueryClientProvider, type QueryClientProviderProps, } from '@tanstack/react-query' import { Provider } from 'jotai' import { useHydrateAtoms } from 'jotai/utils' import { queryClientAtom } from './_queryClientAtom' const HydrateAtoms = ({ client, children }: QueryClientProviderProps) => { useHydrateAtoms([[queryClientAtom, client]]) return children } export function QueryClientAtomProvider({ client, children, }: QueryClientProviderProps) { return createElement( QueryClientProvider, { client }, createElement( Provider, null, createElement(HydrateAtoms, { client }, children) ) ) } ================================================ FILE: src/types.ts ================================================ import { type DefaultError, type DefinedInfiniteQueryObserverResult, type DefinedQueryObserverResult, type InfiniteData, type InfiniteQueryObserverOptions, type InfiniteQueryObserverResult, type MutationObserverOptions, type MutationObserverResult, type QueryKey, type MutateFunction as QueryMutateFunction, type QueryObserverOptions, type QueryObserverResult, type WithRequired, } from '@tanstack/query-core' type Override = { [K in keyof A]: K extends keyof B ? B[K] : A[K] } export type MutateFunction< TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown, > = ( ...args: Parameters> ) => void export type MutateAsyncFunction< TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown, > = QueryMutateFunction export type AtomWithMutationResult = Override< MutationObserverResult, { mutate: MutateFunction } > & { mutateAsync: MutateAsyncFunction } export type MutationOptions< TData = unknown, TError = DefaultError, TVariables = void, TContext = unknown, > = Omit< MutationObserverOptions, '_defaulted' | 'variables' > export type BaseAtomWithQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = WithRequired< QueryObserverOptions, 'queryKey' > export type AtomWithQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = Omit< WithRequired< BaseAtomWithQueryOptions< TQueryFnData, TError, TData, TQueryFnData, TQueryKey >, 'queryKey' >, 'suspense' > export type AtomWithSuspenseQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = Omit< AtomWithQueryOptions, 'enabled' | 'throwOnError' | 'placeholderData' > export type AtomWithInfiniteQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, > = WithRequired< Omit< InfiniteQueryObserverOptions< TQueryFnData, TError, TData, TQueryKey, TPageParam >, 'suspense' >, 'queryKey' > export type AtomWithSuspenseInfiniteQueryOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, > = Omit< AtomWithInfiniteQueryOptions< TQueryFnData, TError, TData, TQueryKey, TPageParam >, 'enabled' | 'throwOnError' | 'placeholderData' > export type AtomWithQueryResult< TData = unknown, TError = DefaultError, > = QueryObserverResult export type DefinedAtomWithQueryResult< TData = unknown, TError = DefaultError, > = DefinedQueryObserverResult export type AtomWithSuspenseQueryResult< TData = unknown, TError = DefaultError, > = | Omit, 'isPlaceholderData'> | Promise< Omit, 'isPlaceholderData'> > export type AtomWithInfiniteQueryResult< TData = unknown, TError = DefaultError, > = InfiniteQueryObserverResult export type DefinedAtomWithInfiniteQueryResult< TData = unknown, TError = DefaultError, > = DefinedInfiniteQueryObserverResult export type AtomWithSuspenseInfiniteQueryResult< TData = unknown, TError = DefaultError, > = Promise< Omit, 'isPlaceholderData'> > export type UndefinedInitialDataOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = AtomWithQueryOptions & { initialData?: undefined } type NonUndefinedGuard = T extends undefined ? never : T export type DefinedInitialDataOptions< TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, > = AtomWithQueryOptions & { initialData: | NonUndefinedGuard | (() => NonUndefinedGuard) } export type UndefinedInitialDataInfiniteOptions< TQueryFnData, TError = DefaultError, TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, > = AtomWithInfiniteQueryOptions< TQueryFnData, TError, TData, TQueryKey, TPageParam > & { initialData?: undefined } export type DefinedInitialDataInfiniteOptions< TQueryFnData, TError = DefaultError, TData = InfiniteData, TQueryKey extends QueryKey = QueryKey, TPageParam = unknown, > = AtomWithInfiniteQueryOptions< TQueryFnData, TError, TData, TQueryKey, TPageParam > & { initialData: | NonUndefinedGuard> | (() => NonUndefinedGuard>) } ================================================ FILE: src/utils.ts ================================================ import type { DefaultError, DefaultedQueryObserverOptions, Query, QueryKey, QueryObserverResult, ThrowOnError, } from '@tanstack/query-core' export const shouldSuspend = ( defaultedOptions: | DefaultedQueryObserverOptions | undefined, result: QueryObserverResult, isRestoring: boolean ) => defaultedOptions?.suspense && willFetch(result, isRestoring) export const willFetch = ( result: QueryObserverResult, isRestoring: boolean ) => result.isPending && !isRestoring export const getHasError = < TData, TError, TQueryFnData, TQueryData, TQueryKey extends QueryKey, >({ result, throwOnError, query, }: { result: QueryObserverResult throwOnError: | ThrowOnError | undefined query: Query }) => { return ( result.isError && !result.isFetching && shouldThrowError(throwOnError, [result.error, query]) ) } export function shouldThrowError boolean>( throwOnError: boolean | T | undefined, params: Parameters ): boolean { // Allow useErrorBoundary function to override throwing behavior on a per-error basis if (typeof throwOnError === 'function') { return throwOnError(...params) } return !!throwOnError } export const defaultThrowOnError = < TQueryFnData = unknown, TError = DefaultError, TData = TQueryFnData, TQueryKey extends QueryKey = QueryKey, >( _error: TError, query: Query ) => typeof query.state.data === 'undefined' export const ensureStaleTime = ( defaultedOptions: DefaultedQueryObserverOptions ) => { if (defaultedOptions.suspense) { if (typeof defaultedOptions.staleTime !== 'number') { return { ...defaultedOptions, staleTime: 1000, } } } return defaultedOptions } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "strict": true, "target": "esnext", "downlevelIteration": true, "esModuleInterop": true, "module": "es2015", "moduleResolution": "bundler", "allowJs": true, "noUnusedLocals": true, "noUnusedParameters": true, "noUncheckedIndexedAccess": true, "exactOptionalPropertyTypes": true, "jsx": "react", "baseUrl": ".", "skipLibCheck": true, "paths": { "jotai-tanstack-query": ["./src"] }, "types": ["vitest/globals", "jsdom"] }, "include": ["__tests__", "src"], "exclude": ["node_modules", "dist"] } ================================================ FILE: vitest.config.ts ================================================ /// import { defineConfig } from 'vitest/config' export default defineConfig({ test: { environment: 'jsdom', globals: true, setupFiles: [], projects: ['vitest.config.ts'], include: ['__tests__/**/*.spec.tsx'], testTimeout: 10000, // Increase timeout for tests with fake timers }, resolve: { alias: { 'jotai-tanstack-query': new URL('./src/index.ts', import.meta.url) .pathname, }, }, })