Apollo GraphQL Best Practices
Apollo Client
Client Setup
Configure ApolloClient with InMemoryCache and appropriate type policies:
import { ApolloClient, InMemoryCache, HttpLink } from "@apollo/client";
const client = new ApolloClient({
link: new HttpLink({ uri: "/graphql" }),
cache: new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Define field policies for pagination, merging, etc.
},
},
// Custom key fields for entity identification
User: {
keyFields: ["email"], // Use email instead of id
},
},
}),
});Queries with useQuery
import { useQuery, gql } from "@apollo/client";
const GET_DATA = gql`
query GetData($id: ID!) {
item(id: $id) {
id
name
}
}
`;
function Component({ id }: { id: string }) {
const { data, loading, error } = useQuery(GET_DATA, {
variables: { id },
fetchPolicy: "cache-first", // Default, use cache when available
});
if (loading) return <Loading />;
if (error) return <Error message={error.message} />;
return <Display data={data} />;
}Mutations with useMutation
Update cache after mutations using update callback:
import { useMutation, gql } from "@apollo/client";
const ADD_ITEM = gql`
mutation AddItem($input: ItemInput!) {
addItem(input: $input) {
id
name
}
}
`;
function AddItemForm() {
const [addItem, { loading }] = useMutation(ADD_ITEM, {
update(cache, { data: { addItem } }) {
cache.modify({
fields: {
items(existingItems = []) {
const newItemRef = cache.writeFragment({
data: addItem,
fragment: gql`
fragment NewItem on Item {
id
name
}
`,
});
return [...existingItems, newItemRef];
},
},
});
},
// Or use refetchQueries for simpler cases
// refetchQueries: [{ query: GET_ITEMS }],
});
return (
<form onSubmit={(e) => {
e.preventDefault();
addItem({ variables: { input: { name: "New Item" } } });
}}>
<button type="submit" disabled={loading}>Add</button>
</form>
);
}Error Handling
Use errorPolicy and CombinedGraphQLErrors for granular error control:
import { useQuery } from "@apollo/client";
import { CombinedGraphQLErrors } from "@apollo/client/errors";
function Component() {
const { data, error } = useQuery(QUERY, {
errorPolicy: "all" // Receive partial data with errors
});
if (error) {
if (CombinedGraphQLErrors.is(error)) {
// GraphQL errors (validation, resolver errors)
return <div>Error: {error.errors[0].message}</div>;
}
// Network errors
return <div>Network error: {error.message}</div>;
}
return <div>{data?.field}</div>;
}Cache Type Policies
Configure pagination and field merging:
const cache = new InMemoryCache({
typePolicies: {
Query: {
fields: {
// Offset-based pagination
items: {
keyArgs: ["filter"], // Cache separately per filter
merge(existing = [], incoming, { args }) {
const offset = args?.offset ?? 0;
const merged = existing.slice(0);
for (let i = 0; i < incoming.length; i++) {
merged[offset + i] = incoming[i];
}
return merged;
},
},
},
},
// Entities without id field
Token: {
keyFields: false, // Treat as singleton
},
},
});Fetch Policies
| Policy | Behavior |
|---|---|
cache-first | Read cache, fetch if missing (default) |
cache-only | Only read cache, never fetch |
network-only | Always fetch, update cache |
no-cache | Always fetch, don't cache |
cache-and-network | Return cache immediately, then fetch |
Apollo Server
Server Setup
import { ApolloServer } from "@apollo/server";
import { startStandaloneServer } from "@apollo/server/standalone";
interface Context {
user?: User;
db: Database;
}
const server = new ApolloServer<Context>({
typeDefs,
resolvers,
});
const { url } = await startStandaloneServer(server, {
context: async ({ req }) => ({
user: await getUserFromToken(req.headers.authorization),
db: await getDatabase(),
}),
listen: { port: 4000 },
});Schema Design Principles
- Use non-nullable by default - Add
!unless field can legitimately be null - Prefer specific types - Use
ID!for identifiers, custom scalars for dates - Design for the client - Structure schema around UI needs, not database schema
- Use input types for mutations - Group related arguments
type Query {
user(id: ID!): User
users(filter: UserFilter, pagination: Pagination): UserConnection!
}
type Mutation {
createUser(input: CreateUserInput!): CreateUserPayload!
updateUser(id: ID!, input: UpdateUserInput!): UpdateUserPayload!
}
input CreateUserInput {
email: String!
name: String!
}
type CreateUserPayload {
user: User
errors: [Error!]
}Resolvers
const resolvers = {
Query: {
user: async (_, { id }, context) => {
return context.db.users.findById(id);
},
},
Mutation: {
createUser: async (_, { input }, context) => {
if (!context.user) {
throw new GraphQLError("Not authenticated", {
extensions: { code: "UNAUTHENTICATED" },
});
}
const user = await context.db.users.create(input);
return { user, errors: [] };
},
},
// Field resolvers for computed/related data
User: {
posts: (parent, _, context) => {
return context.db.posts.findByUserId(parent.id);
},
},
};Error Handling
Throw GraphQLError with descriptive codes:
import { GraphQLError } from "graphql";
// In resolver
if (!user) {
throw new GraphQLError("User not found", {
extensions: {
code: "NOT_FOUND",
argumentName: "id",
},
});
}
// In context for auth errors
context: async ({ req }) => {
const user = await getUser(req);
if (!user) {
throw new GraphQLError("Authentication required", {
extensions: {
code: "UNAUTHENTICATED",
http: { status: 401 },
},
});
}
return { user };
};Standard Error Codes
| Code | Use Case |
|---|---|
UNAUTHENTICATED | Missing or invalid authentication |
FORBIDDEN | Authenticated but not authorized |
BAD_USER_INPUT | Invalid argument values |
NOT_FOUND | Requested resource doesn't exist |
INTERNAL_SERVER_ERROR | Unexpected server errors |
Performance Tips
- Use DataLoader - Batch and cache database calls to avoid N+1 queries
- Implement pagination - Never return unbounded lists
- Use persisted queries - Reduce request size in production
- Enable APM - Use Apollo Studio for query performance monitoring
- Lazy load fragments - Split large queries with
@deferdirective - Configure cache TTL - Set appropriate
maxAgefor cached responses