This episode teaches consuming REST and GraphQL APIs, fetch policies with retries and retry delays, centralized error handling complete with fallback UI, plus preloading data and optimistic updates for a better user experience.

Poor API integration is felt in the user's hands: a spinner spinning forever, requests failing without explanation, or lists out of sync after an action. Episode 13 makes your integration robust.
We start with consuming REST and GraphQL, then discuss fetch policies with retries and retry delays, centralized error handling with good fallback UI, and finish with two advanced UX patterns: preloading data and optimistic updates.
Separate the API logic from components by creating your own module. This makes reuse and testing easier:
// src/api/users.js
export async function getUsers() {
const res = await fetch("https://api.example.com/users")
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
}
// komponen memanggil
import { getUsers } from "../api/users.js"
const users = await getUsers()export async function getUsers() isolates the endpoint details in one place. Components only know that this function returns an array of users or throws an error.
For GraphQL, queries are built with the query language and sent via HTTP POST. Use a library like urql or @apollo/client:
import { useQuery, gql } from "urql"
const USERS_QUERY = gql`
query {
users {
id
name
}
}
`
function DaftarUser() {
const [result] = useQuery({ query: USERS_QUERY })
if (result.fetching) return <p>Memuat...</p>
if (result.error) return <p>Error: {result.error.message}</p>
return <ul>{result.data.users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}useQuery({ query: USERS_QUERY }) runs the GraphQL query and provides fetching, error, and data. GraphQL's advantage: the client only fetches the fields it actually needs.
Networks can't be relied on. Retries with exponential backoff turn temporarily failing requests into automatic successes:
import { useQuery } from "@tanstack/react-query"
function DaftarUser() {
const { data, error, refetch } = useQuery({
queryKey: ["users"],
queryFn: getUsers,
retry: 3,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000),
})
if (error) return (
<div>
<p>Gagal memuat data</p>
<button onClick={() => refetch()}>Coba lagi</button>
</div>
)
return <ul>{data?.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}retry: 3 retries up to three times, and retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30000) uses exponential backoff capped at 30 seconds — preventing the server from being overloaded when the network is down.
Handle two types of errors differently:
function Fallback({ pesan, onRetry }) {
return (
<div role="alert">
<p>Maaf, terjadi kesalahan: {pesan}</p>
<button onClick={onRetry}>Muat ulang</button>
</div>
)
}<div role="alert">{:javascript}</div> announces the error to screen readers. A good fallback tells users what went wrong and gives them a way out — not just a blank screen.
Centralized error handling means all requests pass through a single point that handles failures consistently — for example, an axios interceptor that catches status 401 to trigger automatic logout, or a useApi hook that wraps useQuery with uniform error configuration. One policy change is enough in a single place.
Preloading fetches data early, for example when the user hovers over a button, so the target page feels instant:
import { useQueryClient } from "@tanstack/react-query"
function Menu() {
const queryClient = useQueryClient()
const prefetchUsers = () => {
queryClient.prefetchQuery({ queryKey: ["users"], queryFn: getUsers })
}
return <Link to="/users" onMouseEnter={prefetchUsers}>Pengguna</Link>
}queryClient.prefetchQuery({ queryKey: ["users"], queryFn: getUsers }) fetches data in the background when the cursor enters the link. When the page opens, the data is already in the cache — no spinner.
For fast actions like likes or deletes, an optimistic update changes the UI first, then updates the server in the background:
const { mutate } = useMutation({
mutationFn: hapusUser,
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ["users"] })
queryClient.setQueryData(["users"], (old) =>
old.filter((u) => u.id !== id)
)
},
})onMutate immediately updates the cache before the server response arrives. If the server fails, use onError to restore the cache to its previous state — and show a brief message that synchronization failed.
npm install urql graphqlThe npm install urql graphql command installs a GraphQL client. Try replacing one of your REST fetches with a GraphQL query to feel the difference.
Episode 13 made your API integration robust: an API layer for REST and GraphQL, retries with exponential backoff, centralized error handling with fallback UI, then preloading and optimistic updates for smooth UX.
Key takeaways:
In the next episode, episode 14, we'll cover networking performance — HTTP caching and stale-while-revalidate, code splitting and bundle analysis, prefetching data and assets, and monitoring network performance. Your app won't just work — it'll be fast.