This episode teaches fetching data with fetch, axios, and async/await, plus managing loading state, error handling, and data normalization. You'll also see the stable Suspense pattern and caching data with React Query or SWR.

A useful React app has to fetch data from a server. Episode 8 covers all the patterns you need: fetching data with fetch and axios, writing async flows with async/await, and managing the three states that always exist — loading, error, and success.
In the second half we look at more advanced patterns: Suspense to declare loading, and React Query / SWR for caching, retries, and background refetch. This is where your app truly starts communicating with the outside world efficiently.
The most basic pattern uses fetch inside useEffect:
import { useEffect, useState } from "react"
function DaftarUser() {
const [users, setUsers] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
useEffect(() => {
fetch("https://jsonplaceholder.typicode.com/users")
.then((res) => res.json())
.then((data) => { setUsers(data); setLoading(false) })
.catch((err) => { setError(err.message); setLoading(false) })
}, [])
if (loading) return <p>Memuat data...</p>
if (error) return <p>Gagal: {error}</p>
return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}fetch("https://jsonplaceholder.typicode.com/users").then((res) => res.json()) sends the request and converts the response to JSON. The three states (users, loading, error) keep the UI informed at every stage.
The pattern above can be written more cleanly with async/await:
useEffect(() => {
const ambilData = async () => {
try {
const res = await fetch("https://jsonplaceholder.typicode.com/users")
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setUsers(data)
} catch (e) {
setError(e.message)
} finally {
setLoading(false)
}
}
ambilData()
}, [])const res = await fetch(url) waits for the response, then if (!res.ok) throw new Error(...) throws an error for non-2xx HTTP statuses, which fetch doesn't fail on automatically. Don't forget to check res.ok — it's often overlooked.
Axios is a popular alternative with interceptors and more convenient error handling:
npm install axiosimport axios from "axios"
const res = await axios.get("https://jsonplaceholder.typicode.com/users")
setUsers(res.data)axios.get(url) throws an error directly for 4xx/5xx statuses, and the data lives in res.data. For projects with many endpoints, axios is often the choice thanks to its easy default configuration.
Design your UI for three conditions explicitly: loading (spinner or skeleton), error (message + retry button), and success (data). The simple pattern:
if (loading) return <Spinner />
if (error) return <PesanError pesan={error} onRetry={ambilData} />
return <Daftar data={data} />Data from APIs often doesn't match the shape the UI uses. Normalization converts it into a convenient form: for example, turning an array from the server into a Map of objects keyed by ID, making lookup and updates easier. Normalize in one place (a custom hook or data layer) so components don't need to know the API's raw format.
Suspense is React's mechanism for deferring rendering until data is available. In modern React, you can wrap components that depend on data with a Suspense boundary:
import { Suspense } from "react"
function App() {
return (
<Suspense fallback={<p>Memuat profil...</p>}>
<ProfilUser userId={1} />
</Suspense>
)
}<Suspense fallback={<p>Memuat profil...</p>}>{:javascript}</Suspense> shows a fallback while the components inside aren't ready yet. Suspense makes loading code declarative instead of scattered across each component.
Suspense for code splitting has been stable for a long time (episode 9). For data fetching, use the stable patterns available today — React Query or SWR — because full suspending data fetching is only now maturing in the server components ecosystem (episode 23).
Writing manual fetching means rewriting retry, cache, and refetch for every feature. React Query simplifies all of it:
npm install @tanstack/react-queryimport { useQuery } from "@tanstack/react-query"
function DaftarUser() {
const { data, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/users")
if (!res.ok) throw new Error("Gagal memuat")
return res.json()
},
})
if (isLoading) return <p>Memuat...</p>
if (error) return <p>Error: {error.message}</p>
return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
}useQuery({ queryKey, queryFn }) gives you automatic caching, built-in retries, and background refetch when the window regains focus. queryKey defines the identity of the data — change it when parameters change.
SWR from Vercel uses the strategy that shares its name: show stale cached data while fetching fresh data in the background. useSWR(key, fetcher) provides data, error, and isLoading without boilerplate. Episode 14 will dig deeper into this networking strategy.
Episode 8 connected React to the outside world: fetching with fetch, axios, and async/await, managing the three UI states, data normalization, the Suspense pattern, and automatic caching and refetch with React Query or SWR.
Key takeaways:
fetch doesn't fail on 4xx/5xx statuses: always check res.ok.In the next episode, episode 9, we'll cover routing & navigation — React Router with BrowserRouter, Routes, and Route, nested routes, dynamic params, and route guards, then Link, NavLink, redirect, and route-based code splitting with lazy loading. Your app will have many pages.