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?
answer
- the page reads a child's field
- request results hide fragment fields
- declare what you read
- an escape hatch on the spread
- migrate mode warns first
basics
~20 sWith 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 sThe 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 linesquery ProductList {
products {
id
inStock
...ProductCardFragment
}
}go deeper
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.
Explain which APIs return masked data and which do not, and how a child reads its own fields with useFragment once masking is on.
Diagnose a field that turned undefined after enabling masking, fix it by declaring the dependency, and plan an incremental rollout with migrate mode.
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.