Welcome to your hands-on Apollo Client lesson! You'll be building a Pokemon favorites app while learning how to use Apollo Client's useQuery
and useMutation
hooks.
By the end of this code-along, you will be able to:
- Explain why Apollo Client is useful for GraphQL applications
- Write GraphQL queries and use them with the
useQuery
hook - Implement mutations with the
useMutation
hook - Handle loading states, errors, and cache updates
- Build a complete React app that fetches and modifies data
- Solid understanding of React hooks and TypeScript
- Basic GraphQL knowledge (queries, mutations, variables)
- Node.js 18+ installed
- Fork and clone this repository
- Install dependencies:
npm install
- Start the development server:
npm run dev
- Verify everything is working:
npm run verify
- Open http://localhost:5173 in your browser
You should see placeholder components that you'll be building step by step. Each component has the UI structure ready, but you'll add the Apollo Client functionality!
๐ Note: Starter files with placeholder content have been created for you in src/components/
. You'll add Apollo Client imports, queries, and mutations to make them work.
We'll build this app step by step:
- Part 1: Understanding Apollo Client & Setup
- Part 2: Your First Query with
useQuery
- Part 3: Creating Data with
useMutation
- Part 4: Advanced Patterns & Best Practices
- Part 5: Optional Practice - Build Your Own Feature!
Before starting this section, read the official Apollo Client documentation:
- What is Apollo Client? - Overview and core concepts
Apollo Client is a comprehensive state management library that helps you manage GraphQL data in React applications. Think of it as a smart layer between your React components and your GraphQL API that handles:
- Caching: Stores data so you don't re-fetch unnecessarily
- Loading states: Tracks when requests are in progress
- Error handling: Manages network and GraphQL errors
- Cache updates: Keeps your UI in sync when data changes
Let's compare the traditional approach vs Apollo Client:
const [pokemon, setPokemon] = useState(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState(null)
useEffect(() => {
setLoading(true)
fetch("/graphql", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
query: `query GetPokemon($name: String!) {
pokemon(name: $name) { id name types }
}`,
variables: { name: "pikachu" },
}),
})
.then((res) => res.json())
.then((data) => setPokemon(data.pokemon))
.catch((err) => setError(err))
.finally(() => setLoading(false))
}, [])
const { data, loading, error } = useQuery(GET_POKEMON, {
variables: { name: "pikachu" },
})
Much cleaner, right? Apollo handles all the state management for you!
Take a look at the app at http://localhost:5173:
You'll see two sections with placeholder content:
- Pokemon Search - Has a search input but shows "No Pokemon found - add your useQuery hook!"
- Note: The search requires exact Pokemon names (lowercase, full names like "pikachu", "charizard")
- My Pokemon Favorites - Has a form but shows "No favorites yet - add your Apollo hooks to see data!"
Your job will be to make these components work by adding Apollo Client functionality!
Before starting this section, read through the "Executing a query" section in the official documentation about queries:
- Queries with useQuery - Complete guide to fetching data
Now let's add Apollo Client functionality to create an interactive Pokemon search! Look at your app - you'll see a search input that shows "No Pokemon found - add your useQuery hook!" Let's make it work step by step.
First, let's look at the GraphQL query we'll be using. Open the GraphQL Playground and try this query:
query GetPokemon($pokemon: PokemonEnum!) {
getPokemon(pokemon: $pokemon) {
key
species
types {
name
}
sprite
}
}
Try it yourself:
- Go to https://graphqlpokemon.favware.tech/v8
- Paste the query above
- In the variables section (bottom left), add:
{"pokemon": "pikachu"}
- Click the play button
You should see Pokemon data returned! This is the same API our app uses.
Open src/components/PokemonSearch.tsx
. You'll see it has the search input and UI structure but no Apollo functionality yet.
๐ฏ Your Task: Add the Apollo Client imports at the top of the file.
Replace the first TODO comment with:
import { gql, useQuery } from "@apollo/client"
๐ง What are these?
gql
: A template literal tag that parses GraphQL query strings. It helps with syntax highlighting and validation.useQuery
: A React hook that executes GraphQL queries and manages loading, error, and data states for you.
๐พ Save and check: Your app should still show "No Pokemon found - add your useQuery hook!"
๐ฏ Your Task: Add your GraphQL query after the imports.
Replace the "TODO: Add your GraphQL query here" comment with:
const GET_POKEMON = gql`
query GetPokemon($pokemon: PokemonEnum!) {
getPokemon(pokemon: $pokemon) {
key
species
types {
name
}
baseStats {
hp
attack
defense
speed
}
sprite
}
}
`
๐ง What's happening here?
gql
parses our GraphQL query string and turns it into a format Apollo can use$pokemon: PokemonEnum!
defines a variable that we'll pass in (the!
means it's required)- We're asking for specific fields:
key
,species
,types
, andsprite
- This is like writing a "shopping list" of data we want from the API
๐พ Save and check: Still should show the same placeholder - we haven't used the query yet!
๐ฏ Your Task: Add the query types after the existing Pokemon type.
Add these types after the Pokemon type definition:
type GetPokemonData = {
getPokemon: Pokemon | null
}
type GetPokemonVars = {
pokemon: string
}
๐ง Why do we need these types?
GetPokemonData
: Describes the shape of data coming back from our query. The API might return a Pokemon ornull
if not found.GetPokemonVars
: Describes the variables we're sending to the query. We need to pass apokemon
string.- TypeScript uses these to give us autocomplete and catch errors before we run the code!
๐พ Save and check: Still showing placeholder - that's expected!
๐ฏ Your Task: Replace the "TODO: Add your useQuery hook here (with skip option)" comment with:
const { data, loading, error } = useQuery<GetPokemonData, GetPokemonVars>(
GET_POKEMON,
{
variables: { pokemon: searchTerm },
skip: !searchTerm || searchTerm.length < 3,
}
)
๐ง What's useQuery doing?
- Automatically executes our GraphQL query when the component mounts
- Manages three states for us:
loading
:true
while the request is in progresserror
: Contains error info if something goes wrongdata
: Contains the response data when successful
- Handles variables: We pass
pokemonName
as the$pokemon
variable - TypeScript generics:
<GetPokemonData, GetPokemonVars>
tells TypeScript what types to expect
loading
, error
). This is expected! We have both the placeholder variables and the real ones from useQuery. Step 6 will fix this by removing the placeholders.
๐ฏ Step 6a: Remove the placeholder variables.
Delete these three lines:
const loading = false
const error = null
const pokemon: Pokemon | null = null
๐ง Why remove these? These were fake placeholder values. Now we want to use the real loading
and error
from useQuery!
๐ฏ Step 6b: Add the real pokemon variable.
Add this line after your useQuery hook:
const pokemon = data?.getPokemon
๐ง What's data?.getPokemon
?
- The
?.
is called "optional chaining" - it safely accessesgetPokemon
even ifdata
is undefined - This prevents crashes while the query is loading (when
data
is still undefined) - Once the query completes,
data.getPokemon
will contain our Pokemon object
๐พ Save and check: ๐ Now try typing "pikachu" in the search box - you should see real Pokemon data!
๐ Search Tips: The Pokemon API requires exact Pokemon names. Try these examples:
- "pikachu" โ
- "charizard" โ
- "bulbasaur" โ
- "pika" โ (partial names won't work)
- "Pikachu" โ (case sensitive - use lowercase)
๐ง What You Just Built: You've created a complete GraphQL-powered React component! Here's what Apollo Client is doing for you:
- Automatic Network Requests: useQuery sends HTTP requests to the GraphQL API
- State Management: No need for useState to track loading/error/data
- Caching: Try switching between Pokemon names - Apollo caches results for better performance
- Type Safety: TypeScript prevents bugs by checking data types
- Error Handling: Built-in error states for network issues or invalid data
Discussion Questions:
- What happens during the loading state? (Watch for the brief "Loading..." message)
- How does Apollo handle errors vs. "not found"? (Try an invalid Pokemon name)
- Why is caching important for user experience?
- What would this component look like if you built it with just
fetch()
anduseState
?
Before starting this section, read through the "Executing a mutation" section in the official documentation about mutations:
- Mutations with useMutation - Complete guide to modifying data
Now let's learn about mutations - GraphQL operations that modify data! You'll see how Apollo Client makes it easy to create new data and keep your UI in sync.
Before we dive in, let's understand the key differences:
- Read data from the server
- Run automatically when the component mounts
- Used with
useQuery
hook
- Modify data on the server (create, update, delete)
- Run only when you call them (like clicking a button)
- Used with
useMutation
hook - Can update the cache after completion
Take a look at the app at http://localhost:5173:
You'll see a "My Pokemon Favorites" section with:
- โ A form to add favorites (but it doesn't work yet!)
- โ A list area that shows "No favorites yet - add your Apollo hooks to see data!"
Your job is to make the form actually save data and display the favorites list!
Our app uses a local JSON server for favorites. The favorites have this structure:
type Favorite = {
id: string // Unique identifier
title: string // Pokemon name
body: string // Why it's your favorite
createdAt: string // When it was added
}
๐ง Why a separate API? We're using a local JSON server to simulate a real backend. This teaches you how to work with different GraphQL endpoints - a common real-world scenario!
Open src/components/MyFavorites.tsx
. You'll see it already has:
- โ
The form with state management (
title
andbody
state) - โ The UI structure for displaying favorites
- โ
Apollo Client imports (
gql
,useQuery
,favoritesClient
) - โ
A working
useQuery
hook that fetches favorites - โ Missing the mutation functionality
๐ง What's already set up?
useQuery
: Already fetching favorites from the local APIfavoritesClient
: A separate Apollo Client instance for our local API (different from the Pokemon API)- The favorites list should already be working (showing "No favorites yet" since the database is empty)
๐พ Save and check: You should see the favorites list working, but the form doesn't save data yet.
You'll notice the component already has the GET_FAVORITES
query implemented - that's why you can see the favorites list working! Now you need to add the mutation for creating new favorites.
๐ฏ Your Task: Add the mutation. Replace the "TODO: Add your CREATE_FAVORITE mutation here" comment with:
const CREATE_FAVORITE = gql`
mutation CreateFavorite($input: CreateFavoriteInput!) {
createFavorite(input: $input) {
id
title
body
createdAt
}
}
`
๐ง What's happening here?
The Mutation:
CREATE_FAVORITE
: Creates a new favorite$input: CreateFavoriteInput!
: Takes an input object with title and body- Returns the created favorite with all its fields (including the generated
id
andcreatedAt
) - This follows the same pattern as the Pokemon query, but for creating data instead of reading it
๐พ Save and check: Still showing the same behavior - we haven't connected the mutation hook yet!
Before we can use mutations, we need to import the useMutation
hook. Notice the component currently only imports useQuery
.
๐ฏ Your Task: Update your imports to include useMutation
:
import { gql, useQuery, useMutation } from "@apollo/client"
๐ง What's useMutation?
useMutation
: A React hook for executing GraphQL mutations- Unlike
useQuery
, it doesn't run automatically - you call it when needed (like on form submit) - Returns a function to execute the mutation plus loading/error states
๐พ Save and check: Nothing should change visually yet - we just added the import!
Notice that the useQuery
hook is already implemented in the component! Let's understand what it's doing:
const {
data,
loading: queryLoading,
error: queryError,
} = useQuery<{
favorites: Favorite[]
}>(GET_FAVORITES, {
client: favoritesClient,
})
๐ง What's different from Part 2?
client: favoritesClient
: We specify which Apollo Client to use (our local API, not the Pokemon API)loading: queryLoading
: We renameloading
to avoid conflicts with mutation loading- No
refetch
for now - we'll add that later!
๐พ Save and check: You should already see "Loading favorites..." briefly, then "No favorites yet" (since there are no favorites in the database yet)!
Now for the exciting part - adding the mutation! This is where useMutation
differs significantly from useQuery
.
๐ฏ Your Task: Replace the "TODO: Add your useMutation hook for creating favorites" comment with:
const [createFavorite, { loading: mutationLoading, error: mutationError }] =
useMutation<
{ createFavorite: Favorite },
{ input: { title: string; body: string } }
>(CREATE_FAVORITE, {
client: favoritesClient,
})
๐ง What's useMutation doing?
Different Return Pattern:
useQuery
returns{ data, loading, error }
useMutation
returns[mutationFunction, { loading, error, data }]
The Mutation Function:
createFavorite
: A function you call to execute the mutation- Only runs when YOU call it (not automatically like queries)
TypeScript Generics:
- First type: What the mutation returns (
{ createFavorite: Favorite }
) - Second type: What variables it needs (
{ input: { title: string; body: string } }
)
Options:
client: favoritesClient
: Use our local API client
๐พ Save and check: Form should now show proper loading states, but still won't save data!
๐ฏ Your Task: Remove the placeholder mutation state variables.
Delete these lines:
const mutationLoading = false
const mutationError = null
๐ง What about the other variables?
favorites
is already correctly implemented asconst favorites = data?.favorites || []
queryLoading
andqueryError
come from the useQuery hook (renamed to avoid conflicts)- We only need to remove the placeholder mutation variables
๐ง Why data?.favorites || []
?
data
might beundefined
while loading?.
safely accessesfavorites
even ifdata
is undefined|| []
provides an empty array as fallback
๐พ Save and check: You should see the favorites list area, but still can't add favorites!
๐ฏ Your Task: Update the handleSubmit function. Replace the TODO comment with:
try {
await createFavorite({
variables: { input: { title, body } },
})
} catch (err) {
console.error("Failed to create favorite:", err)
}
๐ง What's happening here?
await createFavorite()
: Call the mutation function and wait for it to completevariables: { input: { title, body } }
: Pass the form data as GraphQL variables- The structure matches our mutation:
$input: CreateFavoriteInput!
try/catch
: Handle any errors that might occur
๐พ Save and check: ๐ Try adding a favorite! But... it won't appear in the list until you refresh the page. That's our next step!
The mutation works, but the UI doesn't update automatically. We need to tell Apollo to refresh the favorites list after creating a new one.
๐ฏ Your Task: Update your useMutation hook to include onCompleted
:
const [createFavorite, { loading: mutationLoading, error: mutationError }] =
useMutation<
{ createFavorite: Favorite },
{ input: { title: string; body: string } }
>(CREATE_FAVORITE, {
client: favoritesClient,
onCompleted: () => {
setTitle("") // Clear the form
setBody("")
},
})
๐ง What's onCompleted?
- A callback function that runs after the mutation succeeds
- Perfect place to update UI state or clear forms
- Runs only on success (not on errors)
๐พ Save and check: Now the form clears after adding, but the list still doesn't update automatically!
To see new favorites immediately, we need to refetch the favorites list after creating one.
๐ฏ Your Task: First, update your useQuery to include refetch
:
const {
data,
loading: queryLoading,
error: queryError,
refetch,
} = useQuery<{
favorites: Favorite[]
}>(GET_FAVORITES, {
client: favoritesClient,
})
๐ฏ Your Task: Then, update your useMutation to call refetch
:
const [createFavorite, { loading: mutationLoading, error: mutationError }] =
useMutation<
{ createFavorite: Favorite },
{ input: { title: string; body: string } }
>(CREATE_FAVORITE, {
client: favoritesClient,
onCompleted: () => {
refetch() // Refresh the favorites list
setTitle("") // Clear the form
setBody("")
},
})
๐ง What's refetch doing?
refetch()
: Re-runs the GET_FAVORITES query- Fetches fresh data from the server
- Updates the cache with the latest favorites
- Triggers a re-render with the new data
๐พ Save and check: ๐ Now try adding a favorite - it should appear immediately!
Save your file and test the complete functionality!
๐ฏ Try This:
- Add a favorite Pokemon with a reason (e.g., "Pikachu" - "So cute and electric!")
- Watch it appear in the list immediately
- Add another favorite
- Refresh the page - do they persist?
- Try submitting an empty form - what happens?
๐ง What You Just Built: You've created a complete mutation flow! Here's what Apollo Client is doing for you:
- Form Submission:
createFavorite()
sends a GraphQL mutation to the server - Loading States:
mutationLoading
shows "Adding..." while the request is in progress - Error Handling:
mutationError
catches and displays any problems - Cache Updates:
refetch()
ensures the UI shows the latest data - UX Polish:
onCompleted
clears the form for the next entry
Discussion Questions:
- Why don't mutations run automatically like queries?
- What's the difference between
queryLoading
andmutationLoading
? - Why do we need
refetch()
after the mutation? - How does the
onCompleted
callback improve user experience? - What would happen if we forgot to clear the form?
๐ Advanced Challenge:
Instead of using refetch()
(which makes an extra network request), can you think of a more efficient way to update the cache? (Hint: Look up Apollo's update
function - but that's for later!)
This section covers advanced topics. For deeper understanding, explore:
- Caching in Apollo Client - Advanced caching strategies
- Error Handling - Comprehensive error management
Apollo Client provides several ways to update the cache after mutations:
- refetchQueries (easiest, but makes extra network requests)
- update function (more efficient, updates cache directly)
- optimisticResponse (best UX, updates UI immediately)
const [createFavorite] = useMutation(CREATE_FAVORITE, {
optimisticResponse: {
createFavorite: {
__typename: "Favorite",
id: "temp-id",
title: pokemonName,
body: reason,
createdAt: new Date().toISOString(),
},
},
update(cache, { data }) {
// Update cache manually instead of refetching
const existingFavorites = cache.readQuery({ query: GET_FAVORITES })
cache.writeQuery({
query: GET_FAVORITES,
data: {
favorites: [data.createFavorite, ...existingFavorites.favorites],
},
})
},
})
const { data, loading, error } = useQuery(GET_POKEMON, {
variables: { pokemon: searchTerm },
errorPolicy: "all", // Return partial data even on errors
onError: (error) => {
// Log errors for debugging
console.error("Pokemon query error:", error)
},
})
- Use
skip
to avoid unnecessary queries - Memoize variables to prevent re-renders
- Use fragments for reusable query parts
- Implement proper loading states for better UX
You've successfully learned the basics of:
- โ Setting up and using Apollo Client
- โ
Writing GraphQL queries and using
useQuery
- โ Handling loading states and errors
- โ
Creating mutations with
useMutation
- โ Managing cache updates
- โ Building a React app with GraphQL
- Explore Apollo Client's subscriptions for real-time data
- Learn about advanced caching strategies
- Try Apollo Client's local state management
- Build more complex apps with nested queries and mutations
๐ Congratulations! You've learned the fundamentals of Apollo Client. Now it's time to get creative and build something uniquely yours!
Using everything you've learned about useQuery
and useMutation
(or just one of them!), create an original feature that combines Pokemon data with your own creative twist. This is your chance to experiment, have fun, and see what cool ideas you can bring to life!
Choose one of these ideas (or create your own!):
- Search and add Pokemon to build your dream team (limit to 6)
- Display team stats and type coverage
- Save multiple teams with custom names
- Add a rating system (1-5 stars) to your favorites
- Sort favorites by rating, name, or date added
- Add categories like "Cute", "Strong", "Legendary"
- Create a simple battle system using base stats
- Let users pick two Pokemon and simulate a battle
- Save battle history and win/loss records
Remember, there's no "right" way to do this - just pick something that sounds fun and start building! You can always start simple and add more features as you go.
Happy coding! ๐