Mirror: The highly customizable and versatile GraphQL client with which you add on features like normalized caching as you grow.

title: Architecture order: 3#

Architecture#

urql is a highly customizable and flexible GraphQL client. As you use it in your app, it's split into three parts:

  • Bindings — such as for React, Preact, Vue, or Svelte — which interact with @urql/core's Client.
  • The Client — as created with the core @urql/core package, which interacts with "exchanges" to execute GraphQL operations, and which you can also use directly.
  • Exchanges, which provide functionality like fetching or caching to the Client.

By default, urql aims to provide the minimal amount of features that allow us to build an app quickly. However, urql has also been designed to be a GraphQL Client that grows with our usage and demands. As we go from building our smallest or first GraphQL apps to utilising its full functionality, we have tools at our disposal to extend and customize urql to our liking.

Using GraphQL Clients#

You may have worked with a GraphQL API previously and noticed that using GraphQL in your app can be as straightforward as sending a plain HTTP request with your query to fetch some data.

GraphQL also provides an opportunity to abstract away a lot of the manual work that goes with sending these queries and managing the data. Ultimately, this lets you focus on building your app without having to handle the technical details of state management in detail.

Specifically, urql simplifies three common aspects of using GraphQL:

  • Sending queries and mutations and receiving results declaratively
  • Abstracting caching and state management internally
  • Providing a central point of extensibility and integration with your API

In the following sections we'll talk about the way that urql solves these three problems and how the logic is abstracted away internally.

Requests and Operations on the Client#

If urql was a train it would take several stops to arrive at its terminus, our API. It starts with us defining queries or mutations by writing in GraphQL's query language.

Any GraphQL request can be abstracted into its query documents and its variables.

import { gql } from '@urql/core';

const query = gql`
  query ($name: String!) {
    helloWorld(name: $name)
  }
`;

const request = createRequest(query, {
  name: 'Urkel',
});

In urql, these GraphQL requests are treated as unique objects and each GraphQL request will have a key generated for them. This key is a hash of the query document and the variables you provide and are set on the key property of a GraphQLRequest.

Whenever we decide to send our GraphQL requests to a GraphQL API we start by using urql's Client. The Client accepts several options to configure its behaviour and the behaviour of exchanges, like the fetchExchange. For instance, we can pass it a url which the fetchExchange will use to make a fetch call to our GraphQL API.

import { Client, cacheExchange, fetchExchange } from '@urql/core';

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

Above, we're defining a Client that is ready to accept our requests. It will apply basic document caching and will send uncached requests to the url we pass it. The bindings that we've seen in the "Basics" section, like useQuery for React for example, interact with the Client directly and are a thin abstraction.

Some methods can be called on it directly however, as seen on the "Core Usage" page.

// Given our request and client defined above, we can call
const subscription = client.executeQuery(request).subscribe(result => {
  console.log(result.data);
});

As we've seen, urql defines our query documents and variables as GraphQLRequests. However, since we have more metadata that is needed, like our url option on the Client, urql internally creates Operations each time a request is executed. The operations are then forwarded to the exchanges, like the cacheExchange and fetchExchange.

An "Operation" is an extension of GraphQLRequests. Not only do they carry the query, variables, and a key property, they will also identify the kind of operation that is executed, like "query" or "mutation", and they contain the Client's options on operation.context.

Operations and Results

This means, once we hand over a GraphQL request to the Client, it will create an Operation, and then hand it over to the exchanges until a result comes back.

As shown in the diagram, each operation is like an event or signal for a GraphQL request to start, and the exchanges will eventually send back a corresponding result. However, because the cache can send updates to us whenever it detects a change, or you could cancel a GraphQL request before it finishes, a special "teardown" Operation also exists, which cancels ongoing requests.

The Client and Exchanges#

To reiterate, when we use urql's bindings for our framework of choice, methods are called on the Client, but we never see the operations that are created in the background from our bindings. We call a method like client.executeQuery (or it's called for us in the bindings), an operation is issued internally when we subscribe with a callback, and later, we're given results.

Operations stream and results stream

While we know that, for us, we're only interested in a single Operation and its OperationResults at a time, the Client treats these as one big stream. The Client sees an incoming flow of all of our operations.

As we've learned before, each operation carries a key and each result we receive carries the original operation. Because an OperationResult also carries an operation property the Client will always know which results correspond to an individual operation. However, internally, all of our operations are processed at the same time concurrently. However, from our perspective:

  • We subscribe to a "stream" and expect to get results on a callback
  • The Client issues the operation, and we'll receive some results back eventually as either the cache responds (synchronously), or the request gets sent to our API.
  • We eventually unsubscribe, and the Client issues a "teardown" operation with the same key as the original operation, which concludes our flow.

The Client itself doesn't actually know what to do with operations. Instead, it sends them through "exchanges". Exchanges are akin to middleware in Redux and have access to all operations and all results. Multiple exchanges are chained to process our operations and to execute logic on them, one of them being the fetchExchange, which as the name implies sends our requests to our API.

How operations get to exchanges#

We now know how we get to operations and to the Client:

  • Any bindings or calls to the Client create an operation
  • This operation identifies itself as either a "query", "mutation" or "subscription" and has a unique key.
  • This operation is sent into the exchanges and eventually ends up at the fetchExchange (or a similar exchange)
  • The operation is sent to the API and a result comes back, which is wrapped in an OperationResult
  • The Client filters the OperationResult by the operation.key and — via a callback — gives us a stream of results.

To come back to our train analogy from earlier, an operation, like a train, travels from one end of the track to the terminus — our API. The results then come back on the same path as they're just travelling the same line in reverse.

The Exchanges#

By default, the Client doesn't do anything with GraphQL requests. It contains only the logic to manage and differentiate between active and inactive requests and converts them to operations. To actually do something with our GraphQL requests, it needs exchanges, which are like plugins that you can pass to create a pipeline of how GraphQL operations are executed.

By default, you may want to add the cacheExchange and the fetchExchange from @urql/core:

  • cacheExchange: Caches GraphQL results with "Document Caching"
  • fetchExchange: Executes GraphQL requests with a fetch HTTP call
import { Client, cacheExchange, fetchExchange } from '@urql/core';

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

As we can tell, exchanges define not only how GraphQL requests are executed and handled, but also get control over caching. Exchanges can be used to change almost any behaviour in the Client, although internally they only handle incoming & outgoing requests and incoming & outgoing results.

Some more exchanges that we can use with our Client are:

  • mapExchange: Allows changing and reacting to operations, results, and errors
  • ssrExchange: Allows for a server-side renderer to collect results for client-side rehydration.
  • retryExchange: Allows operations to be retried on errors
  • persistedExchange: Provides support for Automatic Persisted Queries
  • authExchange: Allows refresh authentication to be implemented easily.
  • requestPolicyExchange: Automatically refreshes results given a TTL.
  • devtoolsExchange: Provides the ability to use the urql-devtools

We can even swap out our document cache, which is implemented by @urql/core's cacheExchange, with urql's normalized cache, Graphcache.

Read more about exchanges and how to write them from scratch on the "Authoring Exchanges" page.

Stream Patterns in urql#

In the previous sections we've learned a lot about how the Client works, but we've always learned it in vague terms — for instance, we've learned that we get a "stream of results" or urql sees all operations as "one stream of operations" that it sends to the exchanges. But, what are streams?

Generally we refer to streams as abstractions that allow us to program with asynchronous events over time. Within the context of JavaScript we're specifically thinking in terms of Observables and Reactive Programming with Observables. These concepts may sound intimidating, but from a high-level view what we're talking about can be thought of as a combination of promises and iterables (e.g. arrays). We're dealing with multiple events, but our callback is called over time. It's like calling forEach on an array but expecting the results to come in asynchronously.

As a user, if we're using the one framework bindings that we've seen in the "Basics" section, we may never see these streams in action or may never use them even, since the bindings internally use them for us. But if we use the Client directly or write exchanges then we'll see streams and will have to deal with their API.

Stream patterns with the client#

When we call methods on the Client like client.executeQuery or client.query then these will return a "stream" of results.

It's normal for GraphQL subscriptions to deliver multiple results, however, even GraphQL queries can give you multiple results in urql. This is because operations influence one another. When a cache invalidates a query, this query may refetch, and a new result is delivered to your application.

Multiple results mean that once you subscribe to a GraphQL query via the Client, you may receive new results in the future.

import { gql } from '@urql/core';

const QUERY = gql`
  query Test($id: ID!) {
    getUser(id: $id) {
      id
      name
    }
  }
`;

client.query(QUERY, { id: 'test' }).subscribe(result => {
  console.log(result); // { data: ... }
});

Read more about the available APIs on the Client in the Core API docs.

Internally, these streams and all exchanges are written using a library called wonka, which is a tiny Observable-like library. It is used to write exchanges and when we interact with the Client it is used internally as well.