diff --git a/docs/eslint/eslint-plugin-query.md b/docs/eslint/eslint-plugin-query.md
index bc092f3b77..834a3ac751 100644
--- a/docs/eslint/eslint-plugin-query.md
+++ b/docs/eslint/eslint-plugin-query.md
@@ -46,6 +46,21 @@ export default [
]
```
+### Recommended type-checked setup
+
+If you're using TypeScript and want to enable rules that require type information, you can use the `flat/recommendedTypeChecked` config:
+
+```js
+import pluginQuery from '@tanstack/eslint-plugin-query'
+
+export default [
+ ...pluginQuery.configs['flat/recommendedTypeChecked'],
+ // Any other config...
+]
+```
+
+> âšī¸ This setup requires type-aware linting. You can follow the [TypeScript ESLint documentation on type-checking](https://typescript-eslint.io/linting/typed-linting/) to set up your ESLint config accordingly.
+
### Custom setup
Alternatively, you can load the plugin and configure only the rules you want to use:
diff --git a/docs/eslint/no-void-query-fn.md b/docs/eslint/no-void-query-fn.md
index 445c437751..baf9a06080 100644
--- a/docs/eslint/no-void-query-fn.md
+++ b/docs/eslint/no-void-query-fn.md
@@ -36,4 +36,5 @@ useQuery({
## Attributes
- [x] â
Recommended
+- [x] đ Type checked
- [ ] đ§ Fixable
diff --git a/examples/react/eslint-type-checked/.gitignore b/examples/react/eslint-type-checked/.gitignore
new file mode 100644
index 0000000000..4673b022e5
--- /dev/null
+++ b/examples/react/eslint-type-checked/.gitignore
@@ -0,0 +1,27 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+pnpm-lock.yaml
+yarn.lock
+package-lock.json
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/examples/react/eslint-type-checked/README.md b/examples/react/eslint-type-checked/README.md
new file mode 100644
index 0000000000..1cf8892652
--- /dev/null
+++ b/examples/react/eslint-type-checked/README.md
@@ -0,0 +1,6 @@
+# Example
+
+To run this example:
+
+- `npm install`
+- `npm run dev`
diff --git a/examples/react/eslint-type-checked/eslint.config.js b/examples/react/eslint-type-checked/eslint.config.js
new file mode 100644
index 0000000000..28f056b6bb
--- /dev/null
+++ b/examples/react/eslint-type-checked/eslint.config.js
@@ -0,0 +1,20 @@
+// @ts-check
+
+import eslint from '@eslint/js'
+import tseslint from 'typescript-eslint'
+import pluginQuery from '@tanstack/eslint-plugin-query'
+
+export default tseslint.config(
+ eslint.configs.recommended,
+ tseslint.configs.recommended,
+ pluginQuery.configs['flat/recommendedTypeChecked'],
+ {
+ files: ['**/*.ts', '**/*.tsx'],
+ languageOptions: {
+ parserOptions: {
+ projectService: true,
+ tsconfigRootDir: import.meta.dirname,
+ },
+ },
+ },
+)
diff --git a/examples/react/eslint-type-checked/index.html b/examples/react/eslint-type-checked/index.html
new file mode 100644
index 0000000000..d7c231330c
--- /dev/null
+++ b/examples/react/eslint-type-checked/index.html
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+ TanStack Query React Basic Example App
+
+
+ You need to enable JavaScript to run this app.
+
+
+
+
diff --git a/examples/react/eslint-type-checked/package.json b/examples/react/eslint-type-checked/package.json
new file mode 100644
index 0000000000..65740bb9f4
--- /dev/null
+++ b/examples/react/eslint-type-checked/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@tanstack/query-example-react-eslint-type-checked",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/query-sync-storage-persister": "^5.71.10",
+ "@tanstack/react-query": "^5.71.10",
+ "@tanstack/react-query-devtools": "^5.71.10",
+ "@tanstack/react-query-persist-client": "^5.71.10",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@tanstack/eslint-plugin-query": "^5.71.5",
+ "@types/react": "^18.2.79",
+ "@types/react-dom": "^18.2.25",
+ "@vitejs/plugin-react": "^4.3.4",
+ "typescript": "5.8.2",
+ "vite": "^6.2.4"
+ }
+}
diff --git a/examples/react/eslint-type-checked/public/emblem-light.svg b/examples/react/eslint-type-checked/public/emblem-light.svg
new file mode 100644
index 0000000000..a58e69ad5e
--- /dev/null
+++ b/examples/react/eslint-type-checked/public/emblem-light.svg
@@ -0,0 +1,13 @@
+
+
+
+ emblem-light
+ Created with Sketch.
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/examples/react/eslint-type-checked/src/index.tsx b/examples/react/eslint-type-checked/src/index.tsx
new file mode 100644
index 0000000000..052ce4c797
--- /dev/null
+++ b/examples/react/eslint-type-checked/src/index.tsx
@@ -0,0 +1,161 @@
+import * as React from 'react'
+import ReactDOM from 'react-dom/client'
+import { QueryClient, useQuery, useQueryClient } from '@tanstack/react-query'
+import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
+import { createSyncStoragePersister } from '@tanstack/query-sync-storage-persister'
+import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: {
+ gcTime: 1000 * 60 * 60 * 24, // 24 hours
+ },
+ },
+})
+
+const persister = createSyncStoragePersister({
+ storage: window.localStorage,
+})
+
+type Post = {
+ id: number
+ title: string
+ body: string
+}
+
+function usePosts() {
+ return useQuery({
+ queryKey: ['posts'],
+ queryFn: async (): Promise> => {
+ const response = await fetch('https://jsonplaceholder.typicode.com/posts')
+ return await response.json()
+ },
+ })
+}
+
+function Posts({
+ setPostId,
+}: {
+ setPostId: React.Dispatch>
+}) {
+ const queryClient = useQueryClient()
+ const { status, data, error, isFetching } = usePosts()
+
+ return (
+
+
Posts
+
+ {status === 'pending' ? (
+ 'Loading...'
+ ) : status === 'error' ? (
+
Error: {error.message}
+ ) : (
+ <>
+
+
{isFetching ? 'Background Updating...' : ' '}
+ >
+ )}
+
+
+ )
+}
+
+const getPostById = async (id: number): Promise => {
+ const response = await fetch(
+ `https://jsonplaceholder.typicode.com/posts/${id}`,
+ )
+ return await response.json()
+}
+
+function usePost(postId: number) {
+ return useQuery({
+ queryKey: ['post', postId],
+ queryFn: () => getPostById(postId),
+ enabled: !!postId,
+ })
+}
+
+function Post({
+ postId,
+ setPostId,
+}: {
+ postId: number
+ setPostId: React.Dispatch>
+}) {
+ const { status, data, error, isFetching } = usePost(postId)
+
+ return (
+
+
+ {!postId || status === 'pending' ? (
+ 'Loading...'
+ ) : status === 'error' ? (
+
Error: {error.message}
+ ) : (
+ <>
+
{data.title}
+
+
{isFetching ? 'Background Updating...' : ' '}
+ >
+ )}
+
+ )
+}
+
+function App() {
+ const [postId, setPostId] = React.useState(-1)
+
+ return (
+
+
+ As you visit the posts below, you will notice them in a loading state
+ the first time you load them. However, after you return to this list and
+ click on any posts you have already visited again, you will see them
+ load instantly and background refresh right before your eyes!{' '}
+
+ (You may need to throttle your network speed to simulate longer
+ loading sequences)
+
+
+ {postId > -1 ? (
+
+ ) : (
+
+ )}
+
+
+ )
+}
+
+const rootElement = document.getElementById('root') as HTMLElement
+ReactDOM.createRoot(rootElement).render( )
diff --git a/examples/react/eslint-type-checked/tsconfig.json b/examples/react/eslint-type-checked/tsconfig.json
new file mode 100644
index 0000000000..bb58f59b5b
--- /dev/null
+++ b/examples/react/eslint-type-checked/tsconfig.json
@@ -0,0 +1,24 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ /* Bundler mode */
+ "moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ /* Linting */
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["src", "eslint.config.js", "vite.config.ts"]
+}
diff --git a/examples/react/eslint-type-checked/vite.config.ts b/examples/react/eslint-type-checked/vite.config.ts
new file mode 100644
index 0000000000..9ffcc67574
--- /dev/null
+++ b/examples/react/eslint-type-checked/vite.config.ts
@@ -0,0 +1,6 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+
+export default defineConfig({
+ plugins: [react()],
+})
diff --git a/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts b/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts
index c136a2582c..a6a36e42f4 100644
--- a/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts
+++ b/packages/eslint-plugin-query/src/__tests__/no-void-query-fn.test.ts
@@ -8,7 +8,7 @@ RuleTester.afterAll = afterAll
RuleTester.describe = describe
RuleTester.it = it
-const ruleTester = new RuleTester({
+const ruleTesterTypeChecked = new RuleTester({
languageOptions: {
parser: await import('@typescript-eslint/parser'),
parserOptions: {
@@ -18,7 +18,7 @@ const ruleTester = new RuleTester({
},
})
-ruleTester.run('no-void-query-fn', rule, {
+ruleTesterTypeChecked.run('no-void-query-fn', rule, {
valid: [
{
name: 'queryFn returns a value',
@@ -323,3 +323,31 @@ ruleTester.run('no-void-query-fn', rule, {
},
],
})
+
+const ruleTester = new RuleTester({
+ languageOptions: {
+ parser: await import('@typescript-eslint/parser'),
+ },
+})
+
+ruleTester.run('no-void-query-fn with no program', rule, {
+ valid: [],
+ invalid: [
+ {
+ name: 'queryFn returns void',
+ code: normalizeIndent`
+ import { useQuery } from '@tanstack/react-query'
+ function Component() {
+ const query = useQuery({
+ queryKey: ['test'],
+ queryFn: () => {
+ console.log('test')
+ },
+ })
+ return null
+ }
+ `,
+ errors: [{ messageId: 'noProgram' }],
+ },
+ ],
+})
diff --git a/packages/eslint-plugin-query/src/index.ts b/packages/eslint-plugin-query/src/index.ts
index f16cb985b8..7d83ac573d 100644
--- a/packages/eslint-plugin-query/src/index.ts
+++ b/packages/eslint-plugin-query/src/index.ts
@@ -1,6 +1,7 @@
import { rules } from './rules'
import type { ESLint, Linter } from 'eslint'
import type { RuleModule } from '@typescript-eslint/utils/ts-eslint'
+import type { TSESLint } from '@typescript-eslint/utils'
type RuleKey = keyof typeof rules
@@ -8,7 +9,9 @@ export interface Plugin extends Omit {
rules: Record>
configs: {
recommended: ESLint.ConfigData
+ recommendedTypeChecked: ESLint.ConfigData
'flat/recommended': Array
+ 'flat/recommendedTypeChecked': Array
}
}
@@ -20,35 +23,46 @@ const plugin: Plugin = {
rules,
}
+const rulesRecord: TSESLint.SharedConfig.RulesRecord = {
+ '@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',
+}
+
+const rulesTypeCheckedRecord: TSESLint.SharedConfig.RulesRecord = {
+ ...rulesRecord,
+ '@tanstack/query/no-void-query-fn': 'error',
+}
+
// Assign configs here so we can reference `plugin`
Object.assign(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',
- },
+ rules: rulesRecord,
+ },
+ recommendedTypeChecked: {
+ plugins: ['@tanstack/query'],
+ rules: rulesTypeCheckedRecord,
},
'flat/recommended': [
{
name: 'tanstack/query/flat/recommended',
- plugins: {
- '@tanstack/query': plugin,
- },
- 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',
- },
+ plugins: { '@tanstack/query': plugin },
+ rules: rulesRecord,
+ },
+ ],
+ 'flat/recommendedTypeChecked': [
+ {
+ name: 'tanstack/query/flat/recommendedTypeChecked',
+ plugins: { '@tanstack/query': plugin },
+ rules: rulesTypeCheckedRecord,
},
],
-})
+} satisfies Record<
+ string,
+ TSESLint.FlatConfig.ConfigArray | TSESLint.ClassicConfig.Config
+>)
export default plugin
diff --git a/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts b/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts
index 9c202e621a..cdb7a67a96 100644
--- a/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts
+++ b/packages/eslint-plugin-query/src/rules/no-void-query-fn/no-void-query-fn.rule.ts
@@ -19,6 +19,7 @@ export const rule = createRule({
},
messages: {
noVoidReturn: 'queryFn must return a non-undefined value',
+ noProgram: `Type information is not available for this file. See https://typescript-eslint.io/getting-started/typed-linting/ for how to set this up.`,
},
schema: [],
},
@@ -41,7 +42,10 @@ export const rule = createRule({
!parserServices.esTreeNodeToTSNodeMap ||
!parserServices.program
) {
- return
+ return context.report({
+ node: node.value,
+ messageId: 'noProgram',
+ })
}
const checker = parserServices.program.getTypeChecker()
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5b2acc6a7e..a77da9520e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -809,6 +809,46 @@ importers:
specifier: ^6.2.4
version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.29.2)(sass@1.86.0)(terser@5.39.0)(yaml@2.6.1)
+ examples/react/eslint-type-checked:
+ dependencies:
+ '@tanstack/query-sync-storage-persister':
+ specifier: workspace:*
+ version: link:../../../packages/query-sync-storage-persister
+ '@tanstack/react-query':
+ specifier: workspace:*
+ version: link:../../../packages/react-query
+ '@tanstack/react-query-devtools':
+ specifier: workspace:*
+ version: link:../../../packages/react-query-devtools
+ '@tanstack/react-query-persist-client':
+ specifier: workspace:*
+ version: link:../../../packages/react-query-persist-client
+ react:
+ specifier: ^19.0.0
+ version: 19.0.0
+ react-dom:
+ specifier: ^19.0.0
+ version: 19.0.0(react@19.0.0)
+ devDependencies:
+ '@tanstack/eslint-plugin-query':
+ specifier: workspace:*
+ version: link:../../../packages/eslint-plugin-query
+ '@types/react':
+ specifier: ^19.0.1
+ version: 19.0.1
+ '@types/react-dom':
+ specifier: ^19.0.2
+ version: 19.0.2(@types/react@19.0.1)
+ '@vitejs/plugin-react':
+ specifier: ^4.3.4
+ version: 4.3.4(vite@6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.29.2)(sass@1.86.0)(terser@5.39.0)(yaml@2.6.1))
+ typescript:
+ specifier: 5.8.2
+ version: 5.8.2
+ vite:
+ specifier: ^6.2.4
+ version: 6.2.5(@types/node@22.14.0)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.29.2)(sass@1.86.0)(terser@5.39.0)(yaml@2.6.1)
+
examples/react/infinite-query-with-max-pages:
dependencies:
'@tanstack/react-query':