Skip to content
Merged
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/prefer-query-options-rule.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 and `recommendedStrict` config
4 changes: 4 additions & 0 deletions docs/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -1293,6 +1293,10 @@
{
"label": "Mutation Property Order",
"to": "eslint/mutation-property-order"
},
{
"label": "Prefer Query Options",
"to": "eslint/prefer-query-options"
}
]
},
Expand Down
24 changes: 24 additions & 0 deletions docs/eslint/eslint-plugin-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -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)
145 changes: 145 additions & 0 deletions docs/eslint/prefer-query-options.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
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(id) {
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" */

function getFooOptions(id) {
return queryOptions({
queryKey: ['get', id],
queryFn: () => Api.get(`/foo/${id}`),
})
}

function Component({ id }) {
const query = useQuery(getFooOptions(id))
// ...
}
```

```tsx
/* eslint "@tanstack/query/prefer-query-options": "error" */

function getFooOptions(id) {
return queryOptions({
queryKey: ['get', id],
queryFn: () => Api.get(`/foo/${id}`),
})
}

function useFooQuery(id) {
return useQuery({ ...getFooOptions(id), 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" */

function todoOptions(id) {
return queryOptions({
queryKey: ['todo', id],
queryFn: () => api.getTodo(id),
})
}

function Component({ id }) {
const queryClient = useQueryClient()
return queryClient.getQueryData(['todo', id])
}
```

```tsx
/* eslint "@tanstack/query/prefer-query-options": "error" */

function todoOptions(id) {
return queryOptions({
queryKey: ['todo', id],
queryFn: () => api.getTodo(id),
})
}

function Component({ id }) {
const queryClient = useQueryClient()
return queryClient.invalidateQueries({ queryKey: ['todo', id] })
}
```

Examples of **correct** `queryKey` references for this rule:

```tsx
/* eslint "@tanstack/query/prefer-query-options": "error" */

function todoOptions(id) {
return queryOptions({
queryKey: ['todo', id],
queryFn: () => api.getTodo(id),
})
}

function Component({ id }) {
const queryClient = useQueryClient()
return queryClient.getQueryData(todoOptions(id).queryKey)
}
```

```tsx
/* eslint "@tanstack/query/prefer-query-options": "error" */

function todoOptions(id) {
return queryOptions({
queryKey: ['todo', id],
queryFn: () => api.getTodo(id),
})
}

function Component({ id }) {
const queryClient = useQueryClient()
return queryClient.invalidateQueries({ queryKey: todoOptions(id).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
2 changes: 1 addition & 1 deletion examples/react/eslint-plugin-demo/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -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'])
}
23 changes: 23 additions & 0 deletions examples/react/eslint-plugin-demo/src/queries.tsx
Original file line number Diff line number Diff line change
@@ -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<Array<{ id: string; title: string }>> {
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')
}
Loading
Loading