Typograph

urql

Drop-in typed React hooks built on top of urql.

For React apps, typograph ships a first-party urql integration. It gives you typed useQuery, useMutation, and useSubscription hooks with no extra wiring — you call them with the same selection sets you'd write for the core client, and the variables and response types are inferred from your schema.

You don't have to use it. Typograph's core client emits a plain GraphQL string and a variables object, so you can pair the schema with any GraphQL client. The urql integration is just the shortest path to a typed React app.

Setup

The integration lives at typograph/integrations/urql and exposes a single factory:

import { createUrqlIntegration } from "@overstacked/typograph/integrations/urql";

Wire it up once at app boot, alongside your urql Client:

urql-client.ts
import { Client, cacheExchange, fetchExchange } from "urql";
import { createUrqlIntegration } from "@overstacked/typograph/integrations/urql";
import { typeDefs } from "./schema";

export const client = new Client({
  url: "http://localhost:3001/graphql",
  exchanges: [cacheExchange, fetchExchange],
});

export const { useQuery, useMutation, useSubscription } =
  createUrqlIntegration(typeDefs);

The integration hands typograph the schema, which is what gives the hooks their typed selection sets. The urql Client itself still handles transport, caching, and provider context — typograph only owns the type bridge.

Using the hooks

Components import the hooks from your local urql-client.ts and pass typograph selection sets directly. There's no gql template literal and no string-based query.

Posts.tsx
import { args } from "@overstacked/typograph";
import { useQuery, useMutation, useSubscription } from "./urql-client";

export const Posts = () => {
  // Query with a nested field arg — `commentLimit` is a top-level variable.
  const [{ data }] = useQuery(
    {
      listPosts: {
        id: true,
        title: true,
        comments: args({ limit: "$commentLimit" }, { id: true, body: true }),
      },
    },
    { variables: { commentLimit: 5 } },
  );

  // Mutation — typed against the schema's input shape.
  const [, createPost] = useMutation({
    createPost: { id: true, title: true },
  });

  // Subscription — returns urql's native [state, reexecute] tuple.
  const [{ data: feed }] = useSubscription({
    postCreated: { id: true, title: true },
  });

  return (
    <>
      {data?.listPosts.map((p) => (
        <article key={p.id}>
          <h2>{p.title}</h2>
          <ul>
            {p.comments.map((c) => (
              <li key={c.id}>{c.body}</li>
            ))}
          </ul>
        </article>
      ))}
      <button onClick={() => createPost({ title: "Hello", body: "World" })}>
        New post
      </button>
    </>
  );
};

data?.listPosts, the createPost arguments, and feed are all typed directly from the selection set. There's no codegen and no manual response types.

Don't forget to wrap your app in urql's Provider:

App.tsx
import { Provider } from "urql";
import { client } from "./urql-client";
import { Posts } from "./Posts";

export const App = () => (
  <Provider value={client}>
    <Posts />
  </Provider>
);

Subscriptions over SSE

graphql-yoga serves subscriptions over text/event-stream using named events (event: next, event: complete) rather than the default unnamed message event the browser's EventSource listens to via .onmessage. You have to attach explicit listeners for next and complete for payloads to reach urql's sink.

Here's a dependency-free SSE exchange that speaks graphql-yoga's dialect:

urql-client.ts (continued)
import {
  Client,
  cacheExchange,
  fetchExchange,
  subscriptionExchange,
} from "urql";

const GRAPHQL_URL = "http://localhost:3001/graphql";

export const client = new Client({
  url: GRAPHQL_URL,
  exchanges: [
    cacheExchange,
    fetchExchange,
    subscriptionExchange({
      forwardSubscription: (request) => ({
        subscribe: (sink) => {
          const url = new URL(GRAPHQL_URL);
          if (request.query) {
            url.searchParams.set("query", request.query);
          }
          if (
            request.variables &&
            Object.keys(request.variables).length > 0
          ) {
            url.searchParams.set(
              "variables",
              JSON.stringify(request.variables),
            );
          }

          const es = new EventSource(url.toString());

          const handleNext = (event: MessageEvent) => {
            try {
              sink.next(JSON.parse(event.data));
            } catch (err) {
              sink.error(err);
            }
          };
          const handleComplete = () => {
            sink.complete();
            es.close();
          };
          const handleError = () => {
            sink.error(new Error("Subscription connection error"));
          };

          es.addEventListener("next", handleNext);
          es.addEventListener("complete", handleComplete);
          es.addEventListener("error", handleError);

          return {
            unsubscribe: () => {
              es.removeEventListener("next", handleNext);
              es.removeEventListener("complete", handleComplete);
              es.removeEventListener("error", handleError);
              es.close();
            },
          };
        },
      }),
    }),
  ],
});

A production app would usually reach for graphql-sse or graphql-ws instead of rolling its own client. The example above stays dependency-free so the demo can run as-is, but anything that implements urql's forwardSubscription Observable contract will work.

Where to next

On this page