diff --git a/docs/integrations/query.mdx b/docs/integrations/query.mdx index 7e02d8d1a2..896b35e250 100644 --- a/docs/integrations/query.mdx +++ b/docs/integrations/query.mdx @@ -1,37 +1,58 @@ --- -title: Query -description: This doc describes `jotai/query` bundle. +title: TanStack Query +description: This doc describes TanStack Query integration. nav: 4.03 --- -This is the Jotai integration with [TanStack Query v4](https://tanstack.com/query/v4), a set of hooks and tools for managing async state (typically external data). +[TanStack Query](https://tanstack.com/query/) provides a set of functions for managing async state (typically external data). From the [Overview docs](https://tanstack.com/query/v4/docs/overview): > React Query is often described as the missing data-fetching library for React, but in more technical terms, it makes **fetching, caching, synchronizing and updating server state** in your React applications a breeze. -Jotai with TanStack Query 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. +[jotai-tanstack-query](https://github.com/jotai-labs/jotai-tanstack-query) is a Jotai integration 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. ## Install -In addition to `jotai`, you have to install `@tanstack/query-core` to make use of this bundle and its functions. +In addition to `jotai`, you have to install `jotai-tanstack-query` and `@tanstack/query-core` to use the integration. ```bash -yarn add @tanstack/query-core +yarn add jotai-tanstack-query @tanstack/query-core ``` -## atomWithQuery +## Exported functions -`atomWithQuery` creates a new atom that implements a standard [`Query`](https://tanstack.com/query/v4/docs/guides/queries) from TanStack Query. This function combines both Jotai `atom` features and `useQuery` features in a single atom. This atom supports all features you'd expect to find when using the standard `@tanstack/react-query` package. +- `atomsWithQuery` for [QueryObserver](https://tanstack.com/query/v4/docs/reference/QueryObserver) +- `atomsWithInfiniteQuery` for [InfiniteQueryObserver](https://tanstack.com/query/v4/docs/reference/InfiniteQueryObserver) +- `atomsWithMutation` for MutationObserver + +All three functions follow the same signature. + +```ts +const [dataAtom, statusAtom] = atomsWithSomething(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/v4/docs/reference/QueryClient). + +The return values have two atoms. +The first one is called `dataAtom` and it's an atom for the data from the observer. `dataAtom` requires Suspense. +The second one is called `statusAtom` and it's an atom for the full result from the observer. `statusAtom` doesn't require Suspnse. +The data from the observer is also included in `statusAtom`, +so if you don't use Suspense, you don't need to use `dataAtom`. + +## `atomsWithQuery` usage + +`atomsWithQuery` creates new atoms that implement a standard [`Query`](https://tanstack.com/query/v4/docs/guides/queries) from TanStack Query. > A query is a declarative dependency on an asynchronous source of data that is tied to a unique key. A query can be used with any Promise based method (including GET and POST methods) to fetch data from a server. ```jsx import { useAtom } from 'jotai' -import { atomWithQuery } from 'jotai/query' +import { atomsWithQuery } from 'jotai-tanstack-query' const idAtom = atom(1) -const userAtom = atomWithQuery((get) => ({ +const [userAtom] = atomsWithQuery((get) => ({ queryKey: ['users', get(idAtom)], queryFn: async ({ queryKey: [, id] }) => { const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`) @@ -45,9 +66,9 @@ const UserData = () => { } ``` -## atomWithInfiniteQuery +## `atomsWithTansackInfiniteQuery` usage -`atomWithInfiniteQuery` is very similar to `atomWithQuery`, however it creates 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/v4/docs/guides/infinite-queries). This atom supports all features you'd expect to find when using the standard `@tanstack/react-query` package. +`atomsWithInfiniteQuery` is very similar to `atomsWithQuery`, 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/v4/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. @@ -55,10 +76,10 @@ A notable difference between a standard query atom is the additional option `get ```jsx import { useAtom } from 'jotai' -import { atomWithInfiniteQuery } from 'jotai/query' +import { atomsWithInfiniteQuery } from 'jotai-tanstack-query' const idAtom = atom(1) -const userAtom = atomWithInfiniteQuery((get) => ({ +const [userAtom] = atomsWithInfiniteQuery((get) => ({ queryKey: ['users', get(idAtom)], queryFn: async ({ queryKey: [, id] }) => { const res = await fetch(`https://jsonplaceholder.typicode.com/users/${id}`) @@ -78,17 +99,50 @@ const UserData = () => { ### Fetching pages and refetching -Using the same atom as in the above example, we can use the `update` method returned from `useAtom` to call different `@tanstack/react-query` methods: +Using the same atom as in the above example, we can dispatch an action to `userAtom`. ```js const UserData = () => { - const [data, update] = useAtom(userAtom) + const [data, dispatch] = useAtom(userAtom) + const handleFetchNextPage = () => dispatch({ type: 'fetchNextPage' }) + const handleFetchPreviousPage = () => dispatch({ type: 'fetchPreviousPage' }) +} +``` - const handleFetchNextPage = () => update({ type: 'fetchNextPage' }) +## `atomsWithTansackMutation` usage - const handleFetchPreviousPage = () => update({ type: 'fetchPreviousPage' }) +`atomsWithMutation` creates new atoms that implement a standard [`Mutatioin`](https://tanstack.com/query/v4/docs/guides/mutations) from TanStack Query. - const handleRefetchAllPages = () => update({ type: 'refetch' }) +> Unlike queries, mutations are typically used to create/update/delete data or perform server side-effects. + +```jsx +import { useAtom } from 'jotai' +import { atomsWithMutation } from 'jotai-tanstack-query' + +const idAtom = atom(1) +const [, postAtom] = atomsWithMutation((get) => ({ + mutationKey: ['posts'], + mutationFn: async ({ title, body }) => { + const res = await fetch(`https://jsonplaceholder.typicode.com/posts`, { + method: 'POST', + body: JSON.stringify({ title, body, userId: get(idAtom) }), + headers: { 'Content-type': 'application/json; charset=UTF-8' }, + }) + const data = await res.json() + return data + }, +})) + +const PostData = () => { + const [post, mutate] = useAtom(postAtom) + return ( +
+ +
{JSON.stringify(post)}
+
+ ) } ``` @@ -100,6 +154,8 @@ To ensure that you reference the same `QueryClient` object, be sure to wrap the Without this step, `useQueryAtom` will reference a seperate `QueryClient` from any hooks that utilise the `useQueryClient()` hook to get the queryClient. +Alternatively, you can specify your `queryClient` with `getQueryClient` parameter. + ### Example In the example below, we have a mutation hook, `useTodoMutation` and a query `todosAtom`. @@ -117,7 +173,7 @@ import { QueryClient, QueryClientProvider, } from '@tanstack/react-query' -import { atomWithQuery, queryClientAtom } from 'jotai/query' +import { atomsWithQuery, queryClientAtom } from 'jotai-tanstack-query' const queryClient = new QueryClient() export const App = () => { @@ -133,7 +189,7 @@ export const App = () => { ) } -export const todosAtom = atomWithQuery((get) => { +export const [todosAtom] = atomsWithQuery((get) => { return { queryKey: ['todos'], queryFn: () => fetch('/todos'), @@ -159,21 +215,28 @@ export const useTodoMutation = () => { ## SSR support -Both 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/v4/docs/guides/ssr) that React Query supports for use within SSR apps, [hydration](https://tanstack.com/query/v4/docs/guides/ssr#using-hydration) or [`initialData`](https://tanstack.com/query/v4/docs/guides/ssr#using-initialdata). +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/v4/docs/guides/ssr) that React Query supports for use within SSR apps, [hydration](https://tanstack.com/query/v4/docs/guides/ssr#using-hydration) or [`initialData`](https://tanstack.com/query/v4/docs/guides/ssr#using-initialdata). ## Error handling +With `dataAtom`, Fetch error will be thrown and can be caught with ErrorBoundary. Refetching may recover from a temporary error. +FIXME: Update example + See [a working example](https://codesandbox.io/s/04mepx) to learn more. ## Examples ### Basic demo +FIXME: Update demo + ### Hackernews +FIXME: Update demo + diff --git a/package.json b/package.json index b4763b0468..cf0a809917 100644 --- a/package.json +++ b/package.json @@ -238,6 +238,7 @@ "immer": "^9.0.15", "jest": "^29.2.0", "jest-environment-jsdom": "^29.2.0", + "jotai-tanstack-query": "^0.4.0", "json": "^11.0.0", "optics-ts": "^2.3.0", "postinstall-postinstall": "^2.1.0", @@ -265,9 +266,9 @@ "peerDependencies": { "@babel/core": "*", "@babel/template": "*", - "@tanstack/query-core": "*", "@urql/core": "*", "immer": "*", + "jotai-tanstack-query": "*", "optics-ts": "*", "react": ">=16.8", "valtio": "*", @@ -287,10 +288,10 @@ "immer": { "optional": true }, - "optics-ts": { + "jotai-tanstack-query": { "optional": true }, - "@tanstack/query-core": { + "optics-ts": { "optional": true }, "valtio": { diff --git a/src/query/atomWithInfiniteQuery.ts b/src/query/atomWithInfiniteQuery.ts index bd3f19fb57..846cb309c8 100644 --- a/src/query/atomWithInfiniteQuery.ts +++ b/src/query/atomWithInfiniteQuery.ts @@ -1,14 +1,13 @@ -import { InfiniteQueryObserver, isCancelledError } from '@tanstack/query-core' import type { InfiniteData, InfiniteQueryObserverOptions, QueryKey, - QueryObserverResult, RefetchOptions, RefetchQueryFilters, } from '@tanstack/query-core' +import { atomsWithInfiniteQuery } from 'jotai-tanstack-query' import { atom } from 'jotai' -import type { WritableAtom } from 'jotai' +import type { Getter, WritableAtom } from 'jotai' import { queryClientAtom } from './queryClientAtom' import { CreateQueryOptions, GetQueryClient } from './types' @@ -117,113 +116,29 @@ export function atomWithInfiniteQuery< InfiniteData | undefined, AtomWithInfiniteQueryAction > { - type Result = QueryObserverResult, TError> - type State = { - isMounted: boolean - unsubscribe: (() => void) | null - } - const queryDataAtom = atom( + const getOptions = (get: Getter) => ({ + staleTime: 200, + ...(typeof createQuery === 'function' ? createQuery(get) : createQuery), + }) + const [dataAtom] = atomsWithInfiniteQuery(getOptions, getQueryClient) + return atom( (get) => { - const queryClient = getQueryClient(get) - const options = - typeof createQuery === 'function' ? createQuery(get) : createQuery - - const defaultedOptions = queryClient.defaultQueryOptions(options) - const observer = new InfiniteQueryObserver(queryClient, defaultedOptions) - const initialResult = observer.getCurrentResult() - - let resolve: ((result: Result) => void) | null = null - const resultAtom = atom>( - initialResult.data === undefined && options.enabled !== false - ? new Promise((r) => { - resolve = r - }) - : initialResult - ) - let setResult: (result: Result) => void = () => { - throw new Error('setting result without mount') - } - const state: State = { - isMounted: false, - unsubscribe: null, - } - const listener = (result: Result) => { - if ( - result.isFetching || - (!result.isError && result.data === undefined) || - (result.isError && isCancelledError(result.error)) - ) { - return - } - if (resolve) { - setTimeout(() => { - if (!state.isMounted) { - state.unsubscribe?.() - state.unsubscribe = null - } - }, 1000) - resolve(result) - resolve = null - } else { - setResult(result) - } - } - if (options.enabled !== false) { - state.unsubscribe = observer.subscribe(listener) - } - resultAtom.onMount = (update) => { - setResult = update - state.isMounted = true - if (options.enabled !== false && !state.unsubscribe) { - state.unsubscribe = observer.subscribe(listener) - listener(observer.getCurrentResult()) - } - return () => state.unsubscribe?.() - } - return { options, resultAtom, observer, state } - }, - (get, set, action: AtomWithInfiniteQueryAction) => { - const { options, resultAtom, observer, state } = get(queryDataAtom) + const options = getOptions(get) if (options.enabled === false) { - return + const queryClient = getQueryClient(get) + return queryClient.getQueryData>(options.queryKey) } - switch (action.type) { - case 'refetch': { - set(resultAtom, new Promise(() => {})) // infinite pending - if (!state.isMounted) { - state.unsubscribe?.() - state.unsubscribe = null - } - observer.refetch(action.payload).then((result) => { - set(resultAtom, result) - }) - return - } - case 'fetchPreviousPage': { - observer.fetchPreviousPage() - return - } - case 'fetchNextPage': { - observer.fetchNextPage() - return - } + return get(dataAtom) + }, + (_get, set, action: AtomWithInfiniteQueryAction) => { + if (action.type === 'refetch') { + return set(dataAtom, { + type: 'refetch', + force: true, + options: action.payload as any, + }) } + return set(dataAtom, action) } ) - - const queryAtom = atom< - InfiniteData | undefined, - AtomWithInfiniteQueryAction - >( - (get) => { - const { resultAtom } = get(queryDataAtom) - const result = get(resultAtom) - if (result.isError) { - throw result.error - } - return result.data - }, - (_get, set, action) => set(queryDataAtom, action) // delegate action - ) - return queryAtom } diff --git a/src/query/atomWithQuery.ts b/src/query/atomWithQuery.ts index 4be8367623..40d9e0d5d7 100644 --- a/src/query/atomWithQuery.ts +++ b/src/query/atomWithQuery.ts @@ -1,16 +1,10 @@ -import { QueryClient, QueryObserver } from '@tanstack/query-core' -import type { - QueryKey, - QueryObserverOptions, - QueryObserverResult, -} from '@tanstack/query-core' +import type { QueryKey, QueryObserverOptions } from '@tanstack/query-core' +import { atomsWithQuery } from 'jotai-tanstack-query' import { atom } from 'jotai' -import type { WritableAtom } from 'jotai' +import type { Getter, WritableAtom } from 'jotai' import { queryClientAtom } from './queryClientAtom' import type { CreateQueryOptions, GetQueryClient } from './types' -type Timeout = ReturnType - type AtomWithQueryAction = { type: 'refetch' } @@ -88,119 +82,24 @@ export function atomWithQuery< >, getQueryClient: GetQueryClient = (get) => get(queryClientAtom) ): WritableAtom> { - type Result = QueryObserverResult - - // HACK: having state in atom creator function is not ideal - // because, unlike hooks, it's shared by multiple providers. - const previousDataCache = new WeakMap() - - const queryDataAtom = atom((get) => { - const queryClient = getQueryClient(get) - const options = - typeof createQuery === 'function' ? createQuery(get) : createQuery - const observer = new QueryObserver(queryClient, options) - const initialResult = observer.getCurrentResult() - if ( - initialResult.data === undefined && - options.keepPreviousData && - previousDataCache.has(queryClient) - ) { - initialResult.data = previousDataCache.get(queryClient) - } - - let resolve: ((result: Result) => void) | null = null - const makePending = () => - new Promise((r) => { - resolve = r - }) - const resultAtom = atom>( - initialResult.data === undefined && options.enabled !== false - ? makePending() - : initialResult - ) - let setResult: ((result: Result) => void) | null = null - const listener = (result: Result) => { - if (result.isFetching || (!result.isError && result.data === undefined)) { - return - } - if (!resolve && !setResult) { - throw new Error('setting result without mount') - } - if (resolve) { - resolve(result) - resolve = null - } - if (setResult) { - setResult(result) - } - if (result.data !== undefined) { - previousDataCache.set(queryClient, result.data) - } - } - let unsubscribe: (() => void) | null = null - let timer: Timeout | undefined - const startQuery = () => { - if (!setResult && unsubscribe) { - clearTimeout(timer) - unsubscribe() - unsubscribe = null - } - if (options.enabled !== false) { - if (setResult) { - observer.refetch({ cancelRefetch: true }).then(setResult) - } else { - unsubscribe = observer.subscribe(listener) - } - } - if (!setResult) { - // not mounted yet - timer = setTimeout(() => { - if (unsubscribe) { - unsubscribe() - unsubscribe = null - } - }, 1000) - } - } - startQuery() - resultAtom.onMount = (update) => { - setResult = update - if (unsubscribe) { - clearTimeout(timer) - } else { - startQuery() - } - return () => { - setResult = null - if (unsubscribe) { - unsubscribe() - // FIXME why does this fail? - // unsubscribe = null - } - } - } - return { resultAtom, makePending, startQuery } + const getOptions = (get: Getter) => ({ + staleTime: 200, + ...(typeof createQuery === 'function' ? createQuery(get) : createQuery), }) - - const queryAtom = atom( + const [dataAtom] = atomsWithQuery(getOptions, getQueryClient) + return atom( (get) => { - const { resultAtom } = get(queryDataAtom) - const result = get(resultAtom) - if (result.isError) { - throw result.error + const options = getOptions(get) + if (options.enabled === false) { + const queryClient = getQueryClient(get) + return queryClient.getQueryData(options.queryKey) } - return result.data + return get(dataAtom) }, - (get, set, action: AtomWithQueryAction) => { - const { resultAtom, makePending, startQuery } = get(queryDataAtom) - switch (action.type) { - case 'refetch': { - set(resultAtom, makePending()) - startQuery() - } + (_get, set, action: AtomWithQueryAction) => { + if (action.type === 'refetch') { + return set(dataAtom, { type: 'refetch', force: true }) } } ) - - return queryAtom } diff --git a/src/query/queryClientAtom.ts b/src/query/queryClientAtom.ts index 98c09a7153..748f6ff0ea 100644 --- a/src/query/queryClientAtom.ts +++ b/src/query/queryClientAtom.ts @@ -1,4 +1 @@ -import { QueryClient } from '@tanstack/query-core' -import { atom } from 'jotai' - -export const queryClientAtom = atom(new QueryClient()) +export { queryClientAtom } from 'jotai-tanstack-query' diff --git a/tests/query/atomWithInfiniteQuery.test.tsx b/tests/query/atomWithInfiniteQuery.test.tsx index 9d5f9615e7..132935485e 100644 --- a/tests/query/atomWithInfiniteQuery.test.tsx +++ b/tests/query/atomWithInfiniteQuery.test.tsx @@ -239,6 +239,8 @@ it('infinite query with enabled 2', async () => { await new Promise((r) => setTimeout(r, 100)) fireEvent.click(getByText('set disabled')) fireEvent.click(getByText('set slug')) + + await new Promise((r) => setTimeout(r, 100)) await findByText('slug: hello-first') await new Promise((r) => setTimeout(r, 100)) diff --git a/tests/query/atomWithQuery.test.tsx b/tests/query/atomWithQuery.test.tsx index e1c56309bc..11a092e78c 100644 --- a/tests/query/atomWithQuery.test.tsx +++ b/tests/query/atomWithQuery.test.tsx @@ -131,7 +131,6 @@ it('query refetch', async () => { await new Promise((r) => setTimeout(r, 100)) fireEvent.click(getByText('refetch')) - await findByText('loading') await findByText('count: 1') expect(mockFetch).toBeCalledTimes(2) @@ -418,6 +417,7 @@ it('query with enabled 2', async () => { expect(mockFetch).toHaveBeenCalledTimes(1) await findByText('slug: hello-first') + await new Promise((r) => setTimeout(r, 100)) fireEvent.click(getByText('set disabled')) fireEvent.click(getByText('set slug')) @@ -517,11 +517,11 @@ it('query with initialData test', async () => { } const { findByText } = render( - <> + - + ) // NOTE: the atom never suspends @@ -655,7 +655,6 @@ describe('error handling', () => { const countAtom = atomWithQuery(() => ({ queryKey: ['error test', 'count2'], retry: false, - staleTime: 200, queryFn: () => { const promise = fakeFetch({ count }, willThrowError, 200) willThrowError = !willThrowError diff --git a/yarn.lock b/yarn.lock index 02e58e67bc..bd7cdbc1f0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3908,6 +3908,11 @@ jest@^29.2.0: import-local "^3.0.2" jest-cli "^29.2.0" +jotai-tanstack-query@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/jotai-tanstack-query/-/jotai-tanstack-query-0.4.0.tgz#f8568160efb76f884156dc61a584fabafa5c5651" + integrity sha512-K3++3iIKGm34hyHm0i+8cMM7c2agDq/GdE7xJ2Asa+VwyEBQVRudfdGEv3HCYLp6smNcqWUxn7iQddue/DXe8g== + joycon@^3.0.1: version "3.1.1" resolved "https://registry.yarnpkg.com/joycon/-/joycon-3.1.1.tgz#bce8596d6ae808f8b68168f5fc69280996894f03"