Now, let’s talk about getting this glorious, type-safe GraphQL data into your React app without pulling your hair out. You’ve got your schema, you’ve generated your TypeScript types—congratulations, you’re already ahead of 90% of the folks yelling at their screens. But now you need a client to actually fetch the data. This is where you might be tempted to reach for the 800-pound gorilla, Apollo Client. It’s powerful, it’s popular… and it’s a lot. Sometimes, you just want to make a simple query, not configure a planetary probe.

Enter URQL. It stands for “Universal React Query Library,” which is a bit of a mouthful, so we’ll stick with the acronym. Its core philosophy is brilliant in its simplicity: be lightweight, be flexible, and get out of your way. It doesn’t assume you need a full-blown state management behemoth for your remote data. Often, you don’t.

The Core Exchange: It’s All About the Middleware

The first thing you need to wrap your head around with URQL is its architecture, centered on the concept of an Exchange. Think of an exchange as a piece of middleware that handles an operation (a query, mutation, or subscription) at a specific stage. The default set of exchanges URQL uses gives you a solid, cache-enabled client right out of the box.

The magic happens when you create the client. You’ll typically use the ssrExchange for Next.js or other server-side rendering, the cacheExchange for… well, caching (it’s a stellar in-memory cache), and the fetchExchange which is the workhorse that actually sends your requests to the GraphQL API.

import { createClient, dedupExchange, cacheExchange, fetchExchange, ssrExchange } from '@urql/core';
import { Provider } from 'urql';

// For SSR scenarios, you'd create this outside of your component render function
const ssrCache = ssrExchange({ isClient: true });

const client = createClient({
  url: 'https://your-graphql-api.com/graphql',
  exchanges: [dedupExchange, cacheExchange, ssrCache, fetchExchange],
});

// Then wrap your app with the Provider
function App({ children }) {
  return <Provider value={client}>{children}</Provider>;
}

The dedupExchange is a tiny bit of genius that prevents you from firing off the same identical query multiple times in parallel. It’s a simple, effective performance win.

Hooking Into Your Data

Using URQL in your components is dead simple thanks to its React hooks. The main ones you’ll live in are useQuery and useMutation. Here’s the beautiful part: because you’ve already run codegen, you can slot your generated types right in and get full end-to-end type safety.

import { useQuery } from 'urql';
import { graphql } from '../gql'; // Your generated graphql function

// Define your query using the gql tag function from codegen
const GetProductsDocument = graphql(`
  query GetProducts($category: String!) {
    products(category: $category) {
      id
      name
      price
    }
  }
`);

function ProductList({ category }) {
  // Pass the typed document and variables. Look at that beautiful type inference!
  const [result] = useQuery({
    query: GetProductsDocument,
    variables: { category },
  });

  const { data, fetching, error } = result;

  if (fetching) return <p>Loading...</p>;
  if (error) return <p>Oh no... {error.message}</p>;

  return (
    <ul>
      {data?.products.map((product) => (
        <li key={product.id}>
          {product.name} - ${product.price}
        </li>
      ))}
    </ul>
  );
}

See that? The type of data is automatically inferred as { products: Array<{ id: string; name: string; price: number }> } | undefined | null. You get autocomplete on product., and if you try to access product.description (which we didn’t query for), TypeScript will throw a fit. This is the payoff. This is why we did the work.

Mutations and the Imperative Mindset

Mutations work on a slightly different mental model. While queries are largely reactive (they run when variables change), mutations are imperative. You call a function to fire them off.

import { useMutation } from 'urql';
import { graphql } from '../gnl';

const CreateProductDocument = graphql(`
  mutation CreateProduct($name: String!, $price: Numeric!) {
    createProduct(input: { name: $name, price: $price }) {
      id
      name
      price # Always query for the data you want back!
    }
  }
`);

function CreateProductForm() {
  const [createResult, executeMutation] = useMutation(CreateProductDocument);

  const handleSubmit = (event) => {
    event.preventDefault();
    const formData = new FormData(event.target);
    executeMutation({
      name: formData.get('name'),
      price: parseFloat(formData.get('price')),
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* ... form fields ... */}
    </form>
  );
}

A common pitfall here is forgetting to query for fields in the mutation response. URQL’s cache is normalized, so if you return the id and the new data, it can automatically update any queries watching that same object. It’s smart, but it’s not psychic. You have to give it the data to work with.

When to Reach for Something Else

URQL is brilliant, but it’s not a religious choice. Its lightweight nature is its greatest strength and its primary weakness. If you find yourself needing extremely granular control over a massive, complex cache (like manually manipulating nested paginated lists after every mutation), you might start bumping into its limits. Apollo Client’s InMemoryCache is objectively more powerful and configurable for those truly byzantine state management scenarios. The trade-off is bundle size and complexity. It’s a valid trade! Just know what you’re signing up for. For probably 80% of applications, URQL is not just easier; it’s the better, more focused tool for the job.