A GraphQL server is only half the story. This lesson covers the client side: how requests actually travel over HTTP, when plain fetch is enough, and what a dedicated client like Apollo brings, using React as the example UI layer.
There is no magic transport. A GraphQL request is an HTTP POST with a JSON body containing query, optional variables, and optional operationName:
const res = await fetch("http://localhost:4000/", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `query GetPost($id: ID!) { post(id: $id) { title author { name } } }`,
variables: { id: "1" },
}),
});
const { data, errors } = await res.json();Two things to remember: the HTTP status is usually 200 even when errors is present, so always inspect the errors array; and auth is plain HTTP, typically an Authorization: Bearer header.
For scripts, server-to-server calls, or an app with two queries, fetch is genuinely fine. Wrap it once:
async function gql(query, variables) {
const res = await fetch(ENDPOINT, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query, variables }),
});
const { data, errors } = await res.json();
if (errors?.length) throw new Error(errors[0].message);
return data;
}What fetch does not give you: caching, request deduplication, loading/error state management, and automatic UI updates after mutations. That is where client libraries earn their bundle size.
npm install @apollo/client graphqlimport { ApolloClient, InMemoryCache, ApolloProvider, gql, useQuery } from "@apollo/client";
const client = new ApolloClient({ uri: "http://localhost:4000/", cache: new InMemoryCache() });
const GET_USERS = gql`
query GetUsers { users { id name posts { id title } } }
`;
function UserList() {
const { loading, error, data } = useQuery(GET_USERS);
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error.message}</p>;
return (
<ul>
{data.users.map((u) => (
<li key={u.id}>{u.name} ({u.posts.length} posts)</li>
))}
</ul>
);
}
export default function App() {
return <ApolloProvider client={client}><UserList /></ApolloProvider>;
}useQuery handles the request lifecycle and subscribes the component to the cache: if another part of the app updates a user, this list re-renders automatically.
InMemoryCache flattens responses into records keyed by __typename:id. A mutation that returns { id, name } for a user updates every query result containing that user, with no manual refetching. This is why lesson 8 insisted on selecting id in every fragment, and why mutations should return the entities they touch.
const [addPost] = useMutation(CREATE_POST);
await addPost({ variables: { title: "Hi", authorId: "1" } });Pair any of them with GraphQL Code Generator to derive TypeScript types from your operations; it eliminates an entire class of runtime bugs.
query and variables; check the errors array, not the status code.__typename:id and auto-updates every subscribed component.Next lesson: Pagination, Errors and the N+1 Problem, the topics that separate demos from production APIs.