Lesson 13 / 22
GraphQL Client
Field selection moving to the client, naming repeated field sets with fragments, normalizing the nested response into an identity-based store, and partial success joining the error contract.
Contents
The previous lesson ended on a tension: the fields the resource returns do not line up with the fields the screen wants. The North Slope station card shows the station’s name, its last measurement, and its last two measurement rows together; in a REST resource model that means three separate addresses. The alternative — a screen-specific endpoint — ties the server to the interface’s shape.
This lesson takes an approach that relocates the decision. In GraphQL, the server publishes a schema, and the client writes which fields it wants from that schema. One endpoint, one request, exactly the fields asked for. The gain is clear; the cost is two new problems the client must now solve on its own: sharing the same field set across screens, and storing the nested response that comes back.
Field Selection Moving to the Client
A query document selects a subtree of the fields in the schema. If name and
elevation are requested under the station(code: "NS-01") field, the response carries
only those two; if lastMeasurement is requested, it comes in the same request. Nested
fields are gathered in a single round trip, so the per-screen multiplier on latency
disappears.
Three structural differences affect the request layer. First, the address is no longer distinguishing: every query goes to the same endpoint, usually sent with POST. The previous lesson’s “the address is a cache key” rule no longer holds; the key now comes from the query document and its variables combined.
Second, success and failure can coexist in the same response. If a field in the
query cannot be resolved, the HTTP status stays 200 and the body carries both a partial
data and an errors list. This is a case the previous lesson’s four-class error
contract does not cover, and it is addressed at the end of this lesson.
Third, the query’s cost is in the client’s hands. A deep nested selection can produce extra reads per record on the server; selecting a list field under another list field in the schema multiplies the cost. The server reins this in with depth and complexity limits; on the client side, the counterpart is asking only for the fields the screen actually shows.
Fragment: Naming a Field Set
The same field set appears on multiple screens. A measurement row wants the same five fields on both the station card and the measurement history list. Writing that set by hand into every query means some screens fall behind whenever a field is added.
A fragment is a field set named on a type. A query calls the fragment by name, and its definition is attached when the document is assembled. The file below does this stitching as plain string processing: it finds fragment spreads, walks the dependencies recursively, and places each definition into the document exactly once.
// fragment-stitching.mjs — building a document from query fragments const FRAGMENTS = { StationSummary: { on: "Station", fields: ["__typename", "code", "name", "elevation"], }, MeasurementRow: { on: "Measurement", fields: ["__typename", "id", "time", "value", "unit"], }, StationCard: { on: "Station", fields: ["...StationSummary", "lastMeasurement { ...MeasurementRow }"], }, }; // Finds fragment spreads in a field list: every word shaped like "...Name". const spreadNames = (lines) => lines.flatMap((l) => [...l.matchAll(/\.\.\.(\w+)/g)].map((m) => m[1])); function requiredFragments(roots, collected = new Set()) { for (const name of roots) { if (collected.has(name)) continue; // the same fragment is not written twice const fragment = FRAGMENTS[name]; if (!fragment) throw new Error(`undefined fragment: ${name}`); collected.add(name); requiredFragments(spreadNames(fragment.fields), collected); // nested dependencies } return collected; } function buildDocument(name, fields) { const names = requiredFragments(spreadNames(fields)); const definitions = [...names].map((n) => { const f = FRAGMENTS[n]; return `fragment ${n} on ${f.on} {\n ${f.fields.join("\n ")}\n}`; }); return [`query ${name} {\n ${fields.join("\n ")}\n}`, ...definitions].join("\n\n"); } const document = buildDocument("StationPage", [ "station(code: \"NS-01\") {", " ...StationCard", " measurements(last: 2) { ...MeasurementRow }", "}", ]); console.log(document); console.log("---"); console.log("fragments in the document:", (document.match(/^fragment /gm) ?? []).length);
query StationPage {
station(code: "NS-01") {
...StationCard
measurements(last: 2) { ...MeasurementRow }
}
}
fragment StationCard on Station {
...StationSummary
lastMeasurement { ...MeasurementRow }
}
fragment StationSummary on Station {
__typename
code
name
elevation
}
fragment MeasurementRow on Measurement {
__typename
id
time
value
unit
}
---
fragments in the document: 3
MeasurementRow is called from two places in the document — once directly from the
query, once from inside StationCard — but its definition is written once. That is the
real payoff of deduplication: the field set lives in one place.
A fragment’s real design value is that the need for a field is declared next to the code that uses it. The code that renders a measurement row writes which fields it needs in its own fragment; the page query does not know that, it only calls it. When a new field is added to a screen, a single place changes, and every query using that fragment starts receiving it.
Placing __typename in every fragment is not arbitrary. The store in the next section
keys records by the combination of type and id; without type information in the
response, no key can be produced.
Cache Normalization
The response is a nested tree. The same measurement record shows up both under
lastMeasurement and in the measurements list; stored as two copies, one can be
updated while the other goes stale. Normalization turns the tree into a flat,
identity-keyed store: each record lives once, and its place in the tree is left holding
a reference to it.
// normalization.mjs — turning a nested response into a flat store, merging, and reading back const ID_FIELD = { Station: "code", Measurement: "id" }; const cacheKey = (obj) => { const field = ID_FIELD[obj?.__typename]; return field && obj[field] != null ? `${obj.__typename}:${obj[field]}` : null; }; function normalize(value, store) { if (Array.isArray(value)) return value.map((e) => normalize(e, store)); if (value === null || typeof value !== "object") return value; const fields = {}; for (const [name, sub] of Object.entries(value)) fields[name] = normalize(sub, store); const id = cacheKey(value); if (!id) return fields; // an unidentified object stays in place store[id] = { ...(store[id] ?? {}), ...fields }; // merge is field by field return { __ref: id }; } // Reading always takes a selection; the store is cyclic, so walking it // without a selection would never terminate. function read(selection, value, store) { if (Array.isArray(value)) return value.map((e) => read(selection, e, store)); if (value === null || typeof value !== "object") return value; const source = value.__ref ? store[value.__ref] : value; if (!source) return { __missing: value.__ref }; // not in the store const result = {}; for (const [name, sub] of Object.entries(selection)) { if (!(name in source)) { result[name] = { __missing: name }; continue; } result[name] = sub === true ? source[name] : read(sub, source[name], store); } return result; } const store = {}; const roots = {}; // --- First query: station card + last two measurements --------------------- const response1 = { station: { __typename: "Station", code: "NS-01", name: "North Slope", elevation: 1840, lastMeasurement: { __typename: "Measurement", id: "m-114", time: "2026-01-14T06:00Z", value: -4.2, unit: "C", station: { __typename: "Station", code: "NS-01", name: "North Slope" }, }, measurements: [ { __typename: "Measurement", id: "m-114", time: "2026-01-14T06:00Z", value: -4.2, unit: "C" }, { __typename: "Measurement", id: "m-113", time: "2026-01-13T06:00Z", value: -1.8, unit: "C" }, ], }, }; roots['station(code:"NS-01")'] = normalize(response1.station, store); console.log("store keys :", Object.keys(store).join(" ")); console.log("NS-01 :", JSON.stringify(store["Station:NS-01"])); console.log("m-114 :", JSON.stringify(store["Measurement:m-114"])); // --- Second query: another screen wants the same measurement with different fields -- const response2 = { measurement: { __typename: "Measurement", id: "m-114", value: -4.4, recordedBy: "observer-3" } }; roots['measurement(id:"m-114")'] = normalize(response2.measurement, store); console.log("--- after the second response ---"); console.log("m-114 :", JSON.stringify(store["Measurement:m-114"])); console.log("entry count :", Object.keys(store).length); // --- The first screen is re-read from the store with its own selection ----- const CARD = { code: true, name: true, lastMeasurement: { id: true, value: true, unit: true }, measurements: { id: true, value: true }, }; console.log("--- first screen's new view ---"); console.log(JSON.stringify(read(CARD, roots['station(code:"NS-01")'], store))); // --- A screen requesting a field the store does not carry -------------------- const REPORT = { code: true, lastMeasurement: { id: true, deviation: true } }; console.log("--- screen requesting a missing field ---"); console.log(JSON.stringify(read(REPORT, roots['station(code:"NS-01")'], store)));
store keys : Station:NS-01 Measurement:m-114 Measurement:m-113
NS-01 : {"__typename":"Station","code":"NS-01","name":"North Slope","elevation":1840,"lastMeasurement":{"__ref":"Measurement:m-114"},"measurements":[{"__ref":"Measurement:m-114"},{"__ref":"Measurement:m-113"}]}
m-114 : {"__typename":"Measurement","id":"m-114","time":"2026-01-14T06:00Z","value":-4.2,"unit":"C","station":{"__ref":"Station:NS-01"}}
--- after the second response ---
m-114 : {"__typename":"Measurement","id":"m-114","time":"2026-01-14T06:00Z","value":-4.4,"unit":"C","station":{"__ref":"Station:NS-01"},"recordedBy":"observer-3"}
entry count : 3
--- first screen's new view ---
{"code":"NS-01","name":"North Slope","lastMeasurement":{"id":"m-114","value":-4.4,"unit":"C"},"measurements":[{"id":"m-114","value":-4.4},{"id":"m-113","value":-1.8}]}
--- screen requesting a missing field ---
{"code":"NS-01","lastMeasurement":{"id":"m-114","deviation":{"__missing":"deviation"}}}
The output says four things.
The store is flat and holds no duplicates. In the first response, measurement
m-114 appeared in three places; the store holds a single record, and every other place
holds an __ref reference. The station was deduplicated the same way: the station
object embedded inside the measurement did not produce a separate record, it merged with
the existing one.
Merging is field by field. The second query asked for only two of the measurement’s
fields, but the store record kept all five and added the newly arrived recordedBy
field. The new value, -4.4, overwrote the old one. A partial response does not prune
the stored record.
One screen’s data is refreshed by another screen’s request. The station card did not
issue a new request; because the store changed, the value the card reads became -4.4.
A non-normalized cache would have reflected that update only on the second screen.
Reading requires a selection. The store is cyclic: a station refers to a measurement, a measurement refers back to its station. Walking it without a selection never terminates. The reader has to know, at every level, which fields are wanted — so the query document is needed not only at request time but at read time too. The last line shows the follow-through: a field the store does not hold is reported as missing, and at that point a server round trip is required.
Limits of Normalization
The store only deduplicates records that have an identity. Objects without one — a
total, pagination info, a computed summary — stay inside the record they belong to. If a
type in the schema has no id field, or the field name changes from type to type, the
mapping table (ID_FIELD here) needs manual upkeep.
The second limit is lists. measurements(last: 2) and measurements(last: 20) are two
different results of the same field and cannot be stored under a single value. This is
why list fields are keyed together with their arguments. Pagination also needs a
merge rule: does the new page overwrite the previous one, or get appended? The store
cannot know — the application has to say.
The third limit is deletion. When a measurement is deleted, dropping the store record
is not enough; the lists that reference it also need cleaning up. Otherwise reads return
__missing and a gap appears on screen. Which root fields get invalidated after a
mutation is this context’s counterpart to the invalidation rule from the State
Management topic.
Partial Success and the Error Contract
The previous lesson’s contract recognized four classes: network, HTTP, parse,
cancellation. Query-based access brings a fifth: transport succeeded, a field
failed. The response returns 200, some of the requested fields inside data are
null, and the errors list says which path failed with what.
Treating this response as either a single “error” or a single “success” is wrong either way. The request layer must carry three things separately: the partial data obtained, the failed field paths, and whether that failure should be shown to the user. If the station name arrived but the last-measurement field came back empty, the card can still be drawn, with an error notice in place of the measurement section. Dropping the whole screen into an error state throws away the data that was obtained.
The distinction is made with one question: which fields does the screen need to be meaningful? The server cannot answer this; the code that draws the screen knows which field is dispensable. So partial success is not something the request layer can decide on its own — it is carried through, and the decision is made at the call site.
Alongside field-level errors there are also protocol-level errors: an invalid query document, a field not in the schema, a type mismatch. These are reported before the query ever runs, and they are not a user error but a program error; there is no message to show the user, and they need to be caught during development.
Summary
- In query-based access, field selection moves to the client; one endpoint and one request make the round trips per screen independent of the screen’s complexity.
- The address is no longer distinguishing; the cache key is produced from the combination of the query document and its variables.
- A fragment is a field set named on a type; the need for a field is declared next to the code that uses it, and its definition is written into the document once.
- Normalization turns the nested response into a flat store keyed by the combination of type and id; merging is field by field, and one screen’s request refreshes another screen’s data.
- The normalized store is cyclic; reading always takes a selection, and a field the store does not hold is reported as missing.
- Partial success is a fifth error state: the partial data, the failed field paths, and the necessity decision are carried separately; the screen knows which field is dispensable.
Next Step
Up to this point, data has always arrived the moment the client asked for it: the page opened, a request went out, the response was handled. The North Slope station, though, produces a measurement without anyone asking. If a new record is created while the measurement history screen is open, the user should not have to refresh the page to see it. This reverses the direction of data flow: the client still opens the connection, but the server decides the timing. The next lesson covers the two carriers of this reversed flow — the one-way event stream and the two-way socket — and how incoming events get applied to the store.
To keep your progress and take notes, Log in
My notes
Log in to take notes.