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/afraid-walls-glow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tanstack/eslint-plugin-query": minor
---

feat(eslint-plugin-query): add new rule for SSR QueryClient setup
71 changes: 71 additions & 0 deletions docs/eslint/no-module-query-client.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
id: no-module-query-client
title: Disallow module-level QueryClient in Next.js
---

When doing server-side rendering with Next.js, it's critical to create the QueryClient instance inside your component (using React state or a ref), not at the module level. Creating the QueryClient at the file root makes the cache shared between all requests and users, which is bad for performance and can leak sensitive data.

> This rule only applies to Next.js projects (files in `pages/` or `app/` directories, or files named `_app` or `_document`).

## Rule Details

Examples of **incorrect** code for this rule:

```tsx
// pages/_app.tsx
/* eslint "@tanstack/query/no-module-query-client": "error" */

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

// NEVER DO THIS:
// Creating the queryClient at the file root level makes the cache shared
// between all requests and means _all_ data gets passed to _all_ users.
// Besides being bad for performance, this also leaks any sensitive data.
const queryClient = new QueryClient()

export default function MyApp({ Component, pageProps }) {
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}
```

Examples of **correct** code for this rule:

```tsx
// pages/_app.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { useState } from 'react'

export default function MyApp({ Component, pageProps }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
// With SSR, we usually want to set some default staleTime
// above 0 to avoid refetching immediately on the client
staleTime: 60 * 1000,
},
},
}),
)

return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
</QueryClientProvider>
)
}
```

## When Not To Use It

This rule is specifically designed for Next.js applications. If you're not using Next.js or server-side rendering, this rule may not be necessary. The rule only runs on files that appear to be in a Next.js project structure (`pages/`, `app/`, `_app`, `_document`).

## Attributes

- [x] ✅ Recommended
- [ ] 🔧 Fixable
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
import { RuleTester } from '@typescript-eslint/rule-tester'
import { rule } from '../rules/no-module-query-client/no-module-query-client.rule'
import { normalizeIndent } from './test-utils'

const ruleTester = new RuleTester()

ruleTester.run('no-module-query-client', rule, {
valid: [
{
name: 'QueryClient created with React.useState in _app.tsx',
filename: 'pages/_app.tsx',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
import React from "react";

export default function MyApp({ Component, pageProps }) {
const [queryClient] = React.useState(() => new QueryClient());
return null;
}
`,
},
{
name: 'QueryClient created with useState in _app.tsx',
filename: 'pages/_app.tsx',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
import { useState } from "react";

export default function MyApp({ Component, pageProps }) {
const [queryClient] = useState(() => new QueryClient());
return null;
}
`,
},
{
name: 'QueryClient created with React.useRef in pages directory',
filename: 'pages/_app.tsx',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
import React from "react";

export default function MyApp({ Component, pageProps }) {
const queryClient = React.useRef(new QueryClient());
return null;
}
`,
},
{
name: 'QueryClient created in app directory with useState',
filename: 'app/layout.tsx',
code: normalizeIndent`
'use client';
import { QueryClient } from "@tanstack/react-query";
import { useState } from "react";

export default function RootLayout({ children }) {
const [queryClient] = useState(() => new QueryClient());
return null;
}
`,
},
{
name: 'QueryClient from different package at module level',
filename: 'pages/_app.tsx',
code: normalizeIndent`
import { QueryClient } from "some-other-package";

const queryClient = new QueryClient();

export default function MyApp() {
return null;
}
`,
},
{
name: 'QueryClient at module level in non-Next.js file',
filename: 'src/utils/query.ts',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

export { queryClient };
`,
},
{
name: 'QueryClient in custom hook',
filename: 'pages/custom-hook.ts',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";
import { useState } from "react";

export function useQueryClient() {
const [queryClient] = useState(() => new QueryClient());
return queryClient;
}
`,
},
{
name: 'QueryClient from solid-query at module level in pages',
filename: 'pages/_app.tsx',
code: normalizeIndent`
import { QueryClient } from "@tanstack/solid-query";

const queryClient = new QueryClient();

export default function MyApp() {
return null;
}
`,
},
],
invalid: [
{
name: 'QueryClient created at module level in _app.tsx',
filename: 'pages/_app.tsx',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function MyApp({ Component, pageProps }) {
return null;
}
`,
errors: [
{
messageId: 'noModuleQueryClient',
},
],
},
{
name: 'QueryClient created at module level in _document.tsx',
filename: 'pages/_document.tsx',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function Document() {
return null;
}
`,
errors: [
{
messageId: 'noModuleQueryClient',
},
],
},
{
name: 'QueryClient created at module level in app directory',
filename: 'app/layout.tsx',
code: normalizeIndent`
'use client';
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function RootLayout({ children }) {
return null;
}
`,
errors: [
{
messageId: 'noModuleQueryClient',
},
],
},
{
name: 'QueryClient created in pages directory page',
filename: 'pages/index.tsx',
code: normalizeIndent`
import { QueryClient } from "@tanstack/react-query";

const queryClient = new QueryClient();

export default function HomePage() {
return null;
}
`,
errors: [
{
messageId: 'noModuleQueryClient',
},
],
},
{
name: 'QueryClient created in app router page',
filename: 'app/dashboard/page.tsx',
code: normalizeIndent`
'use client';
import { QueryClient } from "@tanstack/react-query";

const client = new QueryClient();

export default function Dashboard() {
return null;
}
`,
errors: [
{
messageId: 'noModuleQueryClient',
},
],
},
],
})
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 noModuleQueryClient from './rules/no-module-query-client/no-module-query-client.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,
[noModuleQueryClient.name]: noModuleQueryClient.rule,
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { AST_NODE_TYPES, ESLintUtils } from '@typescript-eslint/utils'
import { ASTUtils } from '../../utils/ast-utils'
import { getDocsUrl } from '../../utils/get-docs-url'
import { detectTanstackQueryImports } from '../../utils/detect-react-query-imports'
import type { ExtraRuleDocs } from '../../types'

export const name = 'no-module-query-client'

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

const isNextJsFile = (fileName: string): boolean => {
return (
fileName.includes('_app.') ||
fileName.includes('_document.') ||
/(?:^|[/\\])(?:app|pages)[/\\]/.test(fileName)
)
}

export const rule = createRule({
name,
meta: {
type: 'problem',
docs: {
description:
'Disallow module-level QueryClient in Next.js to prevent cache sharing',
recommended: 'error',
},
messages: {
noModuleQueryClient:
'QueryClient should not be created at module level in Next.js apps. Create it inside your component using useState or useRef.',
},
schema: [],
},
defaultOptions: [],

create: detectTanstackQueryImports((context, _, helpers) => {
const fileName = context.filename

if (!isNextJsFile(fileName)) {
return {}
}

return {
NewExpression: (node) => {
if (
node.callee.type !== AST_NODE_TYPES.Identifier ||
node.callee.name !== 'QueryClient' ||
!helpers.isSpecificTanstackQueryImport(
node.callee,
'@tanstack/react-query',
)
) {
return
}

const component = ASTUtils.getFunctionAncestor(context.sourceCode, node)

if (component === undefined) {
context.report({
node,
messageId: 'noModuleQueryClient',
})
}
},
}
}),
})