Backend Integration: React Query, Mutations & GraphQL
PublishedURL: https://ruvebal.github.io/web-atelier-udit/lessons/en/react/react-backend-integration/
π Table of Contents
- π Table of Contents
- Code conventions in this lesson
- π― Learning Objectives
- π§ Position in the Journey
- 1 β The Mental Model Shift
- 2 β Foundation Scaffold (build once, keep forever)
- 3 β useQuery: Fetching Data
- 4 β useMutation: Writing Data
- 5 β Full CRUD with Optimistic Updates
- 6 β GraphQL with React Query
- 7 β Hygraph: Real GraphQL CMS
- 8 β Key Concepts Summary
- 9 β Sprint Deliverables
- 10 β Atelier Reflections
- π Reference: React Query v5 Cheatsheet
- π Lesson Navigation
βA frontend without a backend is a painting without a gallery β beautiful, but unseen.β
Code conventions in this lesson
All examples live in one permanent CodeSandbox. The sandbox is set up once at the start of class. As each topic is introduced, you add one new file to src/pages/ β nothing is ever replaced.
- CodeSandbox-ready β a complete, copy-paste file. Works once the scaffold below is in place.
- Excerpt β partial pattern, illustrative only. Does not run as-is.
- Template β copy and replace
[BRACKETED]values before use.
Project structure
src/
main.jsx β QueryClient + Router providers
App.jsx β NavBar + Routes (all examples wired)
services/
postsApi.js β REST helpers (fetch, create, delete)
graphqlApi.js β gqlRequest + query/mutation strings
components/
PostCard.jsx β reusable card with optional delete button
StatusMessage.jsx β Loading / ErrorMsg / Empty
pages/
Home.jsx β lesson index with links
Ex1Query.jsx β 1 Β· useQuery
Ex2Mutation.jsx β 2 Β· useMutation (POST)
Ex3Crud.jsx β 3 Β· Full CRUD + optimistic delete
Ex4GraphQLQuery.jsx β 4 Β· GraphQL query
Ex5GraphQLMutation.jsx β 5 Β· GraphQL mutation
Sandbox setup (do this once at the start of class)
Step 1 β Create sandbox
On codesandbox.io choose the React template (Vite, JavaScript β files are .jsx, no TypeScript).
Step 2 β Add dependencies via the Dependencies panel (sidebar +):
tailwindcss
@tailwindcss/vite
react-router-dom
@tanstack/react-query
@tanstack/react-query-devtools
Step 3 β Wire Tailwind into Vite
Replace vite.config.js with:
// vite.config.js
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
})
Step 4 β Enable Tailwind in CSS
Replace the contents of src/index.css with:
@import "tailwindcss";
Step 5 β Build the scaffold (Section 2 below) and verify the preview shows the home page with nav links.
This is the Tailwind v4 setup (single plugin, no
tailwind.config.jsneeded). All examples in this lesson assume this environment.
π― Learning Objectives
By the end of this lesson, you will:
- Understand server state vs UI state β the fundamental distinction
- Use React Query v5 (
useQuery) to fetch, cache, and refetch data - Handle loading, error, and empty states as first-class UI concerns
- Use
useMutationto create, update, and delete data via a mock REST API - Perform cache invalidation after mutations so the UI stays in sync
- Execute a GraphQL query against a real public API with React Query
- Connect to Hygraph (real CMS) with a GraphQL mutation
π§ Position in the Journey
| Sprint | Focus | What changes in your app |
|---|---|---|
| 7 β Architecture | Global state | Features share state |
| 8 β Routing | Navigation | Multi-page structure |
| β 9 β Backend | Data fetching | Real data, real persistence |
| 10 β Auth | Security | User identity |
1 β The Mental Model Shift
UI state vs Server state
Before writing a single line of React Query, internalize this distinction:
| Β | UI State | Server State |
|---|---|---|
| Lives | In the browser | On a remote server |
| Owner | Your React component | The backend |
| Freshness | Always current | Can be stale |
| Updates | Synchronous | Asynchronous |
| Examples | isOpen, filters, selected tab |
User profile, product list, cart |
| Tooling | useState, useReducer |
React Query |
Teaching moment: Draw this on the board. Ask students: βIs the list of items in a shopping cart UI state or server state?β Answer: it depends on whether the cart lives in the DB or only in the browser. This is a real design decision.
The problem plain fetch creates
Excerpt β This is the pattern every student writes first. Point out its flaws.
// β What students write before React Query
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch('/api/products')
.then((r) => r.json())
.then((data) => {
setProducts(data);
setLoading(false);
})
.catch((err) => {
setError(err);
setLoading(false);
});
}, []);
// ...
}
Ask the class: What happens if this component unmounts before the fetch completes? What if the user navigates away and back β will it refetch? What about caching? Background updates?
React Query solves all of this with one hook.
2 β Foundation Scaffold (build once, keep forever)
This is the permanent base of the sandbox. Create every file below before the first example. After this, you only ever add files to src/pages/.
The QueryClientProvider
The QueryClientProvider wraps your entire tree and provides an in-memory cache shared across all components. The cache lives in the browser session only β it is not persisted to localStorage or cookies by default. All queries in the lesson share this single client, so switching routes never loses cached data.
Teaching moment: Open the DevTools panel as soon as you wire this up. Show students the empty cache. By the end of the lesson it will be full.
CodeSandbox-ready β src/main.jsx (replace the template file)
// src/main.jsx
import React from 'react';
import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import App from './App';
import './index.css';
const queryClient = new QueryClient();
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
{/* DevTools panel: bottom-right corner. Open it on day 1 and leave it open. */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</React.StrictMode>
);
CodeSandbox-ready β src/App.jsx (replace the template file)
// src/App.jsx
// Navigation shell + route map.
// All 5 examples are pre-wired β add the page file and the link just works.
import { Routes, Route, NavLink } from 'react-router-dom';
import Home from './pages/Home';
import Ex1Query from './pages/Ex1Query';
import Ex2Mutation from './pages/Ex2Mutation';
import Ex3Crud from './pages/Ex3Crud';
import Ex4GraphQLQuery from './pages/Ex4GraphQLQuery';
import Ex5GraphQLMutation from './pages/Ex5GraphQLMutation';
const NAV = [
{ to: '/', label: 'π Home' },
{ to: '/ex1', label: '1 Β· useQuery' },
{ to: '/ex2', label: '2 Β· useMutation' },
{ to: '/ex3', label: '3 Β· CRUD' },
{ to: '/ex4', label: '4 Β· GraphQL Query' },
{ to: '/ex5', label: '5 Β· GraphQL Mutation' },
];
export default function App() {
return (
<div className="min-h-screen bg-slate-50 font-sans">
<nav className="bg-white border-b border-slate-200 px-4 py-3">
<ul className="flex flex-wrap gap-2">
{NAV.map(({ to, label }) => (
<li key={to}>
<NavLink
to={to}
end
className={({ isActive }) =>
`text-sm px-3 py-1.5 rounded-lg transition-colors ${
isActive
? 'bg-blue-500 text-white font-semibold'
: 'text-slate-600 hover:bg-slate-100'
}`
}>
{label}
</NavLink>
</li>
))}
</ul>
</nav>
<main className="max-w-2xl mx-auto px-4 py-10">
<Routes>
<Route path="/" element={<Home />} />
<Route path="/ex1" element={<Ex1Query />} />
<Route path="/ex2" element={<Ex2Mutation />} />
<Route path="/ex3" element={<Ex3Crud />} />
<Route path="/ex4" element={<Ex4GraphQLQuery />} />
<Route path="/ex5" element={<Ex5GraphQLMutation />} />
</Routes>
</main>
</div>
);
}
Why pre-wire all routes? It avoids editing
App.jsxmid-lesson. Students see the nav bar first and understand the lesson structure before writing any query code.
CodeSandbox-ready β src/services/postsApi.js (create new file)
// src/services/postsApi.js
// All REST calls are isolated here. Components import functions, not fetch().
// This is the separation of concerns we teach: UI β data fetching.
const BASE = 'https://jsonplaceholder.typicode.com';
export async function fetchPosts(limit = 8) {
const res = await fetch(`${BASE}/posts?_limit=${limit}`);
if (!res.ok) throw new Error(`Server error: ${res.status}`);
return res.json();
}
export async function createPost(post) {
const res = await fetch(`${BASE}/posts`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(post),
});
if (!res.ok) throw new Error(`Server error: ${res.status}`);
return res.json(); // returns { id: 101, title, body, userId }
}
export async function deletePost(id) {
const res = await fetch(`${BASE}/posts/${id}`, { method: 'DELETE' });
if (!res.ok) throw new Error(`Server error: ${res.status}`);
return id; // return the id so mutations can use it in onMutate
}
CodeSandbox-ready β src/services/graphqlApi.js (create new file)
// src/services/graphqlApi.js
// Minimal GraphQL client β no Apollo, no urql, just fetch().
// Shows students that GraphQL is just a POST request with a query string.
const ENDPOINT = 'https://graphqlzero.almansi.me/api';
export async function gqlRequest(query, variables = {}) {
const res = await fetch(ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data, errors } = await res.json();
if (errors) throw new Error(errors[0].message);
return data;
}
// ββ Queries and mutations live here β not in components ββββββββββββββββββββ
export const POSTS_QUERY = `
query GetPosts {
posts(options: { paginate: { limit: 8 } }) {
data {
id title body
user { name }
}
}
}
`;
export const CREATE_POST_MUTATION = `
mutation CreatePost($input: CreatePostInput!) {
createPost(input: $input) {
id title body
}
}
`;
CodeSandbox-ready β src/components/PostCard.jsx (create new file)
// src/components/PostCard.jsx
// Reusable post card. Pass onDelete to enable the delete button.
export default function PostCard({ post, onDelete, isDeleting }) {
return (
<li className="flex items-center gap-3 p-3 bg-white rounded-lg shadow-sm text-sm">
<span className="flex-1">
<span className="font-bold text-blue-500">#{post.id}</span> {post.title}
</span>
{onDelete && (
<button
onClick={() => onDelete(post.id)}
disabled={isDeleting}
className="text-gray-300 hover:text-red-500 transition-colors text-lg leading-none disabled:opacity-30 cursor-pointer bg-transparent border-none"
title="Delete post">
π
</button>
)}
</li>
);
}
CodeSandbox-ready β src/components/StatusMessage.jsx (create new file)
// src/components/StatusMessage.jsx
// Three named exports for loading / error / empty β keeps page components clean.
export function Loading({ text = 'Loadingβ¦' }) {
return <p className="text-gray-400 py-4">β³ {text}</p>;
}
export function ErrorMsg({ message }) {
return (
<p className="text-red-500 bg-red-50 border border-red-200 rounded-lg p-3">
β {message}
</p>
);
}
export function Empty({ text = 'No items found.' }) {
return <p className="text-gray-400">{text}</p>;
}
CodeSandbox-ready β src/pages/Home.jsx (create new file)
// src/pages/Home.jsx
// Lesson index β one card per example. Students see the full lesson arc up front.
import { Link } from 'react-router-dom';
const EXAMPLES = [
{ path: '/ex1', title: '1 Β· useQuery', desc: 'Fetch and cache a post list. staleTime, loading, error states.' },
{ path: '/ex2', title: '2 Β· useMutation (POST)', desc: 'Create a post. isPending, onSuccess, cache invalidation.' },
{ path: '/ex3', title: '3 Β· Full CRUD', desc: 'Create + optimistic delete. onMutate, rollback on error.' },
{ path: '/ex4', title: '4 Β· GraphQL Query', desc: 'Fetch posts + user names in one request. No Apollo needed.' },
{ path: '/ex5', title: '5 Β· GraphQL Mutation', desc: 'Create a post via GraphQL mutation with typed variables.' },
];
export default function Home() {
return (
<div>
<h1 className="text-2xl font-bold mb-2">React Query β Sprint 9</h1>
<p className="text-slate-500 mb-8 text-sm">Backend Integration Β· Mock APIs Β· GraphQL</p>
<ul className="space-y-3">
{EXAMPLES.map(({ path, title, desc }) => (
<li key={path}>
<Link
to={path}
className="block p-4 bg-white rounded-xl shadow-sm border border-slate-100 hover:border-blue-300 transition-colors">
<p className="font-semibold text-blue-600">{title}</p>
<p className="text-sm text-slate-500 mt-1">{desc}</p>
</Link>
</li>
))}
</ul>
</div>
);
}
Verify the scaffold: After adding these 7 files the preview should show a nav bar with 6 links and a home page with 5 cards. Pages 1β5 will error until you add their files β that is expected.
3 β useQuery: Fetching Data
The core pattern
Excerpt β Anatomy of useQuery. Explain each field.
const { data, isLoading, isError, error, refetch } = useQuery({
queryKey: ['posts'], // cache key β array of strings/values
queryFn: fetchPosts, // async function that returns data
staleTime: 1000 * 60 * 5, // 5 min: don't refetch if data is fresh
});
CodeSandbox Example 1: Fetching posts with React Query
Fetches from JSONPlaceholder β the free, public REST mock used throughout this lesson.
CodeSandbox-ready β Create src/pages/Ex1Query.jsx.
// src/pages/Ex1Query.jsx
// Demonstrates:
// - useQuery with loading / error / empty states
// - queryKey as cache identifier
// - staleTime: suppress refetch if data is still fresh
// - fetchPosts imported from services (separation of concerns)
import { useQuery } from '@tanstack/react-query';
import { fetchPosts } from '../services/postsApi';
import PostCard from '../components/PostCard';
import { Loading, ErrorMsg, Empty } from '../components/StatusMessage';
export default function Ex1Query() {
const { data: posts, isLoading, isError, error } = useQuery({
queryKey: ['posts'],
queryFn: () => fetchPosts(10),
staleTime: 1000 * 30, // 30 s β won't refetch if data is still fresh
});
if (isLoading) return <Loading text="Fetching postsβ¦" />;
if (isError) return <ErrorMsg message={error.message} />;
if (!posts?.length) return <Empty />;
return (
<div>
<h1 className="text-2xl font-bold mb-2">π° Posts (useQuery)</h1>
<p className="text-sm text-slate-400 mb-6">
staleTime: 30 s β switch tabs and come back to see background refetch in DevTools
</p>
<ul className="space-y-2">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
</ul>
</div>
);
}
Classroom exercise β do this live with students:
- Open Network DevTools β watch the request happen once.
- Switch to another tab and come back β React Query refetches in the background.
- Change
staleTimetoInfinityβ the network request stops happening.
4 β useMutation: Writing Data
The mock API for mutations
JSONPlaceholder accepts POST, PUT, and DELETE requests and returns realistic responses β the data is not actually persisted, but the response is a valid 201/200. Perfect for teaching without a real backend.
| Method | URL | Returns |
|---|---|---|
| POST | /posts |
{ id: 101, title, body, userId } β 201 Created |
| PUT | /posts/1 |
Updated resource β 200 OK |
| DELETE | /posts/1 |
{} β 200 OK |
The mutation pattern
Excerpt β Anatomy of useMutation. Explain the lifecycle.
const mutation = useMutation({
mutationFn: (newPost) => createPost(newPost), // async function
onSuccess: (data) => {
// data = what the server returned
queryClient.invalidateQueries({ queryKey: ['posts'] }); // refresh list
},
onError: (error) => {
console.error('Mutation failed:', error);
},
});
// Trigger: mutation.mutate({ title: '...', body: '...' })
// States: mutation.isPending | mutation.isError | mutation.isSuccess
Teaching moment: Draw the mutation lifecycle on the board:
mutate() called
β
isPending = true β disable button, show spinner
β
Server responds
β
onSuccess β invalidateQueries β useQuery refetches automatically
β
isPending = false, isSuccess = true
CodeSandbox Example 2: Creating a post with useMutation
CodeSandbox-ready β Create src/pages/Ex2Mutation.jsx.
// src/pages/Ex2Mutation.jsx
// Demonstrates:
// - useMutation with POST to JSONPlaceholder mock API
// - isPending: disables inputs and changes button label
// - onSuccess: invalidateQueries β useQuery list refreshes automatically
// - onError: inline error message in the form
// - Controlled form with useState
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchPosts, createPost } from '../services/postsApi';
import PostCard from '../components/PostCard';
import { Loading } from '../components/StatusMessage';
function CreatePostForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const mutation = useMutation({
mutationFn: createPost,
onSuccess: (newPost) => {
// invalidateQueries marks ['posts'] as stale β triggers a refetch
queryClient.invalidateQueries({ queryKey: ['posts'] });
setTitle('');
setBody('');
alert(`β
Created post #${newPost.id}: "${newPost.title}"`);
},
onError: (error) => {
console.error('Failed:', error.message);
},
});
const inputClass =
'w-full mb-3 px-3 py-2 rounded-lg border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:opacity-50';
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (title.trim()) mutation.mutate({ title, body, userId: 1 });
}}
className="bg-blue-50 border border-blue-100 rounded-xl p-6 mb-8">
<h2 className="text-lg font-semibold mb-4 mt-0">New Post</h2>
<input
className={inputClass}
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={mutation.isPending}
/>
<textarea
className={`${inputClass} min-h-[80px] resize-y`}
placeholder="Body"
value={body}
onChange={(e) => setBody(e.target.value)}
disabled={mutation.isPending}
/>
<button
type="submit"
disabled={mutation.isPending}
className="bg-blue-500 hover:bg-blue-600 text-white px-5 py-2 rounded-lg font-semibold text-sm disabled:opacity-50 cursor-pointer transition-colors">
{mutation.isPending ? 'β³ Postingβ¦' : 'π€ Create Post'}
</button>
{mutation.isError && <p className="mt-3 text-sm text-red-500">β {mutation.error.message}</p>}
</form>
);
}
export default function Ex2Mutation() {
const { data: posts, isLoading } = useQuery({
queryKey: ['posts'],
queryFn: () => fetchPosts(5),
});
return (
<div>
<h1 className="text-2xl font-bold mb-6">βοΈ useMutation (POST)</h1>
<CreatePostForm />
<h2 className="text-lg font-semibold mb-3">Recent Posts</h2>
{isLoading ? (
<Loading />
) : (
<ul className="space-y-2">
{posts?.map((post) => <PostCard key={post.id} post={post} />)}
</ul>
)}
</div>
);
}
5 β Full CRUD with Optimistic Updates
What is an optimistic update?
The term βoptimistic updateβ comes from the idea of being optimistic about the outcome of a server request: you optimistically assume the server will succeed and update the UI immediately, before waiting for the serverβs response. If the server later responds with an error, you then roll back the change. This approach improves perceived responsiveness, and is especially popular in interactive UIs where waiting for confirmation would create noticeable latency.
When to use it: Delete, like/unlike, quick toggles. Low-risk, where latency hurts UX.
When not to use it: Payment flows, form submissions where the server assigns important IDs, anything where showing fake data is misleading.
π βOptimistic update is a bet. You are betting the server will agree with you. When is that bet unethical?β β Atelier reflection
CodeSandbox Example 3: Full CRUD with optimistic delete
CodeSandbox-ready β Create src/pages/Ex3Crud.jsx.
// src/pages/Ex3Crud.jsx
// Demonstrates:
// - useMutation CREATE: POST β cache invalidation
// - useMutation DELETE: optimistic update β rollback on error
// - cancelQueries before optimistic update (prevents race conditions)
// - useQueryClient.setQueryData for direct cache manipulation
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { fetchPosts, createPost, deletePost } from '../services/postsApi';
import PostCard from '../components/PostCard';
import { Loading, ErrorMsg } from '../components/StatusMessage';
function CreatePostForm() {
const queryClient = useQueryClient();
const [title, setTitle] = useState('');
const createMutation = useMutation({
mutationFn: createPost,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['posts'] });
setTitle('');
},
});
return (
<form
onSubmit={(e) => {
e.preventDefault();
if (title.trim()) createMutation.mutate({ title, body: 'Created from form', userId: 1 });
}}
className="flex gap-2 mb-6">
<input
className="flex-1 px-3 py-2 rounded-lg border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-blue-300 disabled:opacity-50"
placeholder="New post titleβ¦"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={createMutation.isPending}
/>
<button
type="submit"
disabled={createMutation.isPending}
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg font-semibold text-sm disabled:opacity-50 cursor-pointer whitespace-nowrap transition-colors">
{createMutation.isPending ? 'β¦' : '+ Add'}
</button>
</form>
);
}
function PostListWithDelete() {
const queryClient = useQueryClient();
const { data: posts, isLoading, isError } = useQuery({
queryKey: ['posts'],
queryFn: () => fetchPosts(8),
});
const deleteMutation = useMutation({
mutationFn: deletePost,
// Step 1 β Optimistically remove before the server responds
onMutate: async (deletedId) => {
await queryClient.cancelQueries({ queryKey: ['posts'] }); // prevent race
const previousPosts = queryClient.getQueryData(['posts']); // snapshot
queryClient.setQueryData(['posts'], (old) => old?.filter((p) => p.id !== deletedId));
return { previousPosts }; // passed to onError as context
},
// Step 2 β Server failed: restore snapshot
onError: (_err, _id, context) => {
if (context?.previousPosts) queryClient.setQueryData(['posts'], context.previousPosts);
alert('β Delete failed β changes reverted');
},
// Step 3 β Always sync with server truth once settled
onSettled: () => queryClient.invalidateQueries({ queryKey: ['posts'] }),
});
if (isLoading) return <Loading />;
if (isError) return <ErrorMsg message="Failed to load posts" />;
return (
<ul className="space-y-2">
{posts?.map((post) => (
<PostCard
key={post.id}
post={post}
onDelete={(id) => deleteMutation.mutate(id)}
isDeleting={deleteMutation.isPending}
/>
))}
</ul>
);
}
export default function Ex3Crud() {
return (
<div>
<h1 className="text-2xl font-bold mb-6">π Full CRUD + Optimistic Delete</h1>
<CreatePostForm />
<PostListWithDelete />
</div>
);
}
Walk through the optimistic update flow live in class:
- Click π on any post β it disappears instantly (optimistic).
- Open Network tab β the DELETE request fires in parallel.
- JSONPlaceholder returns 200 OK β
onSettledtriggers a refetch β item reappears (because JSONPlaceholder doesnβt actually delete it). - βIn a real app, it would be gone on the server too. Here we see the refetch because the mock doesnβt persist.β
6 β GraphQL with React Query
Why GraphQL?
REST has one endpoint per resource. GraphQL has one endpoint for everything β you describe exactly what you want.
| Β | REST | GraphQL |
|---|---|---|
| Endpoint | /posts, /posts/1, /users |
/graphql |
| Response shape | Fixed by server | Defined by client query |
| Over-fetching | Common | Eliminated |
| Under-fetching | Common (N+1) | Solved with nested queries |
| Used by | Laravel, most APIs | Hygraph, GitHub, Shopify⦠|
GraphQL request shape
Excerpt β How a GraphQL POST request looks. Always POST to a single endpoint.
fetch('https://your-endpoint.com/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query: `
query GetPosts {
posts { id title body }
}
`,
variables: {}, // optional β for parameterized queries
}),
});
The classroom mock: graphqlzero.almansi.me
For classroom use we use GraphQL Zero β a free, public GraphQL API mirroring JSONPlaceholder. No account, no key, no setup needed.
Endpoint: https://graphqlzero.almansi.me/api
CodeSandbox Example 4: GraphQL query with React Query
CodeSandbox-ready β Create src/pages/Ex4GraphQLQuery.jsx.
// src/pages/Ex4GraphQLQuery.jsx
// Demonstrates:
// - GraphQL requests with plain fetch (no Apollo, no urql needed)
// - useQuery with a GraphQL queryFn
// - Nested query: posts + user names in ONE request (no N+1)
// - queryKey namespaced by source ['gql', 'posts']
// - POSTS_QUERY imported from services (query strings don't belong in components)
import { useQuery } from '@tanstack/react-query';
import { gqlRequest, POSTS_QUERY } from '../services/graphqlApi';
import { Loading, ErrorMsg, Empty } from '../components/StatusMessage';
export default function Ex4GraphQLQuery() {
const { data, isLoading, isError, error } = useQuery({
queryKey: ['gql', 'posts'], // 'gql' namespace keeps REST/GQL keys separate
queryFn: () => gqlRequest(POSTS_QUERY),
});
if (isLoading) return <Loading text="Fetching via GraphQLβ¦" />;
if (isError) return <ErrorMsg message={error.message} />;
const posts = data?.posts?.data ?? [];
if (!posts.length) return <Empty />;
return (
<div>
<h1 className="text-2xl font-bold mb-2">π· GraphQL Query</h1>
<p className="text-sm text-slate-400 mb-6">
Source: <code className="bg-slate-100 px-1 rounded">graphqlzero.almansi.me</code> β public mirror of
JSONPlaceholder
</p>
<ul className="space-y-3">
{posts.map((post) => (
<li key={post.id} className="p-4 bg-white rounded-xl shadow-sm border-l-4 border-indigo-400">
<p className="font-semibold text-indigo-600 mb-1">{post.title}</p>
<p className="text-xs text-slate-400 mb-2">
by {post.user?.name ?? 'β'} Β· #{post.id}
</p>
<p className="text-sm text-slate-600">{post.body.slice(0, 90)}β¦</p>
</li>
))}
</ul>
</div>
);
}
Classroom exercise β open the GraphQL Zero playground:
- Run the query manually β students see the raw JSON shape.
- Add a field that doesnβt exist β observe the error.
- Remove
user { name }β show you only get what you ask for. - βWhat would the REST equivalent of this nested query look like?β Answer: two requests β GET
/poststhen GET/users/:idfor each.
CodeSandbox Example 5: GraphQL mutation (create a post)
CodeSandbox-ready β Create src/pages/Ex5GraphQLMutation.jsx.
// src/pages/Ex5GraphQLMutation.jsx
// Demonstrates:
// - GraphQL mutation with typed variables ($input: CreatePostInput!)
// - mutationFn receives the object passed to mutation.mutate()
// - CREATE_POST_MUTATION imported from services (not hardcoded here)
// - Success state renders the server response as formatted JSON
import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { gqlRequest, CREATE_POST_MUTATION } from '../services/graphqlApi';
export default function Ex5GraphQLMutation() {
const [title, setTitle] = useState('');
const [body, setBody] = useState('');
const [created, setCreated] = useState(null);
const mutation = useMutation({
mutationFn: ({ title, body }) =>
gqlRequest(CREATE_POST_MUTATION, { input: { title, body } }),
onSuccess: (data) => {
setCreated(data.createPost);
setTitle('');
setBody('');
},
});
const inputClass =
'w-full mb-3 px-3 py-2 rounded-lg border border-violet-200 text-sm focus:outline-none focus:ring-2 focus:ring-violet-300 disabled:opacity-50';
return (
<div>
<h1 className="text-2xl font-bold mb-6">π· GraphQL Mutation</h1>
<form
onSubmit={(e) => {
e.preventDefault();
if (title.trim()) mutation.mutate({ title, body });
}}
className="bg-violet-50 border border-violet-100 rounded-xl p-6 mb-6">
<h2 className="text-lg font-semibold mb-4 mt-0 text-violet-800">Create Post via GraphQL</h2>
<input
className={inputClass}
placeholder="Post title"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={mutation.isPending}
/>
<textarea
className={`${inputClass} min-h-[80px] resize-y`}
placeholder="Post body"
value={body}
onChange={(e) => setBody(e.target.value)}
disabled={mutation.isPending}
/>
<button
type="submit"
disabled={mutation.isPending}
className="bg-violet-600 hover:bg-violet-700 text-white px-5 py-2 rounded-lg font-semibold text-sm disabled:opacity-50 cursor-pointer transition-colors">
{mutation.isPending ? 'β³ Creatingβ¦' : 'π Create via GraphQL'}
</button>
{mutation.isError && <p className="mt-3 text-sm text-red-500">β {mutation.error.message}</p>}
</form>
{created && (
<div className="bg-green-50 border border-green-200 rounded-xl p-4">
<p className="font-semibold text-green-700 mb-2">β
Post created!</p>
<pre className="text-xs bg-white rounded-lg p-3 overflow-x-auto border border-green-100">
{JSON.stringify(created, null, 2)}
</pre>
</div>
)}
</div>
);
}
7 β Hygraph: Real GraphQL CMS
This section is for students ready to connect to a real backend. Requires a free Hygraph account.
What Hygraph gives you
Hygraph (formerly GraphCMS) is a headless CMS β you model your content, and it auto-generates a GraphQL API. No backend code needed.
Free tier: 3 projects, unlimited reads, rate-limited writes. Perfect for student projects.
Setup: 5 minutes
- Create a free account at hygraph.com
- Create a project β choose any starter template (e.g. Blog)
- Go to Settings β API Access
- Copy your Content API endpoint (public reads)
- For mutations: create a Permanent Auth Token with
MUTATIONpermissions
Hygraph integration template
Template β Replace [YOUR_ENDPOINT] and [YOUR_TOKEN] before use.
// src/lib/hygraph.js β Template
// Replace [YOUR_ENDPOINT] and [YOUR_TOKEN] with values from
// Hygraph β Settings β API Access
const HYGRAPH_ENDPOINT = '[YOUR_ENDPOINT]';
// e.g. 'https://api-eu-central-1.hygraph.com/v2/clxxxxx/master'
const HYGRAPH_TOKEN = '[YOUR_TOKEN]';
// From Hygraph β Settings β API Access β Permanent Auth Tokens
export async function hygraphQuery(query, variables = {}) {
const res = await fetch(HYGRAPH_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${HYGRAPH_TOKEN}`,
},
body: JSON.stringify({ query, variables }),
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const { data, errors } = await res.json();
if (errors) throw new Error(errors[0].message);
return data;
}
Template β Hygraph query + mutation for a Blog post model. Adjust field names to your schema.
// src/hooks/usePosts.js β Template
// Requires: @tanstack/react-query@5
// Assumes your Hygraph schema has a "Post" model with title and content fields.
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { hygraphQuery } from '../lib/hygraph';
const GET_POSTS = `
query GetPosts {
posts(first: 10, orderBy: createdAt_DESC) {
id
title
content
createdAt
}
}
`;
const CREATE_POST = `
mutation CreatePost($title: String!, $content: String!) {
createPost(data: { title: $title, content: $content }) {
id
title
}
}
`;
export function usePosts() {
return useQuery({
queryKey: ['hygraph', 'posts'],
queryFn: () => hygraphQuery(GET_POSTS).then((d) => d.posts),
});
}
export function useCreatePost() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ title, content }) => hygraphQuery(CREATE_POST, { title, content }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['hygraph', 'posts'] });
},
});
}
Teaching note: Walk students through the Hygraph schema builder. Model a Post with title and content fields. Then show how the API is auto-generated β the schema is the API contract.
8 β Key Concepts Summary
Query key design
The query key is the cache identifier. Think of it as an address.
Excerpt β Query key patterns. Shows naming strategy, not runnable as-is.
// Single entity type
useQuery({ queryKey: ['posts'] });
// Parameterized β different keys = different cache entries
useQuery({ queryKey: ['posts', postId] });
useQuery({ queryKey: ['posts', { userId: 1, page: 2 }] });
// Namespaced by source
useQuery({ queryKey: ['rest', 'posts'] });
useQuery({ queryKey: ['gql', 'posts'] });
useQuery({ queryKey: ['hygraph', 'posts'] });
// Invalidate all posts regardless of params:
queryClient.invalidateQueries({ queryKey: ['posts'] }); // matches ['posts', ...]
Cache times
Excerpt β React Query v5 cache configuration at the client level.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // Data "fresh" for 5 minutes
gcTime: 1000 * 60 * 10, // Keep unused data in memory 10 min (v5: gcTime, not cacheTime)
retry: 2, // Retry failed queries twice
refetchOnWindowFocus: true, // Refetch when user returns to tab
},
},
});
Error normalization at the boundary
Excerpt β Normalize errors in the API layer, not in components.
// src/lib/apiClient.js
export async function apiGet(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) {
// Normalize all HTTP errors into JavaScript Error objects.
// Components receive Error instances, not raw Response objects.
throw new Error(`${res.status}: ${res.statusText}`);
}
return res.json();
}
export async function apiPost(url, body) {
return apiGet(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
}
9 β Sprint Deliverables
| Β | Deliverable | Pattern |
|---|---|---|
| β | API client module | apiGet, apiPost in src/lib/ |
| β | 3+ useQuery hooks |
One per data type, with cache keys |
| β | Loading/error/empty UI | Every data view handles all three |
| β | 1 mutation | Create, update, or delete with useMutation |
| β | Cache invalidation | invalidateQueries after mutation success |
| π Bonus | Optimistic update | For delete or toggle operations |
| π Bonus | GraphQL query | Against GraphQL Zero or Hygraph |
10 β Atelier Reflections
π βWhere in your project did you mix UI state with server state? What bug appeared?β
π βOptimistic update is a lie. When is that lie acceptable? When is it unethical?β
π βWhy might GraphQL be a worse choice than REST for your specific project? Name one case.β
π βReact Query caches data between page navigations. Does this change how users perceive your appβs speed? Does it create any risks?β
π Reference: React Query v5 Cheatsheet
Excerpt β Quick reference card for the APIs used in this lesson.
// βββ INSTALLATION ββββββββββββββββββββββββββββββββββββββββ
// npm install @tanstack/react-query@5
// Optional: npm install @tanstack/react-query-devtools
// βββ SETUP βββββββββββββββββββββββββββββββββββββββββββββββ
const queryClient = new QueryClient();
// Wrap app: <QueryClientProvider client={queryClient}>
// βββ useQuery ββββββββββββββββββββββββββββββββββββββββββββ
const {
data, // the response data
isLoading, // first load, no cached data
isPending, // waiting (v5 preferred over isLoading)
isFetching, // any fetch in progress (including background)
isError, // request failed
error, // Error object
refetch, // manual trigger
} = useQuery({
queryKey: ['key', param], // cache address
queryFn: () => fetchData(), // async β data
staleTime: 5 * 60 * 1000, // ms before data is "stale"
enabled: !!someCondition, // conditional fetching
});
// βββ useMutation βββββββββββββββββββββββββββββββββββββββββ
const mutation = useMutation({
mutationFn: (variables) => postData(variables),
onSuccess: (data, variables, context) => {
/* ... */
},
onError: (error, variables, context) => {
/* ... */
},
onSettled: (data, error) => {
/* always runs */
},
});
mutation.mutate(variables); // fire and forget
mutation.mutateAsync(variables); // fire + returns Promise
// mutation.isPending | .isError | .isSuccess | .error
// βββ CACHE OPERATIONS ββββββββββββββββββββββββββββββββββββ
const queryClient = useQueryClient();
queryClient.invalidateQueries({ queryKey: ['posts'] }); // refetch
queryClient.setQueryData(['posts'], updaterFn); // optimistic
queryClient.cancelQueries({ queryKey: ['posts'] }); // cancel in-flight
queryClient.getQueryData(['posts']); // read sync
// βββ OPTIMISTIC UPDATE PATTERN βββββββββββββββββββββββββββ
const mutation = useMutation({
mutationFn: deleteItem,
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['items'] });
const prev = queryClient.getQueryData(['items']);
queryClient.setQueryData(['items'], (old) => old.filter((i) => i.id !== id));
return { prev }; // context for rollback
},
onError: (_err, _id, context) => {
queryClient.setQueryData(['items'], context.prev); // rollback
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ['items'] }),
});
π Lesson Navigation
| Previous | Current | Next |
|---|---|---|
| Routing | Backend Integration | Authentication |
βReal data is messy. Cache is memory. Your job is to make both feel clean.β