From e6e5cd4931eccc66b6fbc3d826fe6b8c5c288e43 Mon Sep 17 00:00:00 2001 From: Jack Leslie Date: Tue, 20 Jan 2026 10:32:01 +0000 Subject: [PATCH 1/3] feat: new eslint rule --- docs/eslint/no-module-query-client.md | 123 +++++++++++ .../__tests__/no-module-query-client.test.ts | 207 ++++++++++++++++++ packages/eslint-plugin-query/src/rules.ts | 2 + .../no-module-query-client.rule.ts | 67 ++++++ 4 files changed, 399 insertions(+) create mode 100644 docs/eslint/no-module-query-client.md create mode 100644 packages/eslint-plugin-query/src/__tests__/no-module-query-client.test.ts create mode 100644 packages/eslint-plugin-query/src/rules/no-module-query-client/no-module-query-client.rule.ts diff --git a/docs/eslint/no-module-query-client.md b/docs/eslint/no-module-query-client.md new file mode 100644 index 0000000000..56af352fc2 --- /dev/null +++ b/docs/eslint/no-module-query-client.md @@ -0,0 +1,123 @@ +--- +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 ( + + + + ) +} +``` + +```tsx +// pages/_app.tsx +/* eslint "@tanstack/query/no-module-query-client": "error" */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' + +export default function MyApp({ Component, pageProps }) { + // This is also incorrect - creating a new instance on every render + const queryClient = new QueryClient() + + return ( + + + + ) +} +``` + +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 ( + + + + ) +} +``` + +```tsx +// app/providers.tsx +'use client' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useState } from 'react' + +export function Providers({ children }: { children: React.ReactNode }) { + const [queryClient] = useState(() => new QueryClient()) + + return ( + {children} + ) +} +``` + +```tsx +// app/providers.tsx +'use client' + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { useRef } from 'react' + +export function Providers({ children }: { children: React.ReactNode }) { + const queryClientRef = useRef(new QueryClient()) + + return ( + + {children} + + ) +} +``` + +## 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 diff --git a/packages/eslint-plugin-query/src/__tests__/no-module-query-client.test.ts b/packages/eslint-plugin-query/src/__tests__/no-module-query-client.test.ts new file mode 100644 index 0000000000..086c308feb --- /dev/null +++ b/packages/eslint-plugin-query/src/__tests__/no-module-query-client.test.ts @@ -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', + }, + ], + }, + ], +}) diff --git a/packages/eslint-plugin-query/src/rules.ts b/packages/eslint-plugin-query/src/rules.ts index d527768ec1..440ad8593a 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 noModuleQueryClient from './rules/no-module-query-client/no-module-query-client.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, + [noModuleQueryClient.name]: noModuleQueryClient.rule, } diff --git a/packages/eslint-plugin-query/src/rules/no-module-query-client/no-module-query-client.rule.ts b/packages/eslint-plugin-query/src/rules/no-module-query-client/no-module-query-client.rule.ts new file mode 100644 index 0000000000..ee269d8716 --- /dev/null +++ b/packages/eslint-plugin-query/src/rules/no-module-query-client/no-module-query-client.rule.ts @@ -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(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', + }) + } + }, + } + }), +}) From b926352c380ae263235444e243c7b6f15bc759d9 Mon Sep 17 00:00:00 2001 From: Jack Leslie Date: Tue, 20 Jan 2026 10:35:15 +0000 Subject: [PATCH 2/3] docs --- docs/eslint/no-module-query-client.md | 52 --------------------------- 1 file changed, 52 deletions(-) diff --git a/docs/eslint/no-module-query-client.md b/docs/eslint/no-module-query-client.md index 56af352fc2..b0fdbe42ca 100644 --- a/docs/eslint/no-module-query-client.md +++ b/docs/eslint/no-module-query-client.md @@ -32,24 +32,6 @@ export default function MyApp({ Component, pageProps }) { } ``` -```tsx -// pages/_app.tsx -/* eslint "@tanstack/query/no-module-query-client": "error" */ - -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' - -export default function MyApp({ Component, pageProps }) { - // This is also incorrect - creating a new instance on every render - const queryClient = new QueryClient() - - return ( - - - - ) -} -``` - Examples of **correct** code for this rule: ```tsx @@ -79,40 +61,6 @@ export default function MyApp({ Component, pageProps }) { } ``` -```tsx -// app/providers.tsx -'use client' - -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useState } from 'react' - -export function Providers({ children }: { children: React.ReactNode }) { - const [queryClient] = useState(() => new QueryClient()) - - return ( - {children} - ) -} -``` - -```tsx -// app/providers.tsx -'use client' - -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { useRef } from 'react' - -export function Providers({ children }: { children: React.ReactNode }) { - const queryClientRef = useRef(new QueryClient()) - - return ( - - {children} - - ) -} -``` - ## 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`). From ae6d75e48a61e6615cbf1c9409a68f1c60a2b8c7 Mon Sep 17 00:00:00 2001 From: Jack Leslie <52004409+jackleslie@users.noreply.github.com> Date: Tue, 20 Jan 2026 10:36:24 +0000 Subject: [PATCH 3/3] Create afraid-walls-glow.md --- .changeset/afraid-walls-glow.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/afraid-walls-glow.md diff --git a/.changeset/afraid-walls-glow.md b/.changeset/afraid-walls-glow.md new file mode 100644 index 0000000000..505370b75d --- /dev/null +++ b/.changeset/afraid-walls-glow.md @@ -0,0 +1,5 @@ +--- +"@tanstack/eslint-plugin-query": minor +--- + +feat(eslint-plugin-query): add new rule for SSR QueryClient setup