Overview
This guide demonstrates practical API integration patterns used in the Weather Finder and GIFs App projects:
Fetching data with proper error handling
Loading states and user feedback
Response transformation and type safety
Caching strategies for performance
Debouncing and request optimization
Basic API Calls
Simple Fetch Pattern
The Weather Finder app demonstrates a clean API service layer:
const GEO_API_BASE = 'https://geocoding-api.open-meteo.com/v1/search' ;
export async function geocodeCity ( city : string ) : Promise < GeocodingResult > {
const url = ` ${ GEO_API_BASE } ?name= ${ encodeURIComponent ( city ) } &count=1&language=es&format=json` ;
const res = await fetch ( url );
if ( ! res . ok ) {
throw new Error ( 'No se pudo conectar con el servicio de búsqueda.' );
}
const data = await res . json ();
if ( ! data . results ?. length ) {
throw new Error ( `No se encontró ninguna ciudad con el nombre " ${ city } ".` );
}
return data . results [ 0 ];
}
Always use encodeURIComponent() when adding user input to URL parameters to prevent injection attacks and handle special characters.
Sequential API Calls
Sometimes you need data from one API to call another:
export async function fetchWeather (
latitude : number ,
longitude : number ,
) : Promise <{ current : CurrentWeatherData ; daily : DailyForecastData }> {
const url =
` ${ WEATHER_API_BASE } ` +
`?latitude= ${ latitude } ` +
`&longitude= ${ longitude } ` +
`¤t=temperature_2m,wind_speed_10m,weather_code` +
`&daily=temperature_2m_max,temperature_2m_min,weather_code` +
`&timezone=auto` +
`&forecast_days=7` ;
const res = await fetch ( url );
if ( ! res . ok ) {
throw new Error ( 'No se pudo obtener el pronóstico del tiempo.' );
}
const data = await res . json ();
return { current: data . current , daily: data . daily };
}
Use template literals for complex URLs instead of URLSearchParams when the API requires literal commas (not encoded as %2C).
Error Handling
Comprehensive Error Management
The Weather Finder hook shows how to handle errors at multiple levels:
import { useState , useCallback } from 'react' ;
interface WeatherState {
status : 'idle' | 'loading' | 'success' | 'error' ;
data : WeatherData | null ;
error : string | null ;
}
export function useWeather () {
const [ state , setState ] = useState < WeatherState >({
status: 'idle' ,
data: null ,
error: null ,
});
const search = useCallback ( async ( city : string ) => {
const trimmed = city . trim ();
if ( ! trimmed ) return ;
// Set loading state
setState ({ status: 'loading' , data: null , error: null });
try {
// Sequential API calls
const { name , latitude , longitude , country } = await geocodeCity ( trimmed );
const { current , daily } = await fetchWeather ( latitude , longitude );
// Success state
setState ({
status: 'success' ,
data: { cityName: name , country , current , daily },
error: null ,
});
} catch ( err ) {
// Error state with user-friendly message
setState ({
status: 'error' ,
data: null ,
error: err instanceof Error ? err . message : 'Ocurrió un error inesperado.' ,
});
}
}, []);
return { ... state , search };
}
Why use a status field instead of separate loading/error booleans?
A single status field ensures mutually exclusive states. You can’t be both loading and in error state at the same time. This prevents impossible states and makes the UI logic clearer: // Clean and explicit
if ( status === 'loading' ) return < LoadingSpinner /> ;
if ( status === 'error' ) return < ErrorMessage /> ;
if ( status === 'success' ) return < WeatherData /> ;
// vs. ambiguous boolean states
if ( isLoading && hasError ) return ??? // impossible state
Displaying Errors to Users
import { useWeather } from './hooks/useWeather' ;
import { ErrorMessage } from './components/ErrorMessage' ;
function App () {
const { status , data , error , search } = useWeather ();
return (
< div className = "app" >
< SearchBar onSearch = { search } isLoading = { status === 'loading' } />
< div className = "app-content" >
{ status === 'idle' && (
< div className = "idle-state" >
< p > Busca una ciudad para ver su pronóstico </ p >
</ div >
) }
{ status === 'loading' && < LoadingSpinner /> }
{ status === 'error' && error && < ErrorMessage message = { error } /> }
{ status === 'success' && data && < WeatherData data = { data } /> }
</ div >
</ div >
);
}
Mapping API Data to Domain Models
The GIFs App transforms Giphy’s complex response into a simpler interface:
get-gifs-by-query.action.ts
import type { GiphyResponse } from "../interfaces/giphy.response" ;
import type { Gif } from "../interfaces/gif.interface" ;
import { giphyApi } from "../api/giphy.api" ;
export const getGifsByQuery = async ( query : string ) : Promise < Gif []> => {
const response = await giphyApi < GiphyResponse >( "/search" , {
params: {
q: query ,
limit: 10 ,
},
});
// Transform complex API response to simple domain model
return response . data . data . map (( gif ) => ({
id: gif . id ,
title: gif . title ,
url: gif . images . original . url ,
width: Number ( gif . images . original . width ),
height: Number ( gif . images . original . height ),
}));
};
Transform API responses at the boundary of your application. This keeps your components clean and protects them from API changes.
Type Safety with TypeScript
gif.interface.ts (Domain Model)
giphy.response.ts (API Response)
export interface Gif {
id : string ;
title : string ;
url : string ;
width : number ;
height : number ;
}
Caching Strategies
In-Memory Cache with useRef
The GIFs App implements a client-side cache to avoid redundant API calls:
import { useRef , useState } from "react" ;
import { getGifsByQuery } from "../actions/get-gifs-by-query.action" ;
export const useGifs = () => {
const [ gifs , setGifs ] = useState < Gif []>([]);
const [ previousTerms , setPreviousTerms ] = useState < string []>([]);
// Cache persists across renders without causing re-renders
const gifsCache = useRef < Record < string , Gif []>>({});
const handleTermClicked = async ( term : string ) => {
// Check cache first
if ( gifsCache . current [ term ]) {
setGifs ( gifsCache . current [ term ]);
return ;
}
// Fetch if not cached
const gifs = await getGifsByQuery ( term );
setGifs ( gifs );
};
const handleSearch = async ( query : string = "" ) => {
query = query . trim (). toLowerCase ();
if ( query . length === 0 ) return ;
// Prevent duplicate searches
if ( previousTerms . includes ( query )) return ;
// Track search history (keep last 7)
setPreviousTerms ([ query , ... previousTerms ]. splice ( 0 , 7 ));
const gifs = await getGifsByQuery ( query );
setGifs ( gifs );
// Store in cache
gifsCache . current [ query ] = gifs ;
};
return {
gifs ,
handleSearch ,
previousTerms ,
handleTermClicked ,
};
};
Use useRef for caching because it doesn’t trigger re-renders when updated. Only update state when you need the UI to reflect changes.
Cache Benefits
Automatic Search with Delay
The SearchBar component implements debouncing to reduce API calls:
import { useEffect , useState } from "react" ;
interface Props {
placeholder ?: string ;
onQuery : ( query : string ) => void ;
}
export const SearchBar = ({ placeholder = "Buscar" , onQuery } : Props ) => {
const [ query , setQuery ] = useState ( "" );
useEffect (() => {
// Wait 700ms after user stops typing
const timeoutId = setTimeout (() => {
onQuery ( query );
}, 700 );
// Cancel previous timer on each keystroke
return () => {
clearTimeout ( timeoutId );
};
}, [ query , onQuery ]);
return (
< div className = "search-container" >
< input
type = "text"
placeholder = { placeholder }
value = { query }
onChange = { ( event ) => setQuery ( event . target . value ) }
/>
</ div >
);
};
Without debouncing, typing “cats” would trigger 4 API calls: c -> API call
ca -> API call
cat -> API call
cats -> API call
With 700ms debouncing, only the final search happens: c -> timer started (700ms)
ca -> timer cancelled and restarted
cat -> timer cancelled and restarted
cats -> timer cancelled and restarted
[700ms passes]
cats -> API call (only one!)
Complete Integration Example
Here’s how all patterns work together in the GIFs App:
import { GifList } from "./gifs/components/GifList" ;
import { PreviousSearches } from "./gifs/components/PreviousSearches" ;
import { SearchBar } from "./shared/components/SearchBar" ;
import { useGifs } from "./gifs/hooks/useGifs" ;
export const GifsApp = () => {
const { handleSearch , handleTermClicked , previousTerms , gifs } = useGifs ();
return (
<>
< CustomHeader
title = "Buscador de Gifs"
description = "Descubre y comparte el gif"
/>
{ /* Debounced search */ }
< SearchBar
placeholder = "Busca lo que quieras"
onQuery = { handleSearch }
/>
{ /* Click to load from cache */ }
< PreviousSearches
searches = { previousTerms }
onLabelClicked = { handleTermClicked }
/>
{ /* Display results */ }
< GifList gifs = { gifs } />
</>
);
};
Best Practices
Validation Always validate and sanitize user input before sending to APIs: const trimmed = city . trim ();
if ( ! trimmed ) return ;
const encoded = encodeURIComponent ( trimmed );
Error Messages Provide user-friendly error messages: if ( ! data . results ?. length ) {
throw new Error (
`No se encontró " ${ city } "`
);
}
Loading States Always show loading indicators: { status === 'loading' &&
< LoadingSpinner /> }
Type Safety Define interfaces for API responses: interface ApiResponse {
data : DataType [];
error ?: string ;
}
Debounce search inputs to reduce API calls (700ms is a good default)
Cache responses using useRef for frequently accessed data
Validate before fetching to avoid unnecessary requests
Transform at the boundary to keep components simple
Use proper loading states to provide user feedback
Handle errors gracefully with user-friendly messages
State Management Learn about useState, useRef, and localStorage patterns
Component Patterns Discover component composition and reusable patterns