skip to content

In Apollo Client 4, after turning on dataMasking, the product listing page's in-stock filter hides every product; why, and what is the right fix?

level: seniorimportance: should knowfreq 34%

answer

  1. the page reads a child's field
  2. request results hide fragment fields
  3. declare what you read
  4. an escape hatch on the spread
  5. migrate mode warns first

basics

~20 s

With dataMasking on, useQuery returns only fields the query selects itself, so inStock, declared only in ProductCardFragment, is undefined and the filter drops everything. Select inStock in the page query; @unmask is the escape hatch.

solid answer

~40 s

The filter was relying on an **implicit dependency**: `inStock` was selected only inside `ProductCardFragment`, and without masking Apollo returned every field in the operation to the page. With `dataMasking: true` on the `ApolloClient`, values read from request-based APIs such as `useQuery`, `client.query` and `client.mutate` contain only the fields the operation selects directly, plus `__typename`; fields from named fragment spreads are hidden, so `product.inStock` is `undefined` and `filter` keeps nothing. The fix is to make the dependency explicit: select `inStock` in the `ProductList` query itself, beside the spread. `ProductCard` keeps reading its own fields with `useFragment`. `@unmask` on the spread would also restore the field, but it unmasks the whole fragment and is meant for migration; `@unmask(mode: "migrate")` keeps the data visible while logging development warnings for every would-be masked read.

code

graphql · 7 lines
graphql
query ProductList {
  products {
    id
    inStock
    ...ProductCardFragment
  }
}

go deeper

for a junior

Recall that dataMasking hides a child's fragment fields from the component that ran the query, and that it is off unless the client enables it.

for a middle

Explain which APIs return masked data and which do not, and how a child reads its own fields with useFragment once masking is on.

for a senior

Diagnose a field that turned undefined after enabling masking, fix it by declaring the dependency, and plan an incremental rollout with migrate mode.

for a principal

Decide whether a codebase should adopt masking: enforced component boundaries and fewer implicit dependencies against a migration cost and more useFragment subscriptions.

## The implicit dependency masking exposes By default Apollo Client returns every field of an operation to the component that ran it, including fields that only a child's fragment selected. On the product listing page, `ProductCardFragment` selects `name`, `imageUrl` and `inStock` because the card shows a stock badge, and the page's `ProductList` query selects `products { id ...ProductCardFragment }`. The page also filters out sold-out products with `products.filter((p) => p.inStock)`. That filter works only because the card happened to ask for `inStock`. The page never declared the field. If the card's designer drops the stock badge and removes `inStock` from the fragment, the page breaks in a file nobody touched. Apollo's documentation calls this an **implicit dependency** between components. ## What dataMasking changes Data masking arrived in Apollo Client 3.12 and is turned on with `new ApolloClient({ dataMasking: true, ... })`; the option defaults to `false`. When it is on: - Values read from **request-based APIs** contain only the fields selected directly by the operation, plus `__typename`. Fields reached through a named fragment spread are hidden unless the spread says otherwise. - **Inline fragments are not masked.** An `... on Product { inStock }` selection stays visible to the page, because only named spreads mark a component boundary. - Components read their own fragment fields with **`useFragment`**, from the object the parent passes down. | API | Masked? | |---|---| | `useQuery` / `useSuspenseQuery` `data` | yes | | `client.query` and `client.mutate` results | yes | | `useMutation` result `data` | yes | | `useSubscription` `data` | yes, but the `onData` callback receives unmasked data | | a mutation's `update` function and `refetchQueries` callback | no | | `cache.readQuery`, `cache.readFragment` | no, cache APIs are never masked | The rule of thumb: anything that reads data for rendering from a request is masked; anything that writes to the cache sees everything. ## What the page actually receives With masking on, the page's `data` for `ProductList` looks like this, even though the network response and the cache both hold `name`, `imageUrl` and `inStock` for every product: ```json { "products": [{ "__typename": "Product", "id": "42" }] } ``` The card still works, because it receives that object as a prop and reads its own fields with `useFragment`, which reads the cache rather than the page's result. The only code that breaks is code outside the card that relied on the card's fields. ## Diagnosing the empty list 1. Log `data.products[0]` in the page: it shows `{ __typename: "Product", id: "42" }` and nothing else. 2. Search the page for fields it reads but does not select; `inStock` is one. 3. Check the network response: `inStock` is still there. Masking does not change the request or what the cache stores, only what the page is handed. ## Fixes, in order of preference 1. **Select the field where it is read.** Add `inStock` to the `ProductList` query beside `...ProductCardFragment`. The page now owns its dependency, and the card is free to change its fragment. 2. **Move the logic to the owner** if only the card cares, for example rendering the stock badge inside `ProductCard` from its `useFragment` data. 3. **`@unmask` on the spread** (`...ProductCardFragment @unmask`) returns the whole fragment's fields to the page. It works, but it removes the boundary for every field in that fragment, so Apollo treats it as an escape hatch. 4. **`@unmask(mode: "migrate")`** returns the data unmasked and, in development, logs `Accessing unmasked field on query 'ProductList' at path 'products[0].inStock'` when the page reads a would-be masked field. That is the tool for finding every hidden dependency before removing the directive. ## Adopting masking in an existing app - Apply `@unmask(mode: "migrate")` to every named spread; Apollo ships a codemod for this. - Turn on `dataMasking` in the same change, so new queries are masked from the start. - Generate masked TypeScript types. In Apollo Client 4 the masking types are configured by declaring `TypeOverrides` that extend `GraphQLCodegenDataMasking.TypeOverrides` from `@apollo/client/masking`. - Refactor components to `useFragment` wherever warnings appear, then remove the directives when the warnings stop. ## Edges worth knowing - An operation with `fetchPolicy: "no-cache"` never writes the cache, so children cannot read masked fragments with `useFragment`; Apollo warns and suggests `@unmask` on those spreads. - `@unmask` has no effect while `dataMasking` is off. - The parent must still select the key fields of any object it passes to a child, or the child's `useFragment` cannot identify it.

  • With dataMasking on, why can a mutation's update function in Apollo Client still read inStock from the result?
    Masking applies to values handed to components from request-based APIs. APIs that update the cache are never masked: a mutation's `update` function, the `refetchQueries` callback, `cache.readQuery` and `cache.readFragment` all see full data, because writing a correct cache entry needs every field. Only the `data` a component renders from is masked.
  • How would you roll dataMasking out across an existing Apollo Client app with hundreds of fragment spreads?
    Run Apollo's codemod to add `@unmask(mode: "migrate")` to every named spread, and turn on `dataMasking` in the same change so new code starts masked. Generate masked types. Then follow the development warnings: each one names a component reading a field it did not select. Either select the field there or switch the child to `useFragment`, and remove the directive once its warnings stop.
  • A report query uses fetchPolicy 'no-cache', and under dataMasking its child components render nothing. Why?
    Masked children read their fields with `useFragment`, which reads from the cache, and a `no-cache` operation never writes the cache. The fragment data therefore exists nowhere a child can reach it. Apollo warns that fragments masked by data masking are inaccessible with `no-cache`; add `@unmask` to those spreads or use a caching fetch policy.

saying these in an interview costs you the question

  • Data masking is on by default in Apollo Client 4.
  • Masking removes the fragment's fields from the network request.
  • cache.readQuery and a mutation's update function return masked data too.
  • Inline fragments are masked the same way as named fragment spreads.
  • The clean fix is @unmask on every spread, left in place permanently.
  • Turning masking on only changes TypeScript types, not runtime data.