Steven's Knowledge

GraphQL

GraphQL from the frontend — schema thinking, fragments, caching, codegen, client comparison

GraphQL

GraphQL solves a specific set of problems — over-fetching, multi-client schemas, deeply nested data — and introduces a different set in return: cache complexity, learning curve, server-side N+1 risk that becomes a frontend problem when ignored.

When GraphQL Earns Its Keep

ScenarioGraphQL fit
Many clients (web, iOS, Android) read the same backendHigh
Highly nested or aggregated reads (dashboards, profile screens)High
Need to evolve schema with versionless deprecationHigh
Mostly simple CRUD on a single clientLow — REST is fine
File uploads as primary use caseLow — REST handles this better
Public API consumed by unknown third partiesModerate — REST is friendlier

If you're already on REST and shipping fine, GraphQL is rarely worth migrating to alone. The win shows up when multiple clients consume the same backend.

Schema-First Thinking

type Order {
  id: ID!
  status: OrderStatus!
  total: Money!
  customer: Customer!
  items: [OrderItem!]!
  createdAt: DateTime!
}

type Query {
  order(id: ID!): Order
  orders(after: String, first: Int = 20, status: OrderStatus): OrderConnection!
}

type Mutation {
  cancelOrder(id: ID!): Order!
  updateOrder(id: ID!, input: UpdateOrderInput!): Order!
}

As a frontend engineer you ask of every query: what is the smallest set of fields this screen actually needs? The temptation to fetch the whole object is the over-fetching that GraphQL was meant to eliminate.

Queries: Ask for What You Need

query OrderDetail($id: ID!) {
  order(id: $id) {
    id
    status
    total { amount currency }
    customer { id name }
    items {
      id quantity
      product { id name imageUrl }
    }
  }
}

Fragments — Reusable Field Sets

fragment OrderRow on Order {
  id
  status
  total { amount currency }
  createdAt
}

query OrderList {
  orders(first: 20) {
    edges { node { ...OrderRow } }
  }
}

query OrderDetail($id: ID!) {
  order(id: $id) {
    ...OrderRow
    customer { id name }
    items { id quantity }
  }
}

Co-locate the fragment with the component that consumes it. When the component needs a new field, you change one place.

Directives: Conditional And Incremental Fields

@skip and @include let one document serve multiple screens without duplicating queries:

query OrderDetail($id: ID!, $withItems: Boolean!) {
  order(id: $id) {
    ...OrderRow
    items @include(if: $withItems) { id quantity }
  }
}

@defer and @stream split one response into incremental chunks so the shell paints before slow fields resolve:

query OrderDetail($id: ID!) {
  order(id: $id) {
    ...OrderRow
    ... @defer {
      recommendations { id name }          # arrives in a later chunk
    }
    items @stream(initialCount: 5) {       # streamed in batches
      id quantity
    }
  }
}

Requires server support (graphql-js ≥17, Apollo Router) and a client that understands multipart/mixed responses. Reach for it on below-the-fold or expensive aggregates — not on everything.

Codegen Is Not Optional

# codegen.yml
schema: https://api.example.com/graphql
documents:
  - 'src/**/*.{ts,tsx}'
generates:
  src/gql/generated.ts:
    plugins:
      - typescript
      - typescript-operations
      - typescript-react-apollo  # or -urql, -react-query

Result: every query/fragment becomes a typed hook.

import { useOrderDetailQuery } from '@/gql/generated';

function OrderDetailScreen({ id }: Props) {
  const { data, loading, error } = useOrderDetailQuery({ variables: { id } });
  if (loading) return <Spinner />;
  if (error) return <ErrorView err={error} />;
  return <OrderView order={data!.order!} />;  // all fields typed
}

Without codegen you re-invent types by hand, and they drift.

Fragment Masking (client-preset)

The modern @graphql-codegen/client-preset generates a typed graphql() function and masks fragment data: a component can only read the fields its own fragment declared, even if the parent over-fetched.

// codegen.ts
generates: {
  './src/gql/': { preset: 'client' },
}
const OrderRow = graphql(`
  fragment OrderRow on Order { id status total { amount currency } }
`);

function Row(props: { order: FragmentType<typeof OrderRow> }) {
  const order = useFragment(OrderRow, props.order);  // unmasks only declared fields
  return <span>{order.status}</span>;
}

This kills the most common GraphQL bug — one component silently reading a field another component requested — at the type level. Prefer it over the older near-operation-file / per-operation hooks for new projects.

Mutations And Cache Updates

mutation CancelOrder($id: ID!) {
  cancelOrder(id: $id) {
    id
    status              # <- so the cache updates correctly
  }
}

Always select the fields you want updated in the cache. Returning just { id } from a mutation means the client can't refresh the changed fields automatically.

Apollo Cache Update

const [cancelOrder] = useCancelOrderMutation({
  update: (cache, { data }) => {
    if (!data) return;
    cache.modify({
      id: cache.identify(data.cancelOrder),
      fields: { status: () => data.cancelOrder.status },
    });
  },
});

Optimistic Response

cancelOrder({
  variables: { id },
  optimisticResponse: {
    cancelOrder: { __typename: 'Order', id, status: 'CANCELLED' },
  },
});

UI flips instantly; if the mutation fails, Apollo rolls back automatically.

Pagination: Relay Spec Cursor Connections

type OrderConnection {
  edges: [OrderEdge!]!
  pageInfo: PageInfo!
}
type OrderEdge { node: Order! cursor: String! }
type PageInfo { hasNextPage: Boolean! endCursor: String }
query Orders($after: String) {
  orders(first: 20, after: $after) {
    edges { node { ...OrderRow } cursor }
    pageInfo { hasNextPage endCursor }
  }
}

Apollo's fetchMore and field policies merge pages:

new InMemoryCache({
  typePolicies: {
    Query: {
      fields: {
        orders: relayStylePagination(),
      },
    },
  },
});

Client Comparison

ClientCacheBundleDXWhen to pick
Apollo ClientNormalized~30 KBBest ecosystem, complexDefault for most apps
urqlDocument or normalized (Graphcache)~6 KBLean, modularBundle-conscious teams
RelayNormalized + compiler~40 KBStrict, fragment-drivenLargest apps with experienced teams
TanStack Query + graphql-requestNone (or per-query)~12 KBSimplestIf you don't need a normalized cache

Apollo

  • Pros: huge ecosystem, normalized cache, dev tools, mature subscriptions-transport-ws.
  • Cons: cache reasoning has a learning curve; bundle size.

urql

  • Pros: lean, plug-and-play exchanges, easy SSR.
  • Cons: ecosystem smaller than Apollo.

Relay

  • Pros: best-in-class compile-time guarantees, fragment co-location enforced.
  • Cons: steepest learning curve, requires server to follow Relay spec.

Where Your Schema Comes From: Federation & BFF

In a multi-team org the single endpoint you query is usually composed, not hand-written:

ApproachWhat it isFrontend impact
Monolith schemaOne server owns every typeSimplest; that team is the bottleneck
Apollo FederationA gateway composes subgraphs owned by different teamsOne endpoint, types span teams; watch cross-subgraph latency
Schema stitchingOlder manual merge of remote schemasLegacy; prefer federation
BFF (Backend for Frontend)A thin GraphQL layer per client, wrapping REST/gRPCYou own the schema; tailor it to the screen and aggregate downstream calls

A BFF is often the pragmatic win: the backend stays REST/gRPC, and a small GraphQL layer (frequently owned by the frontend team) buys query flexibility without forcing the whole org onto GraphQL. With federation, an @key directive marks a type as resolvable across subgraphs — a single entity reference can silently add a gateway round trip, so treat cross-subgraph fields like any other latency source.

N+1 As A Frontend Concern

A poorly written backend resolver can issue N database queries for an N-item list. The frontend often surfaces this as a single slow query.

Signs:

  • Wall-clock time grows linearly with first: N.
  • Server-side traces show repeated DB hits per field.

The fix is on the backend (DataLoader / batched joins), but the frontend should:

  • Avoid speculative deep nesting in lists.
  • Use @defer / @stream for fields that are slow but not critical for first paint.

Caching: The Hardest Part

Apollo and Relay use a normalized cache — every object is stored by its __typename:id. Two queries returning the same Order share the same cache entry; updating one updates everywhere.

Required Setup

Every type the cache normalizes needs an ID, or keyFields:

new InMemoryCache({
  typePolicies: {
    Order: { keyFields: ['id'] },
    OrderItem: { keyFields: ['orderId', 'productId'] },  // composite
  },
});

Missing IDs fall back to the parent — invisible cache "leaks" that don't update on mutation.

Eviction And Garbage Collection

Normalized caches grow forever unless you prune them. After deleting an entity, evict it and run GC so dangling references are swept:

cache.evict({ id: cache.identify(order) });
cache.gc();  // removes objects no longer reachable from a root query

gc() only collects objects with no remaining references — orphan a list entry without evicting it and it lingers. For long-lived sessions (kiosks, desktop apps) schedule periodic gc() or cap retention with field policies.

Subscriptions

subscription OnOrderUpdated($id: ID!) {
  orderUpdated(id: $id) { id status }
}
useOrderUpdatedSubscription({
  variables: { id },
  onData: ({ data }) => {
    // cache updates automatically because we selected `id` and the field changed
  },
});

Transport: prefer graphql-ws (WebSocket sub-protocol) over the deprecated subscriptions-transport-ws.

Error Handling

GraphQL responses can carry both data and errors. A 200 status does not mean success.

const { data, error } = useOrderQuery({ variables: { id } });

if (error) {
  // network error or GraphQL error
}
if (data?.order === null) {
  // explicit not-found (different from missing-field error)
}

Error Categories

CategoryWhereFrontend handles
NetworkTransport failedRetry / offline UI
GraphQL syntaxBad query (codegen catches at build time)Should never reach prod
Resolver errorServer threwShow error, log
ValidationInput invalidMap to form field errors
Authextensions.code === 'UNAUTHENTICATED'Refresh token / sign in

Use error link / exchange to centralize:

const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
  if (graphQLErrors?.some((e) => e.extensions?.code === 'UNAUTHENTICATED')) {
    return fromPromise(refreshToken()).flatMap(() => forward(operation));
  }
});

Persisted Queries

In production, send a hash instead of the full query string. Smaller payload, better CDN caching, prevents arbitrary queries from public clients.

const link = createPersistedQueryLink({ sha256 }).concat(httpLink);

Pair with Automatic Persisted Queries (APQ) so unknown hashes fall back to sending the full query once.

Testing Components That Query

Mock at the network or client boundary — never stub the hooks themselves.

Apollo MockedProvider — declarative request/response pairs:

const mocks = [{
  request: { query: OrderDetailDocument, variables: { id: '1' } },
  result: { data: { order: { __typename: 'Order', id: '1', status: 'PAID' } } },
}];

render(<MockedProvider mocks={mocks}><OrderDetailScreen id="1" /></MockedProvider>);

MSW with a GraphQL handler — client-agnostic, matches by operation name, reusable in Storybook and E2E:

graphql.query('OrderDetail', () =>
  HttpResponse.json({ data: { order: { id: '1', status: 'PAID' } } }),
);

Prefer MSW for one mock layer across unit, Storybook, and integration tests; prefer MockedProvider for tight Apollo cache-behavior assertions.

Common Pitfalls

SymptomCauseFix
Cache shows stale data after mutationMutation didn't return changed fieldsSelect the fields in the mutation
null everywhere after cache.modifyMissing keyFields for the typeDefine keyFields
Bundle too largePulling whole Apollo on a small appTry urql or graphql-request + TanStack Query
Subscriptions silently failWrong transport / proxy strips upgradeUse graphql-ws, verify WS in dev
Slow queryResolver N+1Backend fix (DataLoader); use @defer if available
Fragment fields undefinedComponent requested fragment, parent query didn't include itAlways spread fragments in the parent query
Codegen driftsNo CI checkRun codegen in CI, fail on diff

On this page