From 1ec11e90a675ac2ffc6125623d2ff22e2c80fbef Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Mon, 30 Mar 2026 22:17:10 +0300 Subject: [PATCH 1/6] feat(eslint-plugin-query): add prefer-query-options rule --- docs/config.json | 4 + docs/eslint/eslint-plugin-query.md | 24 + docs/eslint/prefer-query-options.md | 121 +++ .../__tests__/prefer-query-options.test.ts | 889 ++++++++++++++++++ packages/eslint-plugin-query/src/index.ts | 50 +- packages/eslint-plugin-query/src/rules.ts | 2 + .../prefer-query-options.rule.ts | 321 +++++++ 7 files changed, 1394 insertions(+), 17 deletions(-) create mode 100644 docs/eslint/prefer-query-options.md create mode 100644 packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts create mode 100644 packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts diff --git a/docs/config.json b/docs/config.json index 54eeb77e838..41200ceaed2 100644 --- a/docs/config.json +++ b/docs/config.json @@ -1293,6 +1293,10 @@ { "label": "Mutation Property Order", "to": "eslint/mutation-property-order" + }, + { + "label": "Prefer Query Options", + "to": "eslint/prefer-query-options" } ] }, diff --git a/docs/eslint/eslint-plugin-query.md b/docs/eslint/eslint-plugin-query.md index 8b6ae637dec..8a1d726884b 100644 --- a/docs/eslint/eslint-plugin-query.md +++ b/docs/eslint/eslint-plugin-query.md @@ -46,6 +46,19 @@ export default [ ] ``` +### Recommended strict setup + +The `flat/recommended-strict` config extends `flat/recommended` with additional opinionated rules that enforce best practices more aggressively. + +```js +import pluginQuery from '@tanstack/eslint-plugin-query' + +export default [ + ...pluginQuery.configs['flat/recommended-strict'], + // Any other config... +] +``` + ### Custom setup Alternatively, you can load the plugin and configure only the rules you want to use: @@ -78,6 +91,16 @@ To enable all of the recommended rules for our plugin, add `plugin:@tanstack/que } ``` +### Recommended strict setup + +The `recommendedStrict` config extends `recommended` with additional opinionated rules: + +```json +{ + "extends": ["plugin:@tanstack/query/recommendedStrict"] +} +``` + ### Custom setup Alternatively, add `@tanstack/query` to the plugins section, and configure the rules you want to use: @@ -100,3 +123,4 @@ Alternatively, add `@tanstack/query` to the plugins section, and configure the r - [@tanstack/query/infinite-query-property-order](./infinite-query-property-order.md) - [@tanstack/query/no-void-query-fn](./no-void-query-fn.md) - [@tanstack/query/mutation-property-order](./mutation-property-order.md) +- [@tanstack/query/prefer-query-options](./prefer-query-options.md) diff --git a/docs/eslint/prefer-query-options.md b/docs/eslint/prefer-query-options.md new file mode 100644 index 00000000000..f93f8adbf53 --- /dev/null +++ b/docs/eslint/prefer-query-options.md @@ -0,0 +1,121 @@ +--- +id: prefer-query-options +title: Prefer the use of queryOptions +--- + +Separating `queryKey` and `queryFn` can cause unexpected runtime issues when the same query key is accidentally used with more than one `queryFn`. Wrapping them in `queryOptions` (or `infiniteQueryOptions`) co-locates the key and function, making queries safer and easier to reuse. + +## Rule Details + +Examples of **incorrect** code for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function Component({ id }) { + const query = useQuery({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) + // ... +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +function useFooQuery() { + return useQuery({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} +``` + +Examples of **correct** code for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +const options = queryOptions({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), +}) + +function Component({ id }) { + const query = useQuery(options) + // ... +} +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +const options = queryOptions({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), +}) + +function useFooQuery() { + return useQuery({ ...options, select: (data) => data.foo }) +} +``` + +The rule also enforces reusing `queryKey` from a `queryOptions` result instead of typing it manually in `QueryClient` methods or filters. + +Examples of **incorrect** `queryKey` references for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +const todoOptions = queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), +}) + +queryClient.getQueryData(['todo', id]) +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +const todoOptions = queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), +}) + +queryClient.invalidateQueries({ queryKey: ['todo', id] }) +``` + +Examples of **correct** `queryKey` references for this rule: + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +const todoOptions = queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), +}) + +queryClient.getQueryData(todoOptions.queryKey) +``` + +```tsx +/* eslint "@tanstack/query/prefer-query-options": "error" */ + +const todoOptions = queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), +}) + +queryClient.invalidateQueries({ queryKey: todoOptions.queryKey }) +``` + +## When Not To Use It + +If you do not want to enforce the use of `queryOptions` in your codebase, you will not need this rule. + +## Attributes + +- [x] ✅ Recommended (strict) +- [ ] 🔧 Fixable diff --git a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts new file mode 100644 index 00000000000..1261d0e9bb8 --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -0,0 +1,889 @@ +import { RuleTester } from '@typescript-eslint/rule-tester' +import { afterAll, describe, it } from 'vitest' +import { rule } from '../rules/prefer-query-options/prefer-query-options.rule' +import { normalizeIndent } from './test-utils' + +RuleTester.afterAll = afterAll +RuleTester.describe = describe +RuleTester.it = it + +const ruleTester = new RuleTester() + +describe('prefer-query-options', () => { + describe('queryOptions / infiniteQueryOptions builders', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'queryOptions builder is allowed', + code: normalizeIndent` + import { queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + `, + }, + { + name: 'infiniteQueryOptions builder is allowed', + code: normalizeIndent` + import { infiniteQueryOptions } from '@tanstack/react-query' + + const todosOptions = infiniteQueryOptions({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + `, + }, + ], + invalid: [], + }) + }) + + describe('hooks consuming queryOptions result', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'useQuery with queryOptions result is allowed', + code: normalizeIndent` + import { useQuery, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const query = useQuery(todosOptions) + return null + } + `, + }, + { + name: 'useQuery with queryOptions function call result is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component({ id }) { + const query = useQuery(todosOptions(id)) + return null + } + `, + }, + { + name: 'useQuery spreading queryOptions result is allowed', + code: normalizeIndent` + import { useQuery, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const query = useQuery({ ...todosOptions, select: (data) => data.items }) + return null + } + `, + }, + { + name: 'useQuery spreading queryOptions function call result is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component({ id }) { + const query = useQuery({ ...todosOptions(id), select: (data) => data.items }) + return null + } + `, + }, + { + name: 'useQueries with all entries from queryOptions is allowed', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [todosOptions, usersOptions], + }) + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('queryClient methods referencing queryKey from options', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'queryClient.getQueryData with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(todosOptions.queryKey) + return null + } + `, + }, + { + name: 'queryClient.setQueryData with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryData(todosOptions.queryKey, []) + return null + } + `, + }, + { + name: 'queryClient.invalidateQueries with options.queryKey is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) + return null + } + `, + }, + { + name: 'queryClient.invalidateQueries with options.queryKey and extra filter props is allowed', + code: normalizeIndent` + import { useQueryClient, queryOptions } from '@tanstack/react-query' + + const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey, exact: true }) + return null + } + `, + }, + { + name: 'queryClient.getQueryData with variable queryKey is allowed', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component({ queryKey }) { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(queryKey) + return null + } + `, + }, + { + name: 'non-queryClient fetchQuery call is ignored', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + const analytics = { + fetchQuery(options) { + return options + }, + } + + function Component() { + useQuery(todosOptions) + + analytics.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('non-tanstack imports', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [ + { + name: 'non-tanstack useQuery is ignored', + code: normalizeIndent` + import { useQuery } from 'other-library' + + function Component() { + const query = useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + }, + ], + invalid: [], + }) + }) + + describe('inline lone queryKey or queryFn in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery with only inline queryKey (no queryFn)', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQuery with only inline queryFn (no queryKey)', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ queryFn: () => fetchTodos() }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('spread with inline queryKey or queryFn override in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery spreading options but overriding queryKey inline', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ ...options, queryKey: ['override'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQuery spreading options but overriding queryFn inline', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ ...options, queryFn: () => fetchOverride() }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('inline queryKey + queryFn in hooks', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'useQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function Component() { + const query = useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useInfiniteQuery } from '@tanstack/react-query' + + function Component() { + const query = useInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useSuspenseQuery } from '@tanstack/react-query' + + function Component() { + const query = useSuspenseQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useSuspenseInfiniteQuery } from '@tanstack/react-query' + + function Component() { + const query = useSuspenseInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQueries with inline queryKey + queryFn in queries array', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + ], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useQueries with multiple inline entries reports multiple errors', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component() { + const queries = useQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + { + queryKey: ['users'], + queryFn: () => fetchUsers(), + }, + ], + }) + return null + } + `, + errors: [ + { messageId: 'preferQueryOptions' }, + { messageId: 'preferQueryOptions' }, + ], + }, + { + name: 'useQueries with mapped inline query objects', + code: normalizeIndent` + import { useQueries } from '@tanstack/react-query' + + function Component({ ids }) { + const queries = useQueries({ + queries: ids.map((id) => ({ + queryKey: ['todos', id], + queryFn: () => fetchTodo(id), + })), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQueries with inline queryKey + queryFn in queries array', + code: normalizeIndent` + import { useSuspenseQueries } from '@tanstack/react-query' + + function Component() { + const queries = useSuspenseQueries({ + queries: [ + { + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }, + ], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'useSuspenseQueries with mapped inline query objects', + code: normalizeIndent` + import { useSuspenseQueries } from '@tanstack/react-query' + + function Component({ ids }) { + const queries = useSuspenseQueries({ + queries: ids.map((id) => ({ + queryKey: ['todos', id], + queryFn: () => fetchTodo(id), + })), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'usePrefetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { usePrefetchQuery } from '@tanstack/react-query' + + function Component() { + usePrefetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'usePrefetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { usePrefetchInfiniteQuery } from '@tanstack/react-query' + + function Component() { + usePrefetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'inline queryKey + queryFn inside a custom hook', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + + function useTodos() { + return useQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('queryClient with alternate variable names', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'client.fetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const client = useQueryClient() + client.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'qc.getQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const qc = useQueryClient() + const data = qc.getQueryData(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'client.invalidateQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const client = useQueryClient() + client.invalidateQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) + + describe('inline queryKey + queryFn in queryClient methods', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.fetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.prefetchQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.prefetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.fetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.fetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.prefetchInfiniteQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.prefetchInfiniteQuery({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.ensureQueryData with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.ensureQueryData({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'queryClient.ensureInfiniteQueryData with inline queryKey + queryFn', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.ensureInfiniteQueryData({ + queryKey: ['todos'], + queryFn: ({ pageParam }) => fetchTodos(pageParam), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.nextCursor, + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + ], + }) + }) + + describe('inline queryKey as direct parameter', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.getQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueryData with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryData(['todos'], []) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryState with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const state = queryClient.getQueryState(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueryDefaults with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueryDefaults(['todos'], { staleTime: 1000 }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryDefaults with inline queryKey', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const defaults = queryClient.getQueryDefaults(['todos']) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) + + describe('inline queryKey in filter objects', () => { + ruleTester.run('prefer-query-options', rule, { + valid: [], + invalid: [ + { + name: 'queryClient.invalidateQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.cancelQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.cancelQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.refetchQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.refetchQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.removeQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.removeQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.resetQueries with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.resetQueries({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.isFetching with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const count = queryClient.isFetching({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueriesData with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueriesData({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.setQueriesData with inline queryKey in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.setQueriesData({ queryKey: ['todos'] }, []) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'useIsFetching with inline queryKey in filters', + code: normalizeIndent` + import { useIsFetching } from '@tanstack/react-query' + + function Component() { + const count = useIsFetching({ queryKey: ['todos'] }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + ], + }) + }) +}) diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts index 452bc0f5a42..085ba7b9f3d 100644 --- a/packages/eslint-plugin-query/src/index.ts +++ b/packages/eslint-plugin-query/src/index.ts @@ -8,10 +8,27 @@ export interface Plugin extends Omit { rules: Record> configs: { recommended: ESLint.ConfigData + recommendedStrict: ESLint.ConfigData 'flat/recommended': Array + 'flat/recommended-strict': Array } } +const recommendedRules = { + '@tanstack/query/exhaustive-deps': 'error', + '@tanstack/query/no-rest-destructuring': 'warn', + '@tanstack/query/stable-query-client': 'error', + '@tanstack/query/no-unstable-deps': 'error', + '@tanstack/query/infinite-query-property-order': 'error', + '@tanstack/query/no-void-query-fn': 'error', + '@tanstack/query/mutation-property-order': 'error', +} as const + +const recommendedStrictRules = { + ...recommendedRules, + '@tanstack/query/prefer-query-options': 'error', +} as const + export const plugin = { meta: { name: '@tanstack/eslint-plugin-query', @@ -19,15 +36,11 @@ export const plugin = { configs: { recommended: { plugins: ['@tanstack/query'], - rules: { - '@tanstack/query/exhaustive-deps': 'error', - '@tanstack/query/no-rest-destructuring': 'warn', - '@tanstack/query/stable-query-client': 'error', - '@tanstack/query/no-unstable-deps': 'error', - '@tanstack/query/infinite-query-property-order': 'error', - '@tanstack/query/no-void-query-fn': 'error', - '@tanstack/query/mutation-property-order': 'error', - }, + rules: recommendedRules, + }, + recommendedStrict: { + plugins: ['@tanstack/query'], + rules: recommendedStrictRules, }, 'flat/recommended': [ { @@ -35,15 +48,16 @@ export const plugin = { plugins: { '@tanstack/query': {}, // Assigned after plugin object created }, - rules: { - '@tanstack/query/exhaustive-deps': 'error', - '@tanstack/query/no-rest-destructuring': 'warn', - '@tanstack/query/stable-query-client': 'error', - '@tanstack/query/no-unstable-deps': 'error', - '@tanstack/query/infinite-query-property-order': 'error', - '@tanstack/query/no-void-query-fn': 'error', - '@tanstack/query/mutation-property-order': 'error', + rules: recommendedRules, + }, + ], + 'flat/recommended-strict': [ + { + name: 'tanstack/query/flat/recommended-strict', + plugins: { + '@tanstack/query': {}, // Assigned after plugin object created }, + rules: recommendedStrictRules, }, ], }, @@ -51,5 +65,7 @@ export const plugin = { } satisfies Plugin plugin.configs['flat/recommended'][0]!.plugins['@tanstack/query'] = plugin +plugin.configs['flat/recommended-strict'][0]!.plugins['@tanstack/query'] = + plugin export default plugin diff --git a/packages/eslint-plugin-query/src/rules.ts b/packages/eslint-plugin-query/src/rules.ts index d527768ec1d..cbae59eb9c9 100644 --- a/packages/eslint-plugin-query/src/rules.ts +++ b/packages/eslint-plugin-query/src/rules.ts @@ -5,6 +5,7 @@ import * as noUnstableDeps from './rules/no-unstable-deps/no-unstable-deps.rule' import * as infiniteQueryPropertyOrder from './rules/infinite-query-property-order/infinite-query-property-order.rule' import * as noVoidQueryFn from './rules/no-void-query-fn/no-void-query-fn.rule' import * as mutationPropertyOrder from './rules/mutation-property-order/mutation-property-order.rule' +import * as preferQueryOptions from './rules/prefer-query-options/prefer-query-options.rule' import type { ESLintUtils } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from './types' @@ -24,4 +25,5 @@ export const rules: Record< [infiniteQueryPropertyOrder.name]: infiniteQueryPropertyOrder.rule, [noVoidQueryFn.name]: noVoidQueryFn.rule, [mutationPropertyOrder.name]: mutationPropertyOrder.rule, + [preferQueryOptions.name]: preferQueryOptions.rule, } diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts new file mode 100644 index 00000000000..051b5565a27 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts @@ -0,0 +1,321 @@ +import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' +import { ASTUtils } from '../../utils/ast-utils' +import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports' +import { getDocsUrl } from '../../utils/get-docs-url' +import type { TSESTree } from '@typescript-eslint/utils' +import type { ExtraRuleDocs } from '../../types' + +export const name = 'prefer-query-options' + +const queryHooks = [ + 'useQuery', + 'useInfiniteQuery', + 'useSuspenseQuery', + 'useSuspenseInfiniteQuery', + 'usePrefetchQuery', + 'usePrefetchInfiniteQuery', +] + +const queriesHooks = ['useQueries', 'useSuspenseQueries'] + +const filterHooks = ['useIsFetching'] + +const queryClientOptionMethods = [ + 'fetchQuery', + 'prefetchQuery', + 'fetchInfiniteQuery', + 'prefetchInfiniteQuery', + 'ensureQueryData', + 'ensureInfiniteQueryData', +] + +const queryClientQueryKeyMethods = [ + 'getQueryData', + 'setQueryData', + 'getQueryState', + 'setQueryDefaults', + 'getQueryDefaults', +] + +const queryClientFilterMethods = [ + 'invalidateQueries', + 'cancelQueries', + 'refetchQueries', + 'removeQueries', + 'resetQueries', + 'isFetching', + 'getQueriesData', + 'setQueriesData', +] + +const queryOptionsBuilders = ['queryOptions', 'infiniteQueryOptions'] + +const createRule = ESLintUtils.RuleCreator(getDocsUrl) + +export const rule = createRule({ + name, + meta: { + type: 'problem', + docs: { + description: + 'Prefer using queryOptions() to co-locate queryKey and queryFn', + recommended: 'strict', + }, + messages: { + preferQueryOptions: + 'Prefer using queryOptions() or infiniteQueryOptions() to co-locate queryKey and queryFn.', + preferQueryOptionsQueryKey: + 'Prefer referencing a queryKey from a queryOptions() result instead of typing it manually.', + }, + schema: [], + }, + defaultOptions: [], + + create: detectTanstackQueryImports((context, _, helpers) => { + const queryClientVariables = new Set() + + function reportInlineQueryOptions(node: TSESTree.Node): void { + if (ASTUtils.isObjectExpression(node) && hasInlineQueryOptions(node)) { + context.report({ + node, + messageId: 'preferQueryOptions', + }) + } + } + + function reportInlineFilterQueryKey(node: TSESTree.Node): void { + if (ASTUtils.isObjectExpression(node) && hasInlineFilterQueryKey(node)) { + context.report({ + node, + messageId: 'preferQueryOptionsQueryKey', + }) + } + } + + return { + VariableDeclarator: (node) => { + if ( + node.id.type === AST_NODE_TYPES.Identifier && + node.init !== null && + isTanstackQueryClient(node.init, helpers, queryClientVariables) + ) { + queryClientVariables.add(node.id.name) + } + }, + + CallExpression: (node) => { + if (ASTUtils.isIdentifierWithOneOfNames(node.callee, queryOptionsBuilders)) { + return + } + + if ( + ASTUtils.isIdentifier(node.callee) && + helpers.isTanstackQueryImport(node.callee) + ) { + const options = node.arguments[0] + + if (options === undefined) { + return + } + + if (ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks)) { + reportInlineQueryOptions(options) + return + } + + if ( + ASTUtils.isIdentifierWithOneOfNames(node.callee, queriesHooks) && + ASTUtils.isObjectExpression(options) + ) { + const queries = ASTUtils.findPropertyWithIdentifierKey( + options.properties, + 'queries', + )?.value + + if (queries !== undefined) { + getQueryObjects(queries).forEach((query) => { + reportInlineQueryOptions(query) + }) + } + + return + } + + if (ASTUtils.isIdentifierWithOneOfNames(node.callee, filterHooks)) { + reportInlineFilterQueryKey(options) + } + + return + } + + if ( + node.callee.type !== AST_NODE_TYPES.MemberExpression || + !ASTUtils.isIdentifier(node.callee.property) || + !isTanstackQueryClient( + node.callee.object, + helpers, + queryClientVariables, + ) + ) { + return + } + + const method = node.callee.property.name + const options = node.arguments[0] + + if (options === undefined) { + return + } + + if (queryClientOptionMethods.includes(method)) { + reportInlineQueryOptions(options) + return + } + + if ( + queryClientQueryKeyMethods.includes(method) && + options.type === AST_NODE_TYPES.ArrayExpression + ) { + context.report({ + node: options, + messageId: 'preferQueryOptionsQueryKey', + }) + return + } + + if (queryClientFilterMethods.includes(method)) { + reportInlineFilterQueryKey(options) + } + }, + } + }), +}) + +function hasInlineQueryOptions(node: TSESTree.ObjectExpression): boolean { + return ( + ASTUtils.findPropertyWithIdentifierKey(node.properties, 'queryKey') !== + undefined || + ASTUtils.findPropertyWithIdentifierKey(node.properties, 'queryFn') !== + undefined + ) +} + +function hasInlineFilterQueryKey(node: TSESTree.ObjectExpression): boolean { + return ( + ASTUtils.findPropertyWithIdentifierKey(node.properties, 'queryKey')?.value + .type === AST_NODE_TYPES.ArrayExpression + ) +} + +function getReturnedObjectExpressions( + node: TSESTree.Node, +): Array { + if (ASTUtils.isObjectExpression(node)) { + return [node] + } + + if ( + node.type === AST_NODE_TYPES.ArrowFunctionExpression || + node.type === AST_NODE_TYPES.FunctionExpression + ) { + return getReturnedObjectExpressions(node.body) + } + + if (node.type === AST_NODE_TYPES.BlockStatement) { + return node.body.flatMap((statement) => { + if ( + statement.type === AST_NODE_TYPES.ReturnStatement && + statement.argument !== null + ) { + return getReturnedObjectExpressions(statement.argument) + } + + return [] + }) + } + + if (node.type === AST_NODE_TYPES.ConditionalExpression) { + return [ + ...getReturnedObjectExpressions(node.consequent), + ...getReturnedObjectExpressions(node.alternate), + ] + } + + if (node.type === AST_NODE_TYPES.LogicalExpression) { + return [ + ...getReturnedObjectExpressions(node.left), + ...getReturnedObjectExpressions(node.right), + ] + } + + if (node.type === AST_NODE_TYPES.SequenceExpression) { + return node.expressions.flatMap((expression) => + getReturnedObjectExpressions(expression), + ) + } + + return [] +} + +function getQueryObjects( + node: TSESTree.Node, +): Array { + if (node.type === AST_NODE_TYPES.ArrayExpression) { + return node.elements.flatMap((element) => { + if (element !== null && ASTUtils.isObjectExpression(element)) { + return [element] + } + + return [] + }) + } + + if ( + node.type === AST_NODE_TYPES.CallExpression && + node.callee.type === AST_NODE_TYPES.MemberExpression && + ASTUtils.isIdentifierWithName(node.callee.property, 'map') + ) { + const mapper = node.arguments[0] + + if ( + mapper?.type === AST_NODE_TYPES.ArrowFunctionExpression || + mapper?.type === AST_NODE_TYPES.FunctionExpression + ) { + return getReturnedObjectExpressions(mapper) + } + } + + return [] +} + +function isTanstackQueryClient( + node: TSESTree.Node, + helpers: { + isTanstackQueryImport: (node: TSESTree.Identifier) => boolean + }, + queryClientVariables: Set, +): boolean { + if (node.type === AST_NODE_TYPES.Identifier) { + return queryClientVariables.has(node.name) + } + + if ( + node.type === AST_NODE_TYPES.CallExpression && + ASTUtils.isIdentifierWithName(node.callee, 'useQueryClient') + ) { + return helpers.isTanstackQueryImport(node.callee) + } + + if ( + node.type === AST_NODE_TYPES.NewExpression && + ASTUtils.isIdentifierWithName(node.callee, 'QueryClient') + ) { + return helpers.isTanstackQueryImport(node.callee) + } + + if (node.type === AST_NODE_TYPES.ChainExpression) { + return isTanstackQueryClient(node.expression, helpers, queryClientVariables) + } + + return false +} From 7d51d462787db5ea2b128958486aaba5678517b4 Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Mon, 30 Mar 2026 22:29:04 +0300 Subject: [PATCH 2/6] add changeset --- .changeset/prefer-query-options-rule.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/prefer-query-options-rule.md diff --git a/.changeset/prefer-query-options-rule.md b/.changeset/prefer-query-options-rule.md new file mode 100644 index 00000000000..bd33094aec8 --- /dev/null +++ b/.changeset/prefer-query-options-rule.md @@ -0,0 +1,5 @@ +--- +'@tanstack/eslint-plugin-query': minor +--- + +Add `prefer-query-options` rule and `recommendedStrict` config From 3a8e0f47bac088f32cbbc8b5b4fd38dbdc252427 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 19:31:02 +0000 Subject: [PATCH 3/6] ci: apply automated fixes --- .../rules/prefer-query-options/prefer-query-options.rule.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts index 051b5565a27..428ba5f262c 100644 --- a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts @@ -104,7 +104,9 @@ export const rule = createRule({ }, CallExpression: (node) => { - if (ASTUtils.isIdentifierWithOneOfNames(node.callee, queryOptionsBuilders)) { + if ( + ASTUtils.isIdentifierWithOneOfNames(node.callee, queryOptionsBuilders) + ) { return } From afd00a973cb3020daaf2795c604b8848dc33e321 Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Mon, 30 Mar 2026 23:22:37 +0300 Subject: [PATCH 4/6] address review comments --- docs/eslint/prefer-query-options.md | 88 ++++++---- .../__tests__/prefer-query-options.test.ts | 118 +++++++++++++ .../prefer-query-options.rule.ts | 162 ++++++++++++------ 3 files changed, 285 insertions(+), 83 deletions(-) diff --git a/docs/eslint/prefer-query-options.md b/docs/eslint/prefer-query-options.md index f93f8adbf53..3228774bf7d 100644 --- a/docs/eslint/prefer-query-options.md +++ b/docs/eslint/prefer-query-options.md @@ -24,7 +24,7 @@ function Component({ id }) { ```tsx /* eslint "@tanstack/query/prefer-query-options": "error" */ -function useFooQuery() { +function useFooQuery(id) { return useQuery({ queryKey: ['get', id], queryFn: () => Api.get(`/foo/${id}`), @@ -37,13 +37,15 @@ Examples of **correct** code for this rule: ```tsx /* eslint "@tanstack/query/prefer-query-options": "error" */ -const options = queryOptions({ - queryKey: ['get', id], - queryFn: () => Api.get(`/foo/${id}`), -}) +function getFooOptions(id) { + return queryOptions({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} function Component({ id }) { - const query = useQuery(options) + const query = useQuery(getFooOptions(id)) // ... } ``` @@ -51,13 +53,15 @@ function Component({ id }) { ```tsx /* eslint "@tanstack/query/prefer-query-options": "error" */ -const options = queryOptions({ - queryKey: ['get', id], - queryFn: () => Api.get(`/foo/${id}`), -}) +function getFooOptions(id) { + return queryOptions({ + queryKey: ['get', id], + queryFn: () => Api.get(`/foo/${id}`), + }) +} -function useFooQuery() { - return useQuery({ ...options, select: (data) => data.foo }) +function useFooQuery(id) { + return useQuery({ ...getFooOptions(id), select: (data) => data.foo }) } ``` @@ -68,23 +72,33 @@ Examples of **incorrect** `queryKey` references for this rule: ```tsx /* eslint "@tanstack/query/prefer-query-options": "error" */ -const todoOptions = queryOptions({ - queryKey: ['todo', id], - queryFn: () => api.getTodo(id), -}) +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} -queryClient.getQueryData(['todo', id]) +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.getQueryData(['todo', id]) +} ``` ```tsx /* eslint "@tanstack/query/prefer-query-options": "error" */ -const todoOptions = queryOptions({ - queryKey: ['todo', id], - queryFn: () => api.getTodo(id), -}) +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} -queryClient.invalidateQueries({ queryKey: ['todo', id] }) +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.invalidateQueries({ queryKey: ['todo', id] }) +} ``` Examples of **correct** `queryKey` references for this rule: @@ -92,23 +106,33 @@ Examples of **correct** `queryKey` references for this rule: ```tsx /* eslint "@tanstack/query/prefer-query-options": "error" */ -const todoOptions = queryOptions({ - queryKey: ['todo', id], - queryFn: () => api.getTodo(id), -}) +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} -queryClient.getQueryData(todoOptions.queryKey) +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.getQueryData(todoOptions(id).queryKey) +} ``` ```tsx /* eslint "@tanstack/query/prefer-query-options": "error" */ -const todoOptions = queryOptions({ - queryKey: ['todo', id], - queryFn: () => api.getTodo(id), -}) +function todoOptions(id) { + return queryOptions({ + queryKey: ['todo', id], + queryFn: () => api.getTodo(id), + }) +} -queryClient.invalidateQueries({ queryKey: todoOptions.queryKey }) +function Component({ id }) { + const queryClient = useQueryClient() + return queryClient.invalidateQueries({ queryKey: todoOptions(id).queryKey }) +} ``` ## When Not To Use It diff --git a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts index 1261d0e9bb8..396be9da8b4 100644 --- a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -200,6 +200,25 @@ describe('prefer-query-options', () => { } `, }, + { + name: 'shadowed queryClient parameter is ignored', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + + function run(queryClient) { + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + } + + return null + } + `, + }, { name: 'non-queryClient fetchQuery call is ignored', code: normalizeIndent` @@ -333,6 +352,21 @@ describe('prefer-query-options', () => { `, errors: [{ messageId: 'preferQueryOptions' }], }, + { + name: 'aliased useQuery with inline queryKey + queryFn', + code: normalizeIndent` + import { useQuery as useTanstackQuery } from '@tanstack/react-query' + + function Component() { + const query = useTanstackQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, { name: 'useInfiniteQuery with inline queryKey + queryFn', code: normalizeIndent` @@ -550,6 +584,36 @@ describe('prefer-query-options', () => { `, errors: [{ messageId: 'preferQueryOptions' }], }, + { + name: 'aliased useQueryClient tracks query client variables', + code: normalizeIndent` + import { useQueryClient as getClient } from '@tanstack/react-query' + + function Component() { + const client = getClient() + client.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, + { + name: 'aliased QueryClient tracks query client instances', + code: normalizeIndent` + import { QueryClient as Client } from '@tanstack/react-query' + + const queryClient = new Client() + + queryClient.fetchQuery({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), + }) + `, + errors: [{ messageId: 'preferQueryOptions' }], + }, { name: 'qc.getQueryData with inline queryKey', code: normalizeIndent` @@ -707,6 +771,32 @@ describe('prefer-query-options', () => { `, errors: [{ messageId: 'preferQueryOptionsQueryKey' }], }, + { + name: 'queryClient.getQueryData with inline queryKey as const', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData(['todos'] as const) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.getQueryData with inline queryKey satisfies', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + const data = queryClient.getQueryData((['todos']) satisfies readonly string[]) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, { name: 'queryClient.setQueryData with inline queryKey', code: normalizeIndent` @@ -780,6 +870,34 @@ describe('prefer-query-options', () => { `, errors: [{ messageId: 'preferQueryOptionsQueryKey' }], }, + { + name: 'queryClient.invalidateQueries with inline queryKey as const in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: ['todos'] as const }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, + { + name: 'queryClient.invalidateQueries with inline queryKey satisfies in filters', + code: normalizeIndent` + import { useQueryClient } from '@tanstack/react-query' + + function Component() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ + queryKey: (['todos']) satisfies readonly string[], + }) + return null + } + `, + errors: [{ messageId: 'preferQueryOptionsQueryKey' }], + }, { name: 'queryClient.cancelQueries with inline queryKey in filters', code: normalizeIndent` diff --git a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts index 051b5565a27..1dc92da0ed3 100644 --- a/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts +++ b/packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts @@ -2,7 +2,7 @@ import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils' import { ASTUtils } from '../../utils/ast-utils' import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports' import { getDocsUrl } from '../../utils/get-docs-url' -import type { TSESTree } from '@typescript-eslint/utils' +import type { TSESLint, TSESTree } from '@typescript-eslint/utils' import type { ExtraRuleDocs } from '../../types' export const name = 'prefer-query-options' @@ -51,6 +51,9 @@ const queryClientFilterMethods = [ const queryOptionsBuilders = ['queryOptions', 'infiniteQueryOptions'] const createRule = ESLintUtils.RuleCreator(getDocsUrl) +type Helpers = { + isTanstackQueryImport: (node: TSESTree.Identifier) => boolean +} export const rule = createRule({ name, @@ -72,8 +75,6 @@ export const rule = createRule({ defaultOptions: [], create: detectTanstackQueryImports((context, _, helpers) => { - const queryClientVariables = new Set() - function reportInlineQueryOptions(node: TSESTree.Node): void { if (ASTUtils.isObjectExpression(node) && hasInlineQueryOptions(node)) { context.report({ @@ -93,40 +94,34 @@ export const rule = createRule({ } return { - VariableDeclarator: (node) => { - if ( - node.id.type === AST_NODE_TYPES.Identifier && - node.init !== null && - isTanstackQueryClient(node.init, helpers, queryClientVariables) - ) { - queryClientVariables.add(node.id.name) - } - }, - CallExpression: (node) => { - if (ASTUtils.isIdentifierWithOneOfNames(node.callee, queryOptionsBuilders)) { - return - } + if (ASTUtils.isIdentifier(node.callee)) { + const importedName = getTanstackImportName( + context, + helpers, + node.callee, + ) + + if (importedName === null) { + return + } + + if (queryOptionsBuilders.includes(importedName)) { + return + } - if ( - ASTUtils.isIdentifier(node.callee) && - helpers.isTanstackQueryImport(node.callee) - ) { const options = node.arguments[0] if (options === undefined) { return } - if (ASTUtils.isIdentifierWithOneOfNames(node.callee, queryHooks)) { + if (queryHooks.includes(importedName)) { reportInlineQueryOptions(options) return } - if ( - ASTUtils.isIdentifierWithOneOfNames(node.callee, queriesHooks) && - ASTUtils.isObjectExpression(options) - ) { + if (queriesHooks.includes(importedName) && ASTUtils.isObjectExpression(options)) { const queries = ASTUtils.findPropertyWithIdentifierKey( options.properties, 'queries', @@ -141,7 +136,7 @@ export const rule = createRule({ return } - if (ASTUtils.isIdentifierWithOneOfNames(node.callee, filterHooks)) { + if (filterHooks.includes(importedName)) { reportInlineFilterQueryKey(options) } @@ -151,11 +146,7 @@ export const rule = createRule({ if ( node.callee.type !== AST_NODE_TYPES.MemberExpression || !ASTUtils.isIdentifier(node.callee.property) || - !isTanstackQueryClient( - node.callee.object, - helpers, - queryClientVariables, - ) + !isTanstackQueryClient(node.callee.object, context, helpers) ) { return } @@ -174,7 +165,7 @@ export const rule = createRule({ if ( queryClientQueryKeyMethods.includes(method) && - options.type === AST_NODE_TYPES.ArrayExpression + isInlineArrayExpression(options) ) { context.report({ node: options, @@ -201,10 +192,16 @@ function hasInlineQueryOptions(node: TSESTree.ObjectExpression): boolean { } function hasInlineFilterQueryKey(node: TSESTree.ObjectExpression): boolean { - return ( - ASTUtils.findPropertyWithIdentifierKey(node.properties, 'queryKey')?.value - .type === AST_NODE_TYPES.ArrayExpression - ) + const queryKey = ASTUtils.findPropertyWithIdentifierKey( + node.properties, + 'queryKey', + )?.value + + return queryKey !== undefined && isInlineArrayExpression(queryKey) +} + +function isInlineArrayExpression(node: TSESTree.Node): boolean { + return unwrapTypeAssertions(node).type === AST_NODE_TYPES.ArrayExpression } function getReturnedObjectExpressions( @@ -290,32 +287,95 @@ function getQueryObjects( function isTanstackQueryClient( node: TSESTree.Node, - helpers: { - isTanstackQueryImport: (node: TSESTree.Identifier) => boolean - }, - queryClientVariables: Set, + context: Readonly>>, + helpers: Helpers, ): boolean { - if (node.type === AST_NODE_TYPES.Identifier) { - return queryClientVariables.has(node.name) + const source = resolveQueryClientSource(node, context) + + if ( + source.type === AST_NODE_TYPES.CallExpression && + ASTUtils.isIdentifier(source.callee) + ) { + return getTanstackImportName(context, helpers, source.callee) === 'useQueryClient' } if ( - node.type === AST_NODE_TYPES.CallExpression && - ASTUtils.isIdentifierWithName(node.callee, 'useQueryClient') + source.type === AST_NODE_TYPES.NewExpression && + ASTUtils.isIdentifier(source.callee) ) { - return helpers.isTanstackQueryImport(node.callee) + return getTanstackImportName(context, helpers, source.callee) === 'QueryClient' + } + + return false +} + +function getTanstackImportName( + context: Readonly>>, + helpers: Helpers, + node: TSESTree.Identifier, +): string | null { + if (!helpers.isTanstackQueryImport(node)) { + return null } + const definition = context.sourceCode + .getScope(node) + .references.find((reference) => reference.identifier === node) + ?.resolved?.defs[0]?.node + if ( - node.type === AST_NODE_TYPES.NewExpression && - ASTUtils.isIdentifierWithName(node.callee, 'QueryClient') + definition?.type !== AST_NODE_TYPES.ImportSpecifier || + definition.imported.type !== AST_NODE_TYPES.Identifier ) { - return helpers.isTanstackQueryImport(node.callee) + return null + } + + return definition.imported.name +} + +function resolveQueryClientSource( + node: TSESTree.Node, + context: Readonly>>, +): TSESTree.Node { + const visitedNodes = new Set() + + while (!visitedNodes.has(node)) { + visitedNodes.add(node) + + if (node.type === AST_NODE_TYPES.ChainExpression) { + node = node.expression + continue + } + + node = unwrapTypeAssertions(node) + + if (node.type !== AST_NODE_TYPES.Identifier) { + return node + } + + const expression = ASTUtils.getReferencedExpressionByIdentifier({ + context, + node, + }) + + if (expression === null) { + return node + } + + node = expression } - if (node.type === AST_NODE_TYPES.ChainExpression) { - return isTanstackQueryClient(node.expression, helpers, queryClientVariables) + return node +} + +function unwrapTypeAssertions(node: TSESTree.Node): TSESTree.Node { + while ( + node.type === AST_NODE_TYPES.TSAsExpression || + node.type === AST_NODE_TYPES.TSSatisfiesExpression || + node.type === AST_NODE_TYPES.TSTypeAssertion + ) { + node = node.expression } - return false + return node } From aec9ed516d05aa0d86ebc1df20eedd74e8d45158 Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Tue, 31 Mar 2026 12:12:06 +0300 Subject: [PATCH 5/6] update example --- .../react/eslint-plugin-demo/eslint.config.js | 2 +- .../src/prefer-query-options-demo.tsx | 49 +++++++++++++++++++ .../react/eslint-plugin-demo/src/queries.tsx | 23 +++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx create mode 100644 examples/react/eslint-plugin-demo/src/queries.tsx diff --git a/examples/react/eslint-plugin-demo/eslint.config.js b/examples/react/eslint-plugin-demo/eslint.config.js index 1e9a5820b5d..85fa95bb76b 100644 --- a/examples/react/eslint-plugin-demo/eslint.config.js +++ b/examples/react/eslint-plugin-demo/eslint.config.js @@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint' export default [ ...tseslint.configs.recommended, - ...pluginQuery.configs['flat/recommended'], + ...pluginQuery.configs['flat/recommended-strict'], { files: ['src/**/*.ts', 'src/**/*.tsx'], rules: { diff --git a/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx b/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx new file mode 100644 index 00000000000..d992faa55a8 --- /dev/null +++ b/examples/react/eslint-plugin-demo/src/prefer-query-options-demo.tsx @@ -0,0 +1,49 @@ +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { todoOptions, todosOptions } from './queries' + +// ✅ passes: consuming imported queryOptions result directly +export function Todos() { + const query = useQuery(todosOptions) + return query.data +} + +// ✅ passes: spreading imported queryOptions result with additional options +export function Todo({ id }: { id: string }) { + const query = useQuery({ + ...todoOptions(id), + select: (data) => data.title, + }) + return query.data +} + +// ✅ passes: referencing queryKey from imported queryOptions result +export function invalidateTodos() { + const queryClient = useQueryClient() + queryClient.invalidateQueries({ queryKey: todosOptions.queryKey }) +} + +// ❌ fails: inline queryKey + queryFn should use queryOptions() +export function TodosBad() { + const query = useQuery( + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer using queryOptions() or infiniteQueryOptions() to co-locate queryKey and queryFn. + { + queryKey: ['todos'], + queryFn: () => fetch('/api/todos').then((r) => r.json()), + }, + ) + return query.data +} + +// ❌ fails: inline queryKey should reference queryOptions result +export function InvalidateTodosBad() { + const queryClient = useQueryClient() + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer referencing a queryKey from a queryOptions() result instead of typing it manually. + queryClient.invalidateQueries({ queryKey: ['todos'] }) +} + +// ❌ fails: inline queryKey as direct parameter +export function GetTodosDataBad() { + const queryClient = useQueryClient() + // eslint-disable-next-line @tanstack/query/prefer-query-options -- Prefer referencing a queryKey from a queryOptions() result instead of typing it manually. + return queryClient.getQueryData(['todos']) +} diff --git a/examples/react/eslint-plugin-demo/src/queries.tsx b/examples/react/eslint-plugin-demo/src/queries.tsx new file mode 100644 index 00000000000..46a86734eb8 --- /dev/null +++ b/examples/react/eslint-plugin-demo/src/queries.tsx @@ -0,0 +1,23 @@ +import { queryOptions } from '@tanstack/react-query' + +// ✅ passes: queryKey and queryFn are co-located via queryOptions() +export const todosOptions = queryOptions({ + queryKey: ['todos'], + queryFn: () => fetchTodos(), +}) + +// ✅ passes: factory function wrapping queryOptions() +export const todoOptions = (id: string) => + queryOptions({ + queryKey: ['todo', id], + queryFn: () => fetchTodo(id), + }) + +function fetchTodos(): Promise> { + throw new Error('not implemented') +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +function fetchTodo(id: string): Promise<{ id: string; title: string }> { + throw new Error('not implemented') +} From c46f6fea037500025c542a7a15043ca5cdf89fef Mon Sep 17 00:00:00 2001 From: Eliya Cohen Date: Tue, 31 Mar 2026 12:26:23 +0300 Subject: [PATCH 6/6] add coverage --- .../src/__tests__/prefer-query-options.test.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts index 396be9da8b4..ff5962623ba 100644 --- a/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts +++ b/packages/eslint-plugin-query/src/__tests__/prefer-query-options.test.ts @@ -72,6 +72,18 @@ describe('prefer-query-options', () => { } `, }, + { + name: 'useQuery with imported queryOptions function call is allowed', + code: normalizeIndent` + import { useQuery } from '@tanstack/react-query' + import { getFooOptions } from './foo' + + function Component({ id }) { + const query = useQuery(getFooOptions(id)) + return null + } + `, + }, { name: 'useQuery spreading queryOptions result is allowed', code: normalizeIndent`