Setting up URQL for GraphQL in Web Applications
URQL is a GraphQL client that emphasizes modularity and simplicity. Instead of Apollo's monolithic architecture — a system of exchanges: a chain of handlers through which each request passes. Need cache? Connect an exchange. Need subscriptions? Another exchange. Don't need some functionality — don't connect it.
Result: smaller bundle size with comparable features. Out of the box supports React, Vue, Svelte, and Preact.
What's Included
Client and exchanges configuration, cacheExchange setup, authExchange, subscriptionExchange, code generation of types, hooks for queries/mutations/subscriptions, error handling, SSR.
Installation
npm install urql graphql
# exchanges
npm install @urql/exchange-auth @urql/exchange-retry
# for subscriptions
npm install graphql-ws
Basic Configuration
// 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: [
// order matters — top to bottom
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() {
// can refresh token here
const newToken = await refreshTokenRequest()
if (newToken) {
localStorage.setItem('token', newToken)
} else {
localStorage.removeItem('token')
authStore.logout()
}
},
willAuthError() {
// check token before request
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>
)
}
Normalized Cache (Graphcache)
The basic cacheExchange is a document cache (key = query string + variables). For normalized cache like 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) => {
// invalidate all queries containing product list
cache.invalidate('Query', 'products')
},
deleteProduct: (result, args, cache) => {
cache.invalidate({ __typename: 'Product', id: args.id as string })
},
updateProduct: (_result, _args, cache) => {
// normalized cache updates automatically by id
// manual invalidation not needed if __typename + id are returned
},
},
},
optimistic: {
updateProduct: (args) => ({
__typename: 'Product',
id: args.id,
...(args.input as object),
}),
},
})
Code Generation
URQL uses the same @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 for Graphcache
'src/lib/urql/schema.json': {
plugins: ['introspection'],
},
},
}
export default config
GraphQL Documents
# 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
}
}
Hooks
// 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, // pause if orderId is empty
})
return {
status: result.data?.orderStatusChanged?.status,
fetching: result.fetching,
error: result.error,
}
}
Usage in Component
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 supports 4 cache policies at each request level:
-
cache-first— uses cache, doesn't go to network if available (default) -
cache-and-network— returns cache and simultaneously updates -
network-only— always goes to network, doesn't write to cache -
cache-only— cache only, without network request
const [result] = useQuery({
query: GetProductDocument,
variables: { id },
requestPolicy: 'cache-and-network',
})
Manual Cache Operations Outside Components
// invalidate via client directly
urqlClient.invalidateQuery(GetProductsDocument, { categoryId: '1' })
// request via client (outside React)
const result = await urqlClient.query(GetProductDocument, { id: '42' }).toPromise()
SSR with 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)
What We Do
Configure a chain of exchanges for project tasks (auth, retry, cache, subscriptions), set up code generation, implement hooks for all operations, for complex cache requirements — connect Graphcache with normalization and optimistic updates.
Timeline: 2–4 days.







