diff --git a/docs/rtk-query/usage/examples.mdx b/docs/rtk-query/usage/examples.mdx
index 55674e43cf..867abd578b 100644
--- a/docs/rtk-query/usage/examples.mdx
+++ b/docs/rtk-query/usage/examples.mdx
@@ -76,6 +76,22 @@ The example has some intentionally wonky behavior... when editing the name of a
sandbox="allow-modals allow-forms allow-popups allow-scripts allow-same-origin"
>
+## React GraphQL with custom client
+
+
+
## Authentication
There are several ways to handle authentication with RTK Query. This is a very basic example of taking a JWT from a login mutation, then setting that in our store. We then use `prepareHeaders` to inject the authentication headers into every subsequent request.
diff --git a/examples/query/react/graphql-custom-client/.env b/examples/query/react/graphql-custom-client/.env
new file mode 100644
index 0000000000..e73ecc6cd4
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/.env
@@ -0,0 +1,3 @@
+SKIP_PREFLIGHT_CHECK=true
+# https://github.com/facebook/create-react-app/issues/11940
+DISABLE_ESLINT_PLUGIN=true
diff --git a/examples/query/react/graphql-custom-client/package.json b/examples/query/react/graphql-custom-client/package.json
new file mode 100644
index 0000000000..59d3e5f104
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@examples-query-react/graphql-custom-client",
+ "private": true,
+ "version": "1.0.0",
+ "description": "",
+ "keywords": [],
+ "main": "./src/index.tsx",
+ "dependencies": {
+ "@chakra-ui/react": "1.0.0",
+ "@emotion/react": "^11.4.0",
+ "@emotion/styled": "^11.3.0",
+ "@mswjs/data": "^0.3.0",
+ "@reduxjs/toolkit": "^1.6.0-rc.1",
+ "@rtk-query/graphql-request-base-query": "^2.0.0",
+ "faker": "^5.5.3",
+ "framer-motion": "^2.9.5",
+ "graphql": "^15.5.0",
+ "graphql-request": "^3.4.0",
+ "msw": "0.28.2",
+ "react": "^18.1.0",
+ "react-dom": "^18.1.0",
+ "react-icons": "3.11.0",
+ "react-redux": "^8.0.2",
+ "react-router-dom": "6.3.0",
+ "react-scripts": "5.0.1"
+ },
+ "devDependencies": {
+ "@types/faker": "^5.5.5",
+ "@types/react": "^18.0.5",
+ "@types/react-dom": "^18.0.5",
+ "typescript": "~4.2.4"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "prebuild": "rm -rf dist/"
+ },
+ "eslintConfig": {
+ "extends": [
+ "react-app"
+ ],
+ "rules": {
+ "react/react-in-jsx-scope": "off"
+ }
+ },
+ "browserslist": [
+ ">0.2%",
+ "not dead",
+ "not ie <= 11",
+ "not op_mini all"
+ ],
+ "msw": {
+ "workerDirectory": "public"
+ }
+}
diff --git a/examples/query/react/graphql-custom-client/public/index.html b/examples/query/react/graphql-custom-client/public/index.html
new file mode 100644
index 0000000000..09e975e218
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/public/index.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
+
diff --git a/examples/query/react/graphql-custom-client/public/manifest.json b/examples/query/react/graphql-custom-client/public/manifest.json
new file mode 100644
index 0000000000..11c6e14f7c
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/public/manifest.json
@@ -0,0 +1,8 @@
+{
+ "short_name": "RTK Query Pagination Example",
+ "name": "Pagination Demo",
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff"
+}
diff --git a/examples/query/react/graphql-custom-client/public/mockServiceWorker.js b/examples/query/react/graphql-custom-client/public/mockServiceWorker.js
new file mode 100644
index 0000000000..fa1e5f5319
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/public/mockServiceWorker.js
@@ -0,0 +1,323 @@
+/* eslint-disable */
+/* tslint:disable */
+
+/**
+ * Mock Service Worker (0.30.1).
+ * @see https://github.com/mswjs/msw
+ * - Please do NOT modify this file.
+ * - Please do NOT serve this file on production.
+ */
+
+const INTEGRITY_CHECKSUM = '82ef9b96d8393b6da34527d1d6e19187'
+const bypassHeaderName = 'x-msw-bypass'
+const activeClientIds = new Set()
+
+self.addEventListener('install', function () {
+ return self.skipWaiting()
+})
+
+self.addEventListener('activate', async function (event) {
+ return self.clients.claim()
+})
+
+self.addEventListener('message', async function (event) {
+ const clientId = event.source.id
+
+ if (!clientId || !self.clients) {
+ return
+ }
+
+ const client = await self.clients.get(clientId)
+
+ if (!client) {
+ return
+ }
+
+ const allClients = await self.clients.matchAll()
+
+ switch (event.data) {
+ case 'KEEPALIVE_REQUEST': {
+ sendToClient(client, {
+ type: 'KEEPALIVE_RESPONSE',
+ })
+ break
+ }
+
+ case 'INTEGRITY_CHECK_REQUEST': {
+ sendToClient(client, {
+ type: 'INTEGRITY_CHECK_RESPONSE',
+ payload: INTEGRITY_CHECKSUM,
+ })
+ break
+ }
+
+ case 'MOCK_ACTIVATE': {
+ activeClientIds.add(clientId)
+
+ sendToClient(client, {
+ type: 'MOCKING_ENABLED',
+ payload: true,
+ })
+ break
+ }
+
+ case 'MOCK_DEACTIVATE': {
+ activeClientIds.delete(clientId)
+ break
+ }
+
+ case 'CLIENT_CLOSED': {
+ activeClientIds.delete(clientId)
+
+ const remainingClients = allClients.filter((client) => {
+ return client.id !== clientId
+ })
+
+ // Unregister itself when there are no more clients
+ if (remainingClients.length === 0) {
+ self.registration.unregister()
+ }
+
+ break
+ }
+ }
+})
+
+// Resolve the "master" client for the given event.
+// Client that issues a request doesn't necessarily equal the client
+// that registered the worker. It's with the latter the worker should
+// communicate with during the response resolving phase.
+async function resolveMasterClient(event) {
+ const client = await self.clients.get(event.clientId)
+
+ if (client.frameType === 'top-level') {
+ return client
+ }
+
+ const allClients = await self.clients.matchAll()
+
+ return allClients
+ .filter((client) => {
+ // Get only those clients that are currently visible.
+ return client.visibilityState === 'visible'
+ })
+ .find((client) => {
+ // Find the client ID that's recorded in the
+ // set of clients that have registered the worker.
+ return activeClientIds.has(client.id)
+ })
+}
+
+async function handleRequest(event, requestId) {
+ const client = await resolveMasterClient(event)
+ const response = await getResponse(event, client, requestId)
+
+ // Send back the response clone for the "response:*" life-cycle events.
+ // Ensure MSW is active and ready to handle the message, otherwise
+ // this message will pend indefinitely.
+ if (client && activeClientIds.has(client.id)) {
+ ;(async function () {
+ const clonedResponse = response.clone()
+ sendToClient(client, {
+ type: 'RESPONSE',
+ payload: {
+ requestId,
+ type: clonedResponse.type,
+ ok: clonedResponse.ok,
+ status: clonedResponse.status,
+ statusText: clonedResponse.statusText,
+ body:
+ clonedResponse.body === null ? null : await clonedResponse.text(),
+ headers: serializeHeaders(clonedResponse.headers),
+ redirected: clonedResponse.redirected,
+ },
+ })
+ })()
+ }
+
+ return response
+}
+
+async function getResponse(event, client, requestId) {
+ const { request } = event
+ const requestClone = request.clone()
+ const getOriginalResponse = () => fetch(requestClone)
+
+ // Bypass mocking when the request client is not active.
+ if (!client) {
+ return getOriginalResponse()
+ }
+
+ // Bypass initial page load requests (i.e. static assets).
+ // The absence of the immediate/parent client in the map of the active clients
+ // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
+ // and is not ready to handle requests.
+ if (!activeClientIds.has(client.id)) {
+ return await getOriginalResponse()
+ }
+
+ // Bypass requests with the explicit bypass header
+ if (requestClone.headers.get(bypassHeaderName) === 'true') {
+ const cleanRequestHeaders = serializeHeaders(requestClone.headers)
+
+ // Remove the bypass header to comply with the CORS preflight check.
+ delete cleanRequestHeaders[bypassHeaderName]
+
+ const originalRequest = new Request(requestClone, {
+ headers: new Headers(cleanRequestHeaders),
+ })
+
+ return fetch(originalRequest)
+ }
+
+ // Send the request to the client-side MSW.
+ const reqHeaders = serializeHeaders(request.headers)
+ const body = await request.text()
+
+ const clientMessage = await sendToClient(client, {
+ type: 'REQUEST',
+ payload: {
+ id: requestId,
+ url: request.url,
+ method: request.method,
+ headers: reqHeaders,
+ cache: request.cache,
+ mode: request.mode,
+ credentials: request.credentials,
+ destination: request.destination,
+ integrity: request.integrity,
+ redirect: request.redirect,
+ referrer: request.referrer,
+ referrerPolicy: request.referrerPolicy,
+ body,
+ bodyUsed: request.bodyUsed,
+ keepalive: request.keepalive,
+ },
+ })
+
+ switch (clientMessage.type) {
+ case 'MOCK_SUCCESS': {
+ return delayPromise(
+ () => respondWithMock(clientMessage),
+ clientMessage.payload.delay,
+ )
+ }
+
+ case 'MOCK_NOT_FOUND': {
+ return getOriginalResponse()
+ }
+
+ case 'NETWORK_ERROR': {
+ const { name, message } = clientMessage.payload
+ const networkError = new Error(message)
+ networkError.name = name
+
+ // Rejecting a request Promise emulates a network error.
+ throw networkError
+ }
+
+ case 'INTERNAL_ERROR': {
+ const parsedBody = JSON.parse(clientMessage.payload.body)
+
+ console.error(
+ `\
+[MSW] Request handler function for "%s %s" has thrown the following exception:
+
+${parsedBody.errorType}: ${parsedBody.message}
+(see more detailed error stack trace in the mocked response body)
+
+This exception has been gracefully handled as a 500 response, however, it's strongly recommended to resolve this error.
+If you wish to mock an error response, please refer to this guide: https://mswjs.io/docs/recipes/mocking-error-responses\
+`,
+ request.method,
+ request.url,
+ )
+
+ return respondWithMock(clientMessage)
+ }
+ }
+
+ return getOriginalResponse()
+}
+
+self.addEventListener('fetch', function (event) {
+ const { request } = event
+
+ // Bypass navigation requests.
+ if (request.mode === 'navigate') {
+ return
+ }
+
+ // Opening the DevTools triggers the "only-if-cached" request
+ // that cannot be handled by the worker. Bypass such requests.
+ if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
+ return
+ }
+
+ // Bypass all requests when there are no active clients.
+ // Prevents the self-unregistered worked from handling requests
+ // after it's been deleted (still remains active until the next reload).
+ if (activeClientIds.size === 0) {
+ return
+ }
+
+ const requestId = uuidv4()
+
+ return event.respondWith(
+ handleRequest(event, requestId).catch((error) => {
+ console.error(
+ '[MSW] Failed to mock a "%s" request to "%s": %s',
+ request.method,
+ request.url,
+ error,
+ )
+ }),
+ )
+})
+
+function serializeHeaders(headers) {
+ const reqHeaders = {}
+ headers.forEach((value, name) => {
+ reqHeaders[name] = reqHeaders[name]
+ ? [].concat(reqHeaders[name]).concat(value)
+ : value
+ })
+ return reqHeaders
+}
+
+function sendToClient(client, message) {
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel()
+
+ channel.port1.onmessage = (event) => {
+ if (event.data && event.data.error) {
+ return reject(event.data.error)
+ }
+
+ resolve(event.data)
+ }
+
+ client.postMessage(JSON.stringify(message), [channel.port2])
+ })
+}
+
+function delayPromise(cb, duration) {
+ return new Promise((resolve) => {
+ setTimeout(() => resolve(cb()), duration)
+ })
+}
+
+function respondWithMock(clientMessage) {
+ return new Response(clientMessage.payload.body, {
+ ...clientMessage.payload,
+ headers: clientMessage.payload.headers,
+ })
+}
+
+function uuidv4() {
+ return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
+ const r = (Math.random() * 16) | 0
+ const v = c == 'x' ? r : (r & 0x3) | 0x8
+ return v.toString(16)
+ })
+}
diff --git a/examples/query/react/graphql-custom-client/src/App.tsx b/examples/query/react/graphql-custom-client/src/App.tsx
new file mode 100644
index 0000000000..d2c53f79bc
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/src/App.tsx
@@ -0,0 +1,12 @@
+import { Route, Routes } from 'react-router-dom'
+import { PostsManager } from './features/posts/PostsManager'
+
+function App() {
+ return (
+
+ } />
+
+ )
+}
+
+export default App
diff --git a/examples/query/react/graphql-custom-client/src/app/services/posts.ts b/examples/query/react/graphql-custom-client/src/app/services/posts.ts
new file mode 100644
index 0000000000..f3e40f24f0
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/src/app/services/posts.ts
@@ -0,0 +1,83 @@
+import { createApi } from '@reduxjs/toolkit/query/react'
+import { gql } from 'graphql-request'
+import {graphqlRequestBaseQuery} from '@rtk-query/graphql-request-base-query'
+import { GraphQLClient } from "graphql-request";
+
+export const postStatuses = ['draft', 'published', 'pending_review'] as const
+
+export interface Post {
+ id: string
+ title: string
+ author: string
+ content: string
+ status: typeof postStatuses[number]
+ created_at: string
+ updated_at: string
+}
+
+export interface Pagination {
+ page: number
+ per_page: number
+ total: number
+ total_pages: number
+}
+
+export interface GetPostsResponse extends Pagination {
+ data: {
+ posts: Post[]
+ }
+}
+
+interface PostResponse {
+ data: {
+ post: Post
+ }
+}
+
+const client = new GraphQLClient("/graphql", {
+ credentials: "include"
+});
+
+export const api = createApi({
+ baseQuery: graphqlRequestBaseQuery({
+ client,
+ url: '/graphql',
+ }),
+ endpoints: (builder) => ({
+ getPosts: builder.query<
+ GetPostsResponse,
+ { page?: number; per_page?: number }
+ >({
+ query: ({ page, per_page }) => ({
+ document: gql`
+ query GetPosts($page: Int = 1, $per_page: Int = 10) {
+ posts(page: $page, per_page: $per_page) {
+ id
+ title
+ }
+ }
+ `,
+ variables: {
+ page,
+ per_page,
+ },
+ }),
+ }),
+ getPost: builder.query({
+ query: (id) => ({
+ document: gql`
+ query GetPost($id: ID!) {
+ post(id: ${id}) {
+ id
+ title
+ body
+ }
+ }
+ `,
+ }),
+ transformResponse: (response: PostResponse) => response.data.post,
+ }),
+ }),
+})
+
+export const { useGetPostsQuery, useGetPostQuery } = api
diff --git a/examples/query/react/graphql-custom-client/src/features/posts/PostsManager.tsx b/examples/query/react/graphql-custom-client/src/features/posts/PostsManager.tsx
new file mode 100644
index 0000000000..09496ee6b8
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/src/features/posts/PostsManager.tsx
@@ -0,0 +1,110 @@
+import * as React from 'react'
+import {
+ Badge,
+ Box,
+ Button,
+ Divider,
+ Flex,
+ Heading,
+ HStack,
+ Icon,
+ List,
+ ListIcon,
+ ListItem,
+ Spacer,
+ Stat,
+ StatLabel,
+ StatNumber,
+} from '@chakra-ui/react'
+import { MdArrowBack, MdArrowForward, MdBook } from 'react-icons/md'
+import { Post, useGetPostsQuery } from '../../app/services/posts'
+
+const getColorForStatus = (status: Post['status']) => {
+ return status === 'draft'
+ ? 'gray'
+ : status === 'pending_review'
+ ? 'orange'
+ : 'green'
+}
+
+const PostList = () => {
+ const [page, setPage] = React.useState(1)
+ const { data: posts, isLoading, isFetching } = useGetPostsQuery({ page })
+
+ if (isLoading) {
+ return Loading
+ }
+
+ if (!posts?.data) {
+ return No posts :(
+ }
+
+ return (
+
+
+
+
+ {`${page} / ${posts.total_pages}`}
+
+
+ {posts?.data.posts.map(({ id, title, status }) => (
+
+ {title}{' '}
+
+ {status}
+
+
+ ))}
+
+
+ )
+}
+
+export const PostsCountStat = () => {
+ const { data: posts } = useGetPostsQuery({})
+
+ return (
+
+ Total Posts
+ {`${posts?.total || 'NA'}`}
+
+ )
+}
+
+export const PostsManager = () => {
+ return (
+
+
+
+ Manage Posts
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
+
+export default PostsManager
diff --git a/examples/query/react/graphql-custom-client/src/index.tsx b/examples/query/react/graphql-custom-client/src/index.tsx
new file mode 100644
index 0000000000..a7429fe087
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/src/index.tsx
@@ -0,0 +1,26 @@
+import React from 'react'
+import ReactDOM from 'react-dom/client'
+import App from './App'
+import { api } from './app/services/posts'
+import { ChakraProvider } from '@chakra-ui/react'
+
+import { BrowserRouter } from 'react-router-dom'
+import { worker } from './mocks/browser'
+import { ApiProvider } from '@reduxjs/toolkit/query/react'
+
+// Initialize the msw worker, wait for the service worker registration to resolve, then mount
+worker.start({ quiet: true }).then(() => {
+ return ReactDOM.createRoot(
+ document.getElementById('root') as HTMLElement
+ ).render(
+
+
+
+
+
+
+
+
+
+ )
+})
diff --git a/examples/query/react/graphql-custom-client/src/mocks/browser.ts b/examples/query/react/graphql-custom-client/src/mocks/browser.ts
new file mode 100644
index 0000000000..82d072ec49
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/src/mocks/browser.ts
@@ -0,0 +1,4 @@
+import { setupWorker } from 'msw'
+import { handlers } from './db'
+
+export const worker = setupWorker(...handlers)
diff --git a/examples/query/react/graphql-custom-client/src/mocks/db.ts b/examples/query/react/graphql-custom-client/src/mocks/db.ts
new file mode 100644
index 0000000000..0aa6a3c2a4
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/src/mocks/db.ts
@@ -0,0 +1,71 @@
+import { nanoid } from '@reduxjs/toolkit'
+import { factory, primaryKey } from '@mswjs/data'
+import faker from 'faker'
+import { graphql } from 'msw'
+import { postStatuses } from '../app/services/posts'
+import type { Pagination, Post } from '../app/services/posts'
+
+const db = factory({
+ post: {
+ id: primaryKey(String),
+ name: String,
+ title: String,
+ author: String,
+ content: String,
+ status: String,
+ created_at: String,
+ updated_at: String,
+ },
+})
+
+const getRandomStatus = () =>
+ postStatuses[Math.floor(Math.random() * postStatuses.length)]
+
+const createPostData = (): Post => {
+ const date = faker.date.past().toISOString()
+ return {
+ id: nanoid(),
+ title: faker.lorem.words(),
+ author: faker.name.findName(),
+ content: faker.lorem.paragraphs(),
+ status: getRandomStatus(),
+ created_at: date,
+ updated_at: date,
+ }
+}
+
+;[...new Array(50)].forEach((_) => db.post.create(createPostData()))
+
+type PaginationOptions = {
+ page: number; per_page: number
+}
+
+interface Posts extends Pagination {
+ data: {
+ posts: Post[]
+ }
+}
+
+export const handlers = [
+ graphql.query('GetPosts', (req, res, ctx) => {
+ const { page = 1, per_page = 10 } = req.variables
+
+ const posts = db.post.findMany({
+ take: per_page,
+ skip: Math.max(per_page * (page - 1), 0)
+ })
+
+ return res(
+ ctx.data({
+ data: {
+ posts
+ } as { posts: Post[] },
+ per_page,
+ page,
+ total_pages: Math.ceil(db.post.count() / per_page),
+ total: db.post.count(),
+ })
+ )
+ }),
+ ...db.post.toHandlers('graphql'),
+] as const
diff --git a/examples/query/react/graphql-custom-client/src/react-app-env.d.ts b/examples/query/react/graphql-custom-client/src/react-app-env.d.ts
new file mode 100644
index 0000000000..6431bc5fc6
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/src/react-app-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/query/react/graphql-custom-client/tsconfig.json b/examples/query/react/graphql-custom-client/tsconfig.json
new file mode 100644
index 0000000000..5f488e8e73
--- /dev/null
+++ b/examples/query/react/graphql-custom-client/tsconfig.json
@@ -0,0 +1,25 @@
+{
+ "include": [
+ "./src/**/*"
+ ],
+ "compilerOptions": {
+ "strict": true,
+ "esModuleInterop": true,
+ "lib": [
+ "dom",
+ "es2015"
+ ],
+ "jsx": "react-jsx",
+ "target": "es5",
+ "allowJs": true,
+ "skipLibCheck": true,
+ "allowSyntheticDefaultImports": true,
+ "forceConsistentCasingInFileNames": true,
+ "noFallthroughCasesInSwitch": true,
+ "module": "esnext",
+ "moduleResolution": "node",
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "noEmit": true
+ }
+}
diff --git a/yarn.lock b/yarn.lock
index 4fdbfb5d62..7f8b1fa129 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3605,6 +3605,34 @@ __metadata:
languageName: unknown
linkType: soft
+"@examples-query-react/graphql-custom-client@workspace:examples/query/react/graphql-custom-client":
+ version: 0.0.0-use.local
+ resolution: "@examples-query-react/graphql-custom-client@workspace:examples/query/react/graphql-custom-client"
+ dependencies:
+ "@chakra-ui/react": 1.0.0
+ "@emotion/react": ^11.4.0
+ "@emotion/styled": ^11.3.0
+ "@mswjs/data": ^0.3.0
+ "@reduxjs/toolkit": ^1.6.0-rc.1
+ "@rtk-query/graphql-request-base-query": ^2.0.0
+ "@types/faker": ^5.5.5
+ "@types/react": ^18.0.5
+ "@types/react-dom": ^18.0.5
+ faker: ^5.5.3
+ framer-motion: ^2.9.5
+ graphql: ^15.5.0
+ graphql-request: ^3.4.0
+ msw: 0.28.2
+ react: ^18.1.0
+ react-dom: ^18.1.0
+ react-icons: 3.11.0
+ react-redux: ^8.0.2
+ react-router-dom: 6.3.0
+ react-scripts: 5.0.1
+ typescript: ~4.2.4
+ languageName: unknown
+ linkType: soft
+
"@examples-query-react/graphql@workspace:examples/query/react/graphql":
version: 0.0.0-use.local
resolution: "@examples-query-react/graphql@workspace:examples/query/react/graphql"