urql GraphQL Client: Lightweight Customizable Client
Welcome to TopperBlog! 👋
I'm a tech content creator passionate about helping developers level up their careers and master cutting-edge technologies.
🎯 What I Write About:
• AI/ML Engineering & LLMs
• Web3 & Blockchain Development
• System Design & Architecture
• Interview Preparation (FAANG)
• Freelancing & Remote Work
• Modern Tech Stacks (Next.js, React, Rust, TypeScript)
• Performance Optimization & Best Practices
💼 Mission: Sharing practical, actionable insights that accelerate your tech career and maximize your earning potential.
📚 15+ In-Depth Guides covering everything from earning $10k/month as a freelancer to cracking FAANG interviews.
🌐 Let's connect and grow together in this amazing tech journey!
#TechBlogger #SoftwareEngineering #CareerGrowth #WebDevelopment #AIEngineering
urql GraphQL Client
Lightweight · Customizable · Framework‑agnostic
Table of Contents
- What is urql?
- Key Features & Design Goals
- Getting Started – Installation & Basic Setup
- The Core Hook (
useQuery) – A Minimal Example - [Five Common Patterns]
- Frequently Asked Questions (FAQ)
- Conclusion – When to Choose urql?
1. What is urql?
urql (pronounced “Ur‑kel”) is an open‑source GraphQL client created by Formidable and now maintained by Nearform. It ships as a single, tiny npm package that works out of the box with React, React Native, Preact, Vue, Solid, Svelte and even plain JavaScript.
Unlike “batteries‑included” clients such as Apollo, urql starts with a minimal core (≈ 7 KB gzipped) and lets you opt‑in to extra capabilities through a plug‑in system called exchanges. An exchange is essentially a middleware that can inspect, modify, or short‑circuit any GraphQL operation (query, mutation, or subscription). This design gives you fine‑grained control over caching, retries, authentication, logging, and more—without paying the cost for features you never use.
2. Key Features & Design Goals
| Feature | Why it matters |
| Lightweight core | Small bundle size keeps page‑load times low, especially on mobile. |
| Composable exchanges | Add or replace behavior (e.g., cacheExchange, authExchange, retryExchange) in a predictable order. |
| Document caching by default | Every query result is cached by its GraphQL document + variables, giving instant “stale‑while‑revalidate” behavior. |
Normalized graphcache | Optional normalized cache for optimistic updates, pagination, and offline support. |
| First‑class hooks | useQuery, useMutation, useSubscription integrate naturally with React’s rendering model. |
| Framework‑agnostic bindings | Same client can be used in React, Vue, Svelte, Solid, etc., via thin adapters. |
| DevTools extension | Real‑time inspection of operations, cache state, and exchange flow. |
| SSR/SSG ready | ssrExchange enables server‑side rendering and static‑site generation with zero extra code. |
3. Getting Started – Installation & Basic Setup
3.1 Install the core package (and optional extras)
# Core client – works for any framework
npm i urql
# Optional: normalized cache (graphcache) and auth exchange
npm i @urql/exchange-graphcache @urql/exchange-auth
Tip: If you are only doing simple queries, you can stop after installing
urql. The extra packages are lazy‑loaded only when you add them to the exchange chain.
3.2 Create a client
// src/urqlClient.ts
import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql';
// Optional: import extra exchanges
import { authExchange } from '@urql/exchange-auth';
import { cacheExchange as graphCache } from '@urql/exchange-graphcache';
export const client = createClient({
url: 'https://my-api.example.com/graphql',
// The order of exchanges matters – they are executed left‑to‑right.
exchanges: [
dedupExchange, // De‑duplicate identical requests
cacheExchange, // Simple document cache (default)
// graphCache({ /* config */ }), // Uncomment for normalized cache
// authExchange({ /* config */ }), // Uncomment for auth handling
fetchExchange, // Performs the actual HTTP request
],
});
3.3 Provide the client to your UI tree
// src/App.tsx
import React from 'react';
import { Provider } from 'urql';
import { client } from './urqlClient';
import Home from './Home';
function App() {
return (
<Provider value={client}>
<Home />
</Provider>
);
}
export default App;
That’s all you need to have a fully functional GraphQL client. From here you can start using the hooks or the render‑prop components that ship with the binding of your choice.
4. The Core Hook (useQuery) – A Minimal Example
// src/components/Hello.tsx
import React from 'react';
import { useQuery } from 'urql';
import { gql } from 'graphql-tag';
const HELLO_QUERY = gql`
query {
hello
}
`;
export default function Hello() {
const [{ data, fetching, error }] = useQuery({ query: HELLO_QUERY });
if (fetching) return <p>Loading…</p>;
if (error) return <p>❌ {error.message}</p>;
return <p>👋 {data?.hello}</p>;
}
What happens under the hood?
useQueryregisters the operation with the client.- The client checks the document cache – if a fresh result exists, it returns it immediately.
- If not, the request flows through the exchange chain, eventually reaching
fetchExchangewhich performs aPOSTto the GraphQL endpoint. - When the response arrives, the cache is updated and the component re‑renders with the new data.
Because the hook returns an array ([result, reexecuteQuery]), you can also re‑execute the query manually (e.g., after a mutation) – a pattern we’ll revisit later.
5. Five Common Patterns
Below are five practical patterns that cover the majority of real‑world use‑cases. Each pattern shows the minimal code required and explains which exchanges you might want to add.
5.1 Pattern 1 – Fetching Data (Queries)
Goal: Retrieve data, handle loading/error states, and optionally refetch on demand.
import { useQuery } from 'urql';
import { gql } from 'graphql-tag';
const POSTS_QUERY = gql`
query Posts($limit: Int!) {
posts(limit: $limit) {
id
title
author {
name
}
}
}
`;
export function Posts({ limit = 10 }) {
const [{ data, fetching, error }, reexecuteQuery] = useQuery({
query: POSTS_QUERY,
variables: { limit },
});
if (fetching) return <p>Loading posts…</p>;
if (error) return <p>Failed: {error.message}</p>;
return (
<>
<ul>
{data.posts.map(p => (
<li key={p.id}>
<strong>{p.title}</strong> – {p.author.name}
</li>
))}
</ul>
<button onClick={() => reexecuteQuery({ requestPolicy: 'network-only' })}>
Refresh
</button>
</>
);
}
Why it works:
- The default
cacheExchangereturns cached results instantly, then silently updates if the network response differs. requestPolicy: 'network-only'forces a fresh fetch, useful for “pull‑to‑refresh” UI.
5.2 Pattern 2 – Changing Data (Mutations)
Goal: Send a mutation, optimistically update the UI, and keep the cache in sync.
import { useMutation } from 'urql';
import { gql } from 'graphql-tag';
const ADD_TODO = gql`
mutation AddTodo($text: String!) {
addTodo(text: $text) {
id
text
completed
}
}
`;
export function AddTodo() {
const [, addTodo] = useMutation(ADD_TODO);
const [text, setText] = React.useState('');
const handleSubmit = async e => {
e.preventDefault();
await addTodo(
{ text },
{
// Optimistic UI – show the new todo instantly
optimistic: {
addTodo: {
__typename: 'Todo',
id: Math.random().toString(),
text,
completed: false,
},
},
}
);
setText('');
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={e => setText(e.target.value)} placeholder="New todo" />
<button type="submit">Add</button>
</form>
);
}
Key points
useMutationreturns a tuple[result, executeMutation].- The second argument to
executeMutationcan contain anoptimisticfield that urql merges into the cache immediately. - If you have graphcache enabled, you can also write an
updatesconfig to automatically insert the new todo into thetodosquery result.
5.3 Pattern 3 – Real‑time Updates (Subscriptions)
Goal: Subscribe to a GraphQL subscription and render live data (e.g., chat messages).
Prerequisite: Install a WebSocket transport (
subscriptions-transport-wsorgraphql-ws) and add thesubscriptionExchange.
npm i graphql-ws
// src/urqlClient.ts (add subscription exchange)
import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql';
import { subscriptionExchange } from '@urql/exchange-graphcache';
import { createClient as createWSClient } from 'graphql-ws';
const wsClient = createWSClient({
url: 'wss://my-api.example.com/graphql',
});
export const client = createClient({
url: 'https://my-api.example.com/graphql',
exchanges: [
dedupExchange,
cacheExchange,
subscriptionExchange({
forwardSubscription: operation => ({
subscribe: sink => ({
unsubscribe: wsClient.subscribe(operation, sink),
}),
}),
}),
fetchExchange,
],
});
// src/components/Chat.tsx
import { useSubscription } from 'urql';
import { gql } from 'graphql-tag';
const MESSAGE_SUB = gql`
subscription {
messageAdded {
id
author
content
}
}
`;
export function Chat() {
const [{ data, error }] = useSubscription({ query: MESSAGE_SUB });
if (error) return <p>Subscription error: {error.message}</p>;
return (
<ul>
{data?.messageAdded && (
<li key={data.messageAdded.id}>
<strong>{data.messageAdded.author}:</strong> {data.messageAdded.content}
</li>
)}
</ul>
);
}
Why it works:
- The
subscriptionExchangeintercepts operations withoperation.kind === 'subscription'and forwards them to the WebSocket client. - Incoming payloads are merged into the cache, so any component that reads the same query will automatically update.
5.4 Pattern 4 – Authentication & Token Refresh
Goal: Attach an Authorization header to every request, automatically refresh expired tokens, and handle logout on auth errors.
// src/urqlClient.ts (add auth exchange)
import { authExchange } from '@urql/exchange-auth';
import { createClient, dedupExchange, cacheExchange, fetchExchange } from 'urql';
function getAuth() {
const token = localStorage.getItem('access_token');
return token ? { token } : null;
}
export const client = createClient({
url: 'https://my-api.example.com/graphql',
exchanges: [
dedupExchange,
cacheExchange,
authExchange({
addAuthToOperation: ({ authState, operation }) => {
if (!authState?.token) return operation;
const fetchOptions = {
...operation.context.fetchOptions,
headers: {
...operation.context.fetchOptions?.headers,
Authorization: `Bearer ${authState.token}`,
},
};
return { ...operation, context: { ...operation.context, fetchOptions } };
},
// Called when the client is first created
getAuth: async ({ authState }) => {
if (!authState) {
// No token yet – try to read from storage
const token = localStorage.getItem('access_token');
return token ? { token } : null;
}
// Token exists – check expiry and refresh if needed
const isExpired = /* your logic */;
if (!isExpired) return null; // No refresh needed
// Example refresh flow
const response = await fetch('https://my-api.example.com/refresh', {
method: 'POST',
credentials: 'include',
});
const { access_token } = await response.json();
localStorage.setItem('access_token', access_token);
return { token: access_token };
},
// Optional: handle auth errors globally
willAuthError: ({ authState }) => {
// Return true if token is missing or clearly expired
return !authState?.token;
},
}),
fetchExchange,
],
});
How it works
authExchangeruns beforefetchExchange.addAuthToOperationinjects theAuthorizationheader.getAuthis invoked when the client starts and wheneverwillAuthErrorreturnstrue. It can perform a token refresh and return a new auth state.- If a request fails with a GraphQL error that signals “FORBIDDEN”, you can also add a
mapExchangeto intercept and trigger a logout.
5.5 Pattern 5 – Advanced Normalized Caching (graphcache)
Goal: Enable pagination, optimistic updates, and automatic cache invalidation for complex UI.
// src/urqlClient.ts (add graphcache)
import { cacheExchange } from '@urql/exchange-graphcache';
export const client = createClient({
url: 'https://my-api.example.com/graphql',
exchanges: [
dedupExchange,
cacheExchange({
keys: {
// Tell graphcache how to uniquely identify custom types
Todo: data => data.id,
},
resolvers: {
// Cursor‑based pagination for the `posts` field
Query: {
posts: cursorPagination(),
},
},
updates: {
Mutation: {
// After adding a todo, insert it into the cached `todos` query
addTodo: (result, args, cache, info) => {
const allTodos = cache.resolve('Query', 'todos') as string[];
cache.link('Query', 'todos', [...allTodos, result.addTodo.__ref]);
},
},
},
}),
fetchExchange,
],
});
What you gain
| Feature | Implementation |
| Pagination | cursorPagination() (built‑in) or a custom resolver. |
| Optimistic updates | Provide optimistic objects in useMutation – graphcache merges them into the normalized store. |
| Cache invalidation | Use cache.invalidate inside mutation updates to force a refetch of stale queries. |
| Entity relationships | Define keys so the cache can deduplicate objects across queries. |
With graphcache you can treat the client as a local data store, dramatically reducing the number of network round‑trips for UI‑heavy applications.
6. Frequently Asked Questions (FAQ)
| Question | Answer |
| Is urql only for React? | No. The core client (@urql/core) works with any JavaScript environment. Framework‑specific bindings (@urql/preact, @urql/vue, @urql/svelte, @urql/solid) are thin adapters that expose the same hooks/components. |
| How does urql differ from Apollo Client? | Apollo ships with a large feature set (state management, type policies, devtools, etc.) that you pay for even if you never use them. urql starts minimal and lets you compose only the pieces you need via exchanges. This results in a smaller bundle and clearer mental model. |
| Can I use urql with Next.js SSR? | Absolutely. Add the ssrExchange to the exchange list, create a client per request on the server, and hydrate it on the client. The docs contain a full Next.js example. |
| Do I need TypeScript? | No, but urql ships with full TypeScript definitions, and the community strongly recommends using them for better autocomplete and safety. |
| What is the performance impact of the default document cache? | The cache is an in‑memory Map keyed by the query document + serialized variables. Lookups are O(1) and the cache never grows beyond the number of distinct queries you run, making it negligible for most apps. |
| How do I debug exchange flow? | Install the urql DevTools Chrome/Firefox extension. It visualizes each operation as it passes through exchanges, shows cache snapshots, and lets you replay requests. |
| Is there a built‑in retry mechanism? | Yes. Add the retryExchange (from @urql/exchange-retry) to the chain. You can configure exponential back‑off, max attempts, and which error codes trigger a retry. |
| Can I use urql with server‑side GraphQL (e.g., Hasura, Prisma)? | urql is transport‑agnostic; it works with any GraphQL‑compliant endpoint, including Hasura, Prisma, AWS AppSync, and custom servers. |
| What about file uploads? | Use the multipartFetchExchange from @urql/exchange-multipart-fetch. It detects File objects in variables and sends a multipart/form‑data request. |
| Is there a way to batch multiple queries into a single HTTP request? | Yes. Add the dedupExchange (already included) for de‑duplication, and the batchExchange (@urql/exchange-batch) to combine multiple operations that occur within the same tick. |
7. Conclusion – When to Choose urql?
urql shines in scenarios where bundle size, flexibility, and a clear mental model are priorities:
- Small‑to‑medium apps that need a quick GraphQL hookup without the overhead of Apollo’s cache policies.
- Performance‑critical front‑ends (mobile web, PWAs) where every kilobyte matters.
- Projects that evolve – you can start with the bare client and later add
graphcache,authExchange,retryExchange, or custom middleware as the app grows. - Multi‑framework codebases – the same client instance can be shared between a React admin panel and a Svelte widget, reducing duplication.
If you need an all‑in‑one solution with built‑in state management, optimistic UI, and a massive ecosystem, Apollo may still be the right choice. But for developers who value explicitness, composability, and a lean footprint, urql offers a modern, battle‑tested alternative that scales from a single query to a full‑blown offline‑first application.
Happy querying!