Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/perky-comics-dig.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/eslint-plugin-query': minor
---

Add prefer-query-options rule
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { RuleTester } from '@typescript-eslint/rule-tester'
import { afterAll, describe, it } from 'vitest'
import { rule } from '../rules/prefer-query-options/prefer-query-options.rule'

const ruleTester = new RuleTester()

RuleTester.afterAll = afterAll
RuleTester.describe = describe
RuleTester.it = it

// useQuery hooks
ruleTester.run(rule.name, rule, {
valid: [
{ code: `useQuery(usersQuery)` },
{ code: `useQuery({ ...usersQuery })` },
{ code: `useQuery({ ...usersQuery() })` },
{ code: `useQuery({ ...usersQuery, meta: {} })` },
],
invalid: [
{
code: `useQuery({ queryKey: [] })`,
errors: [{ messageId: 'no-inline-query-hook' }],
},
{
code: `const users = useQuery({ ...queryOptions, queryKey: [] })`,
errors: [{ messageId: 'no-inline-query-hook' }],
},
{
code: `const users = useQuery({ queryFn: () => {} })`,
errors: [{ messageId: 'no-inline-query-hook' }],
},
{
code: `const users = useQuery({ ...queryOptions, queryFn: () => {} })`,
errors: [{ messageId: 'no-inline-query-hook' }],
},
{
code: `useInfiniteQuery({ queryKey: [] })`,
errors: [{ messageId: 'no-inline-query-hook' }],
},
{
code: `useSuspenseQuery({ queryKey: [] })`,
errors: [{ messageId: 'no-inline-query-hook' }],
},
{
code: `useSuspenseInfiniteQuery({ queryKey: [] })`,
errors: [{ messageId: 'no-inline-query-hook' }],
},
],
})

// queryClient.invalidateQueries expressions
ruleTester.run(rule.name, rule, {
valid: [
{ code: `queryClient.invalidateQueries(usersQuery)` },
{ code: `queryClient.invalidateQueries({ ...usersQuery })` },
{ code: `queryClient.invalidateQueries({ ...usersQuery() })` },
],
invalid: [
{
code: `queryClient.invalidateQueries({ queryKey: [] })`,
errors: [{ messageId: 'no-inline-query-invalidate' }],
},
{
code: `queryClient.invalidateQueries({ ...queryOptions, queryKey: [] })`,
errors: [{ messageId: 'no-inline-query-invalidate' }],
},
{
code: `queryClient.invalidateQueries({ queryFn: () => {} })`,
errors: [{ messageId: 'no-inline-query-invalidate' }],
},
{
code: `queryClient.invalidateQueries({ ...queryOptions, queryFn: () => {} })`,
errors: [{ messageId: 'no-inline-query-invalidate' }],
},
],
})
20 changes: 20 additions & 0 deletions packages/eslint-plugin-query/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface Plugin extends Omit<ESLint.Plugin, 'rules'> {
configs: {
recommended: ESLint.ConfigData
'flat/recommended': Array<Linter.Config>
'flat/recommendedStrict': Array<Linter.Config>
}
}

Expand Down Expand Up @@ -46,10 +47,29 @@ export const plugin = {
},
},
],
'flat/recommendedStrict': [
{
name: 'tanstack/query/flat/recommendedStrict',
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',
'@tanstack/query/prefer-query-options': 'error',
},
},
],
},
rules,
} satisfies Plugin

plugin.configs['flat/recommended'][0]!.plugins['@tanstack/query'] = plugin
plugin.configs['flat/recommendedStrict'][0]!.plugins['@tanstack/query'] = plugin

export default plugin
2 changes: 2 additions & 0 deletions packages/eslint-plugin-query/src/rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -24,4 +25,5 @@ export const rules: Record<
[infiniteQueryPropertyOrder.name]: infiniteQueryPropertyOrder.rule,
[noVoidQueryFn.name]: noVoidQueryFn.rule,
[mutationPropertyOrder.name]: mutationPropertyOrder.rule,
[preferQueryOptions.name]: preferQueryOptions.rule,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { getDocsUrl } from '../../utils/get-docs-url'
import { detectQueryOptionsInObject } from './prefer-query-options.utils'
import type { TSESTree } from '@typescript-eslint/utils'
import type { ExtraRuleDocs } from '../../types'

export const name = 'prefer-query-options'

const useQueryHooks = [
// see https://tanstack.com/query/latest/docs/framework/react/reference/useQuery
'useQuery',
// 'useQueries', // only works for single queries for now
'useInfiniteQuery',
'useSuspenseQuery',
// 'useSuspenseQueries',
'useSuspenseInfiniteQuery',
]

const createRule = ESLintUtils.RuleCreator<ExtraRuleDocs>(getDocsUrl)

/** @returns true if it's a `useQuery` hook call expression node */
function isQueryHookCallExpression(node: TSESTree.CallExpression) {
if (node.callee.type !== AST_NODE_TYPES.Identifier) return false
if (!useQueryHooks.includes(node.callee.name)) return false
return true
}

/** @returns true if it's a call to `queryClient.invalidateQueries` */
function isInvalidateQueriesCallExpression(node: TSESTree.CallExpression) {
return (
node.callee.type === AST_NODE_TYPES.MemberExpression &&
node.callee.object.type === AST_NODE_TYPES.Identifier &&
node.callee.object.name === 'queryClient' &&
node.callee.property.type === AST_NODE_TYPES.Identifier &&
node.callee.property.name === 'invalidateQueries'
)
}
Comment on lines +29 to +37
Copy link
Contributor

@coderabbitai coderabbitai bot Feb 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

isInvalidateQueriesCallExpression hardcodes the receiver to the variable name 'queryClient'.

This will silently miss every real-world pattern where the QueryClient instance has any other name:

const client = useQueryClient()
client.invalidateQueries({ queryKey: ['users'] }) // ❌ not caught

const qc = new QueryClient()
qc.invalidateQueries({ queryKey: ['users'] })     // ❌ not caught

The most conservative broadening that avoids type-information requirements is to match any MemberExpression whose property name is invalidateQueries, regardless of the object name. This does introduce a small risk of false positives from other libraries, but invalidateQueries is a sufficiently unique method name within the TanStack Query ecosystem.

🔧 Proposed fix
 function isInvalidateQueriesCallExpression(node: TSESTree.CallExpression) {
   return (
     node.callee.type === AST_NODE_TYPES.MemberExpression &&
-    node.callee.object.type === AST_NODE_TYPES.Identifier &&
-    node.callee.object.name === 'queryClient' &&
     node.callee.property.type === AST_NODE_TYPES.Identifier &&
     node.callee.property.name === 'invalidateQueries'
   )
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@packages/eslint-plugin-query/src/rules/prefer-query-options/prefer-query-options.rule.ts`
around lines 32 - 40, The helper isInvalidateQueriesCallExpression currently
only matches when the callee object is an Identifier named "queryClient"; update
it to detect any MemberExpression whose property is an Identifier named
"invalidateQueries" (keep the checks that callee.type ===
AST_NODE_TYPES.MemberExpression and callee.property.type ===
AST_NODE_TYPES.Identifier) and remove the strict check on callee.object.name, so
calls like client.invalidateQueries(...) or qc.invalidateQueries(...) are
recognized; ensure you still return false for non-member calls.

Copy link
Author

@danielpza danielpza Feb 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TkDodo @Newbie012 do you have recommendations here?

An alternative is to make this configurable.

Additionally we could skip this from the rule for the first iteration.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like the humans are having a chat. I'll hop back into my burrow for now. If you need me again, just tag @coderabbitai in a new comment, and I'll come hopping out!


export const rule = createRule({
name,
meta: {
type: 'suggestion',
docs: {
description:
'Ensures queryOptions constructor pattern is used when calling query apis',
recommended: 'strict',
},
messages: {
'no-inline-query-hook': 'Expected query hook to use queryOptions pattern',
'no-inline-query-invalidate':
'Expected query invalidate call to use queryOptions pattern',
},
schema: [],
},
defaultOptions: [],
create(context) {
return {
CallExpression(node) {
// use*Query hook call
if (
isQueryHookCallExpression(node) &&
node.arguments[0] &&
detectQueryOptionsInObject(node.arguments[0])
) {
context.report({ messageId: 'no-inline-query-hook', node })
}

// queryClient.invalidateQueries call
if (
isInvalidateQueriesCallExpression(node) &&
node.arguments[0] &&
detectQueryOptionsInObject(node.arguments[0])
) {
context.report({ messageId: 'no-inline-query-invalidate', node })
}
},
}
},
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { AST_NODE_TYPES } from '@typescript-eslint/utils'
import type { TSESTree } from '@typescript-eslint/utils'

const MAIN_QUERY_PROPERTIES = ['queryKey', 'queryFn']

/**
* @returns true if the node is an object that has main query options (ie queryKey or queryFn).
* This is used for detecting inline query options in hooks and functions
*/
export function detectQueryOptionsInObject(queryNode: TSESTree.Node): boolean {
// skip if it's not an object
if (queryNode.type !== AST_NODE_TYPES.ObjectExpression) return false

// check if any of the properties is queryKey or queryFn
const hasMainQueryProperties = queryNode.properties.find(
(property) =>
property.type === AST_NODE_TYPES.Property &&
property.key.type === AST_NODE_TYPES.Identifier &&
MAIN_QUERY_PROPERTIES.includes(property.key.name),
)

return !!hasMainQueryProperties
}