Налаштування URQL для GraphQL у веб-додатках
URQL — GraphQL-клієнт, який робить ставку на модульність та простоту. Замість монолітної архітектури Apollo — система exchanges: ланцюг обробників, через який проходить кожен запит. Потрібен кеш? Підключи exchange. Потрібні підписки? Ще один exchange. Не потрібна частина функціональності — не підключай.
Результат: менший bundle size при порівнянних можливостях. З коробки підтримує React, Vue, Svelte та Preact.
Що входить у роботу
Конфігурація клієнта та exchanges, налаштування cacheExchange, authExchange, subscriptionExchange, кодогенерація типів, хуки для запитів/мутацій/підписок, обробка помилок, SSR.
Встановлення
npm install urql graphql
# exchanges
npm install @urql/exchange-auth @urql/exchange-retry
# для підписок
npm install graphql-ws
Базова конфігурація
// lib/urql/client.ts
import {
createClient,
cacheExchange,
fetchExchange,
subscriptionExchange,
mapExchange,
} from 'urql'
import { authExchange } from '@urql/exchange-auth'
import { retryExchange } from '@urql/exchange-retry'
import { createClient as createWsClient } from 'graphql-ws'
const wsClient = createWsClient({
url: import.meta.env.VITE_WS_URL ?? 'ws://localhost:4000/graphql',
connectionParams: () => ({
authorization: `Bearer ${localStorage.getItem('token')}`,
}),
})
export const urqlClient = createClient({
url: import.meta.env.VITE_GRAPHQL_URL ?? '/graphql',
exchanges: [
// порядок важен — зверху вниз
mapExchange({
onError(error) {
if (error.response?.status === 401) {
authStore.logout()
}
console.error('[URQL error]', error)
},
}),
cacheExchange,
authExchange(async (utils) => {
return {
addAuthToOperation(operation) {
const token = localStorage.getItem('token')
if (!token) return operation
return utils.appendHeaders(operation, {
Authorization: `Bearer ${token}`,
})
},
didAuthError(error) {
return error.graphQLErrors.some(
(e) => e.extensions?.code === 'UNAUTHENTICATED'
)
},
async refreshAuth() {
// можна оновити токен тут
const newToken = await refreshTokenRequest()
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token')
authStore.logout()
}
},
willAuthError() {
// перевіряємо токен перед запитом
const token = localStorage.getItem('token')
return !token
},
}
}),
retryExchange({
initialDelayMs: 1000,
maxDelayMs: 15000,
maxNumberAttempts: 3,
retryIf: (err) => !!(err && err.networkError),
}),
fetchExchange,
subscriptionExchange({
forwardSubscription(request) {
const input = { ...request, query: request.query ?? '' }
return {
subscribe(sink) {
const dispose = wsClient.subscribe(input, sink)
return { unsubscribe: dispose }
},
}
},
}),
],
})
// main.tsx
import { Provider as UrqlProvider } from 'urql'
import { urqlClient } from '@/lib/urql/client'
function App() {
return (
<UrqlProvider value={urqlClient}>
<Router />
</UrqlProvider>
)
}
Нормалізований кеш (Graphcache)
Базовий cacheExchange — документний кеш (за ключем = рядок запиту + змінні). Для нормалізованого кешу, як у Apollo:
npm install @urql/exchange-graphcache
import { offlineExchange } from '@urql/exchange-graphcache'
import schema from './schema.json' // introspection schema
const cache = offlineExchange({
schema,
keys: {
Product: (data) => data.id ?? null,
User: (data) => data.id ?? null,
Category: (data) => data.id ?? null,
},
resolvers: {
Query: {
product: (_, args) => ({ __typename: 'Product', id: args.id }),
},
},
updates: {
Mutation: {
createProduct: (result, _args, cache) => {
// оновлюємо всі queries, які містять список products
cache.invalidate('Query', 'products')
},
deleteProduct: (result, args, cache) => {
cache.invalidate({ __typename: 'Product', id: args.id as string })
},
updateProduct: (_result, _args, cache) => {
// нормалізований кеш оновиться автоматично за id
// ручна інвалідація не потрібна, якщо __typename + id повертаються
},
},
},
optimistic: {
updateProduct: (args) => ({
__typename: 'Product',
id: args.id,
...(args.input as object),
}),
},
})
Кодогенерація
URQL використовує той же @graphql-codegen/client-preset:
// codegen.ts
import type { CodegenConfig } from '@graphql-codegen/cli'
const config: CodegenConfig = {
schema: 'http://localhost:4000/graphql',
documents: 'src/**/*.graphql',
generates: {
'src/gql/': {
preset: 'client',
config: {
useTypeImports: true,
},
},
// introspection для Graphcache
'src/lib/urql/schema.json': {
plugins: ['introspection'],
},
},
}
export default config
GraphQL-документи
# src/features/products/products.graphql
query GetProducts($categoryId: ID!, $page: Int, $pageSize: Int) {
products(categoryId: $categoryId, page: $page, pageSize: $pageSize) {
items {
id
name
price
stock
}
total
hasNextPage
}
}
mutation CreateProduct($input: CreateProductInput!) {
createProduct(input: $input) {
id
name
price
}
}
subscription OnOrderStatus($orderId: ID!) {
orderStatusChanged(orderId: $orderId) {
orderId
status
updatedAt
}
}
Хуки
// features/products/useProducts.ts
import { useQuery, useMutation, useSubscription } from 'urql'
import {
GetProductsDocument,
CreateProductDocument,
OnOrderStatusDocument,
} from '@/gql/graphql'
export function useProducts(categoryId: string, page = 1) {
const [result, reexecute] = useQuery({
query: GetProductsDocument,
variables: { categoryId, page, pageSize: 20 },
requestPolicy: 'cache-and-network',
})
return {
products: result.data?.products.items ?? [],
total: result.data?.products.total ?? 0,
hasNextPage: result.data?.products.hasNextPage ?? false,
fetching: result.fetching,
error: result.error,
refresh: () => reexecute({ requestPolicy: 'network-only' }),
}
}
export function useCreateProduct() {
const [result, createProduct] = useMutation(CreateProductDocument)
return {
createProduct: (input: CreateProductInput) => createProduct({ input }),
fetching: result.fetching,
error: result.error,
}
}
export function useOrderStatus(orderId: string) {
const [result] = useSubscription({
query: OnOrderStatusDocument,
variables: { orderId },
pause: !orderId, // призупинити, якщо orderId порожній
})
return {
status: result.data?.orderStatusChanged?.status,
fetching: result.fetching,
error: result.error,
}
}
Використання в компоненті
function ProductList({ categoryId }: { categoryId: string }) {
const [page, setPage] = useState(1)
const { products, total, hasNextPage, fetching, error, refresh } = useProducts(categoryId, page)
const { createProduct, fetching: creating } = useCreateProduct()
if (fetching && products.length === 0) return <Skeleton />
if (error) return <ErrorMessage message={error.message} />
return (
<div>
{fetching && <LoadingBar />}
<ProductGrid products={products} />
<Pagination
page={page}
total={total}
hasNext={hasNextPage}
onNext={() => setPage((p) => p + 1)}
onPrev={() => setPage((p) => p - 1)}
/>
</div>
)
}
requestPolicy
URQL підтримує 4 політики кешу на рівні кожного запиту:
-
cache-first— використовує кеш, не йде в мережу, якщо доступно (за замовчуванням) -
cache-and-network— повертає кеш та паралельно оновлює -
network-only— завжди йде в мережу, не записує в кеш -
cache-only— тільки кеш, без запиту в мережу
const [result] = useQuery({
query: GetProductDocument,
variables: { id },
requestPolicy: 'cache-and-network',
})
Ручна робота з кешем поза компонентом
// інвалідація через клієнт безпосередньо
urqlClient.invalidateQuery(GetProductsDocument, { categoryId: '1' })
// запит через клієнт (поза React)
const result = await urqlClient.query(GetProductDocument, { id: '42' }).toPromise()
SSR з Next.js
// pages/_app.tsx
import { withUrqlClient } from 'next-urql'
import { ssrExchange, cacheExchange, fetchExchange } from 'urql'
export default withUrqlClient(
(ssrCache) => ({
url: process.env.GRAPHQL_URL!,
exchanges: [cacheExchange, ssrCache, fetchExchange],
}),
{ ssr: true }
)(MyApp)
Що ми робимо
Конфігуруємо ланцюг exchanges під задачі проекту (auth, retry, cache, subscriptions), налаштовуємо кодогенерацію, реалізуємо хуки для всіх операцій, при складних вимогах до кешу — підключаємо Graphcache з нормалізацією та оптимістичними оновленнями.
Строк: 2–4 дні.







