Stop putting fetch in components
Here’s what most beginners do — scatter fetch() calls throughout their components:
// ❌ API logic mixed into the component
function UserList () {
const [ users , setUsers ] = useState ([]);
useEffect (() => {
fetch ( ` ${ import . meta . env . VITE_API_BASE_URL } /api/users` )
. then ( r => {
if ( ! r . ok ) throw new Error ( `HTTP ${ r . status } ` );
return r . json ();
})
. then ( data => setUsers ( data ))
. catch ( err => console . error ( err ));
}, []);
// ... render
}
This works, but it creates problems: the same URL and error-handling logic gets duplicated in every component that needs users. If the API changes, you fix it in 10 places instead of 1.
The companion repo already uses an API client file (frontend/src/api/users.ts) with this pattern. In this lesson, we stay in JavaScript and then add an optional shared client.js helper as a teaching step to reduce repetition further.
The API client pattern
Create a dedicated file for each resource’s API calls:
// frontend/src/api/users.js
const API_URL = import . meta . env . VITE_API_BASE_URL ;
export async function getUsers () {
const response = await fetch ( ` ${ API_URL } /api/users` );
if ( ! response . ok ) throw new Error ( `HTTP ${ response . status } ` );
return response . json ();
}
export async function getUser ( id ) {
const response = await fetch ( ` ${ API_URL } /api/users/ ${ id } ` );
if ( ! response . ok ) throw new Error ( `HTTP ${ response . status } ` );
return response . json ();
}
export async function createUser ( userData ) {
const response = await fetch ( ` ${ API_URL } /api/users` , {
method: "POST" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ( userData ),
});
if ( ! response . ok ) throw new Error ( `HTTP ${ response . status } ` );
return response . json ();
}
export async function updateUser ( id , userData ) {
const response = await fetch ( ` ${ API_URL } /api/users/ ${ id } ` , {
method: "PUT" ,
headers: { "Content-Type" : "application/json" },
body: JSON . stringify ( userData ),
});
if ( ! response . ok ) throw new Error ( `HTTP ${ response . status } ` );
return response . json ();
}
export async function deleteUser ( id ) {
const response = await fetch ( ` ${ API_URL } /api/users/ ${ id } ` , {
method: "DELETE" ,
});
if ( ! response . ok ) throw new Error ( `HTTP ${ response . status } ` );
}
Now your components are clean:
// frontend/src/pages/UserList.jsx
import { useState , useEffect } from 'react' ;
import { getUsers } from '../api/users' ;
function UserList () {
const [ users , setUsers ] = useState ([]);
const [ loading , setLoading ] = useState ( true );
const [ error , setError ] = useState ( null );
useEffect (() => {
getUsers ()
. then ( data => setUsers ( data ))
. catch ( err => setError ( err . message ))
. finally (() => setLoading ( false ));
}, []);
// ... render
}
The component doesn’t know about URLs, headers, or HTTP methods. It just calls getUsers() and gets data back.
File structure
frontend/src/
├── api/
│ ├── users.js # User CRUD operations
│ ├── products.js # Product CRUD operations
│ └── auth.js # Login, logout, register
├── components/
│ ├── UserCard.jsx
│ └── UserForm.jsx
├── pages/
│ └── Dashboard.jsx
└── App.jsx
One API file per resource. Each file exports functions that match the backend endpoints.
This mirrors how your FastAPI backend is organized. You have a users router in Python and a users.js API client in JavaScript. When you add a new endpoint to FastAPI, you add a matching function to the API client.
Reducing duplication with a helper
Notice how every function repeats the same pattern: fetch, check response, parse JSON. Extract a helper:
// frontend/src/api/client.js
const API_URL = import . meta . env . VITE_API_BASE_URL ;
export async function apiClient ( endpoint , options = {}) {
const response = await fetch ( ` ${ API_URL }${ endpoint } ` , {
headers: {
"Content-Type" : "application/json" ,
... options . headers ,
},
... options ,
});
if ( ! response . ok ) {
const error = await response . json (). catch (() => ({}));
throw new Error ( error . detail || `HTTP ${ response . status } ` );
}
// DELETE often returns no body
if ( response . status === 204 ) return null ;
return response . json ();
}
Now your API functions are one-liners:
// frontend/src/api/users.js
import { apiClient } from './client' ;
export const getUsers = () =>
apiClient ( "/api/users" );
export const getUser = ( id ) =>
apiClient ( `/api/users/ ${ id } ` );
export const createUser = ( data ) =>
apiClient ( "/api/users" , { method: "POST" , body: JSON . stringify ( data ) });
export const updateUser = ( id , data ) =>
apiClient ( `/api/users/ ${ id } ` , { method: "PUT" , body: JSON . stringify ( data ) });
export const deleteUser = ( id ) =>
apiClient ( `/api/users/ ${ id } ` , { method: "DELETE" });
The apiClient helper reads the FastAPI error message (error.detail) when available. This means your backend’s validation errors (“Email is required”) show up directly in the frontend — no extra work needed.
Why this pattern matters
Benefit Explanation Single source of truth API URL defined once in client.js DRY Error handling written once, not in every component Easy to change API changes? Update one file, not every component Testable You can mock getUsers() in tests without mocking fetch Readable components Components focus on UI, not HTTP details
This isn’t over-engineering — it’s the minimum level of organization you need once your app has more than 2-3 API calls.
Using the API client in components
import { useState , useEffect } from 'react' ;
import { getUsers , createUser , deleteUser } from '../api/users' ;
function UserDashboard () {
const [ users , setUsers ] = useState ([]);
const [ loading , setLoading ] = useState ( true );
const [ error , setError ] = useState ( null );
useEffect (() => {
loadUsers ();
}, []);
async function loadUsers () {
try {
setLoading ( true );
const data = await getUsers ();
setUsers ( data );
} catch ( err ) {
setError ( err . message );
} finally {
setLoading ( false );
}
}
async function handleCreate ( userData ) {
const newUser = await createUser ( userData );
setUsers ( prev => [ ... prev , newUser ]);
}
async function handleDelete ( userId ) {
await deleteUser ( userId );
setUsers ( prev => prev . filter ( u => u . id !== userId ));
}
// ... render with loading/error/data pattern
}
Clean, readable, and every API call is one function call.
What’s next?
Your API calls are organized. But how do you make sure the data your frontend sends matches what your backend expects? Let’s talk about type safety.
Type safety Ensure your frontend and backend agree on data shapes