web-data-fetching-graphql-apollo-6482e2 — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited web-data-fetching-graphql-apollo-6482e2 (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Quick Guide: Apollo stores every entity once, keyed by__typenameplus itskeyFields, and re-renders everything watching it. Most of the difficulty is in the cache rather than the hooks:keyFieldsdecides identity,keyArgsdecides how many cache entries a paginated field gets, and an optimistic response missing__typenamefails to normalize without saying so. v3.9 added the Suspense hooks; v4 moved the React hooks to@apollo/client/reactand typed the error classes.
Detailed Resources:
useQuery, useLazyQuery, mutations with cache updatesfetchMore with an observer, relay and offset type policiesgraphql-ws, useSubscription writing to cacheuseSuspenseQuery, useLoadableQuery, useBackgroundQuery, createQueryPreloaderMockedProvider, mock shapes, asserting cache updates@apollo/client/react, links are constructed withnew HttpLink(), uri on the client is gone, and errors are CombinedGraphQLErrors / ServerError rather than one ApolloError. The full map is in reference.md; npx @apollo/client-codemod-migrate-3-to-4 does the mechanical part.
loading, and errors throw tothe nearest error boundary. Pattern 9, then examples/suspense.md.
useQuery returns loading, error and data, and the component renderseach state itself. Patterns 2 onward.
<critical_requirements>
Generate the operation types from the schema. A hand-written response type is a second copy of the schema that nothing keeps in step, and it goes wrong silently — the field the backend added is absent from the type and absent from every render that needed it.
Put `__typename` and the identifying field in every optimistic response. Without them the entry cannot be normalized, so the optimistic write lands nowhere and the UI does not move until the server answers.
Give every entity type a `keyFields` policy. It is what decides whether two responses are the same entity, and the default ["id"] is wrong for anything keyed on sku, a slug, or a pair.
</critical_requirements>
Auto-detection: ApolloClient, InMemoryCache, ApolloProvider, useQuery, useLazyQuery, useMutation, useSubscription, useFragment, useSuspenseQuery, useLoadableQuery, useBackgroundQuery, useReadQuery, createQueryPreloader, typePolicies, keyFields, keyArgs, cache.modify, cache.evict, relayStylePagination, makeVar, gql
Applies to:
fetchMoreHandled elsewhere:
cases here, and anything derived or complex belongs to whatever owns client state
<philosophy>
The cache is the product. A response is not stored as a response — it is split into entities keyed by __typename plus keyFields, and every hook watching one of those entities re-renders when it changes. So one mutation updates every list, detail view and badge showing that entity, without any of them refetching.
The corollary is that everything which can go wrong with Apollo is an identity question: two responses that should have been one entry, one entry that should have been two, or a write the cache could not place because it did not know what it was.
</philosophy>
<patterns>
Build the cache with a type policy per entity, and compose the link chain so auth and error handling sit in front of the transport.
const cache = new InMemoryCache({
typePolicies: {
User: { keyFields: ["id"] },
Product: { keyFields: ["sku"] }, // identity is not always "id"
CartItem: { keyFields: false }, // embed in the parent, never its own entry
Query: { fields: { usersConnection: relayStylePagination(["filter"]) } },
},
});keyFields takes ["id"], another single field, a composite like ["authorId", "postId"], [] for a singleton, or false to embed.
Full code: examples/core.md — codegen config, auth link, error link, client singleton
const GET_USERS: TypedDocumentNode<GetUsersQuery, GetUsersQueryVariables> = gql`
query GetUsers($limit: Int) { users(limit: $limit) { id name } }
`;
const { data, loading, error, refetch } = useQuery(GET_USERS, {
variables: { limit: DEFAULT_PAGE_SIZE },
fetchPolicy: "cache-and-network",
skip: !shouldFetch,
});
if (loading && !data) return <Skeleton />;
if (error) return <Error message={error.message} onRetry={() => refetch()} />;
if (!data?.users?.length) return <EmptyState />;Type the document, not the hook: the hooks infer data and the variables from a TypedDocumentNode, and type arguments on the query and mutation hooks are deprecated.
loading && !data shows the skeleton on first load only, so a background refetch does not blank the screen. cache-and-network renders the cached value immediately and revalidates behind it.
Full code: examples/core.md — also useLazyQuery for user-triggered fetches
Three ways to make the cache agree with a write, in ascending cost: an optimistic response that normalizes on its own, an update callback using cache.modify, or refetchQueries, which is the simplest and costs a round trip.
const [createPost] = useMutation(CREATE_POST, {
optimisticResponse: {
createPost: {
__typename: "Post",
id: `temp-${Date.now()}`,
title,
content,
},
},
update(cache, { data }) {
cache.modify({
fields: {
posts: (existing = [], { toReference }) => [
toReference(data.createPost),
...existing,
],
},
});
},
});An optimistic response carries every field the mutation returns — a partial one writes holes into the cache. It needs no rollback code: Apollo keeps optimistic writes in a separate layer and discards that layer when the mutation fails. Deleting takes cache.evict() followed by cache.gc(); the evict alone leaves dangling references behind. refetchQueries earns its round trip on a large paginated list, where a local edit cannot know where the new row sorts, and on a write that changes several queries at once.
Full code: examples/core.md
A field policy's read function invents a field that no server returns, from other cached fields or from a reactive variable.
User: {
fields: {
fullName: {
read: (_, { readField }) => `${readField("firstName")} ${readField("lastName")}`,
},
},
},
Query: { fields: { isLoggedIn: { read: () => isLoggedInVar() } } },Use readField rather than property access — a cached field may hold a Reference rather than a value.
Cursor pages go through relayStylePagination; offset pages need a merge and a read written by hand. Both live in the type policy, not in the component.
const { data, fetchMore } = useQuery(GET_USERS_CONNECTION, {
variables: { first: PAGE_SIZE },
});
const loadMore = () =>
fetchMore({ variables: { after: data.usersConnection.pageInfo.endCursor } });keyArgs is what separates one filter's pages from another's. Without it every filter merges into one entry and the list shows the wrong rows.
Full code: examples/pagination.md
A component declares the fields it needs; the parent query spreads that fragment. Changing what the child renders then changes one file rather than every query that contains it.
const USER_CARD_FRAGMENT = gql`
fragment UserCard on User {
id
name
avatar
}
`;
const GET_USERS = gql`
query GetUsers {
users {
...UserCard
}
}
${USER_CARD_FRAGMENT}
`;Full code: examples/fragments.md
Route subscriptions to a websocket link and everything else to HTTP, with split.
const splitLink = split(
({ query }) => {
const def = getMainDefinition(query);
return (
def.kind === "OperationDefinition" && def.operation === "subscription"
);
},
wsLink,
httpLink,
);Construct wsLink only where window exists, or a server render opens a socket. Write the payload into the cache from onData, or the subscription updates nothing.
Full code: examples/subscriptions.md
const cartItemsVar = makeVar<string[]>([]);
const addToCart = (id: string) => cartItemsVar([...cartItemsVar(), id]);
const cartItems = useReactiveVar(cartItemsVar);A reactive variable is readable from a field policy's read, which is what lets local state be queried alongside server data. It holds a value and notifies — anything needing derivation, middleware or history belongs to a state solution instead.
| Hook | Fetch starts on | For |
|---|---|---|
useSuspenseQuery | component mount | ordinary loading |
useLoadableQuery | user interaction | hover or click prefetch |
useBackgroundQuery | parent mount | parent triggers, child reads |
createQueryPreloader | route transition | router loaders |
None of them return loading — the component suspends, and errors throw to the error boundary. The last three hand back a queryRef that useReadQuery consumes inside a Suspense boundary.
Full code: examples/suspense.md
const { data: user, complete } = useFragment({ fragment: USER_CARD_FRAGMENT, from: userRef });
if (!complete) return <Skeleton />;No query is issued: the fields are read from the cache and re-read whenever they change. complete is false when some fragment field was never cached.
</patterns>
<red_flags>
Breaks at runtime:
__typename, or missing a field the mutation returns —normalization fails silently and the cache holds a hole.
keyArgs — every filter shares one entry, so switching filtersshows the previous filter's rows.
merge — fetchMore replaces the list instead of extending it.lands under the parent field and a mutation updating that entity leaves this query untouched.
cache.evict() without cache.gc() — the entity is gone but references to it are not.@apollo/client — they live at @apollo/client/react.uri passed to ApolloClient — construct an HttpLink and pass link.rxjs not installed — it is a required peer dependency, replacing zen-observable.data with no loading or error branch — the first render has neither.Surprising behaviour:
refetchQueries runs after the update callback, not before.cache.writeQuery replaces the whole query result, where cache.modify edits one field — reachfor writeQuery and a list becomes exactly what you just wrote.
update fires for cache changes and onCompleted for side effects; navigating from updatefires it again on every optimistic pass.
errorPolicy defaults to none, which discards partial data — "all" keeps it and reports theerrors alongside.
refetch and fetchMore need notifyOnNetworkStatusChange, which defaults tofalse in v3 and true in v4.
pollInterval: 0 disables polling; omitting the option is the same thing and reads better.queryRef is bound to the variables it was loaded with — new variables need a newloadQuery call.
createQueryPreloader runs outside the React tree, so it cannot be called from a component.</red_flags>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.