Introduction
If you've worked with Apollo GraphQL long enough, you've likely stopped fighting the cache, developed strong opinions on
fetchPolicy, and perhaps stumbled onto a powerful but under-discussed feature: client-side custom directives.Before we dive in, let's remember why we chose GraphQL over REST. Consider an e-commerce product page:
This query encapsulates everything great about GraphQL:
- One round trip: We fetch the product, variants, reviews, and cart simultaneously.
- Exact data requirements: We get exactly what we ask for—no over-fetching.
- Strict contracts: The schema dictates the rules; if a field changes, our CI fails before production does.
- Normalized caching: Apollo caches
Product:123. A mutation updates the cache, and every component reacts instantly without manual state management.
But what happens when our data requirements become conditional?
The Problem: Imperative Sprawl
Problem: Imagine our e-commerce platform has a merchant dashboard with Free and Pro plans. Sales analytics is Pro. If the merchant isn't entitled, the field shouldn't be requested, and the UI component shouldn't need to know why.
Without custom directives, conditional data fetching often devolves into imperative sprawl:
This approach forces us to maintain multiple query documents and cache entries. The condition lives far from the field it controls. Multiply this by five plan-gated features, and you have an unmanageable number of query variants.
With a directive, the condition is colocated with the field it governs:
This is better - one document and one cache entry. However, every caller still has to manually thread
isPro, leaving room for human error. The Loophole: Client-Side Directives
Directives can be executed by the server (e.g.,
@skip, @include, @defer) or by the client (e.g., @client, @connection).Client-side directives never reach the network. Apollo strips them from the outgoing document. This is our loophole: if Apollo can invent and handle its own directives in the browser, so can we.
Built-in Apollo Directives You Should Know
Before building our own, it's worth knowing what Apollo already provides out of the box:
@client: Marks a field as local-only, resolving it from the cache or a reactive variable. Apollo removes it before the network request.
@export(as:): Injects a local@clientfield's value as a variable for the rest of the operation.
@connection(key:, filter:): Gives a paginated field a stable cache key, ignoring volatile arguments like cursors (great for infinite scroll).
@nonreactive: Marks a subtree so that changes to it won't trigger a re-render in the parent component.
@defer/@stream: Enables incremental delivery for slow fields overmultipart/mixed.
@unmask: Opts a fragment spread out of data masking, useful when migrating codebases.
Building a Custom @feature Directive
Let's build
@feature. It answers a common frontend question: Is this user allowed to access this feature? 1. Where do we hook in?
Apollo Client provides
DocumentTransform, the single API surface to rewrite queries before execution.2. What will the server tolerate?
Apollo Client does not validate directives, and its list of directives to strip before the network is hardcoded (
@client, @connection, etc.). If we don't remove
@feature, the server will throw an Unknown directive error. Rule #1: Your transform must remove the custom directive on every code path.3. What do we replace it with?
We replace it with something the server already understands:
@include(if: false). By using
@include(if: false), the replacement is a constant. We don't need to compute arguments or change the server schema.4. Where does the state come from?
The transform runs synchronously outside React, so we can't use Context. Instead, we use an Apollo reactive variable (
makeVar).The Implementation
Here is the complete implementation of our
@feature directive:Wiring it into your app is a one-liner:
Now, your UI components can query data without worrying about entitlements:
No flag hooks, no conditional query selection, no prop drilling. The component just renders what comes back.
What Else Can You Build?
Once you view custom directives as an "AST rewrite + predicate", the possibilities expand:
Rewriting the Document (DocumentTransform)
- A/B Experiments:
@experiment(name: "checkout", variant: "B")to fetch different branches.
- Platform Targeting:
@platform(only: ["web"])to skip desktop-only fields.
- Circuit Breakers:
@nonEssentialto strip heavy fields whennavigator.connection.effectiveTypeis2g.
- Compatibility Shims:
@renamedFrom(field: "oldName")to handle schema migrations without touching hundreds of files.
Inspecting Operations (Custom ApolloLink)
- Telemetry:
@track(feature: "sales")to log which gated fields are actually requested.
- Timeouts/Retries:
@retry(times: 3)or@timeout(ms: 500).
- PII Handling:
@sensitiveto redact fields before sending them to error reporters.
Conclusion
GraphQL's true power isn't just "ask for what you need"—it's that the query document is an executable program you can rewrite.
With about 40 lines of AST manipulation, we replaced a sprawl of conditional queries and flag hooks, moving the "should I fetch this?" decision directly to the field it governs. No schema changes, no backend tickets.
Start small. Pick one feature flag that currently forks a query into two documents, and collapse it into a directive. Once you do, you'll start seeing opportunities for custom directives everywhere.