Llamada a la API: Consumiendo datos reales con fetch

Hasta ahora hemos trabajado con datos estáticos almacenados en archivos locales. En esta clase aprenderás a consumir una API real, gestionar el estado de carga, manejar errores y mejorar la experiencia del usuario mientras se obtienen los datos del servidor.

El problema: Datos estáticos vs datos dinámicos

Trabajando con datos estáticos

Hasta ahora, nuestros datos venían de un archivo:

// jobs.json
const jobs = [
  { id: 1, title: 'Frontend Developer', company: 'Acme Corp', location: 'Remote' },
  { id: 2, title: 'Backend Engineer', company: 'TechCo', location: 'Madrid' },
  // ...
]

// App.jsx
import jobsData from './data/jobs.json'

function App() {
  const [jobs] = useState(jobsData)

  return (
    <div>
      {jobs.map((job) => (
        <JobCard key={job.id} job={job} />
      ))}
    </div>
  )
}

Problemas con datos estáticos:

  • Los datos nunca cambian (no reflejan la realidad)
  • No se pueden filtrar en el backend
  • No hay paginación real
  • No escala para grandes volúmenes de datos
  • No representa cómo funcionan las aplicaciones reales

Datos dinámicos desde una API

En aplicaciones reales, los datos vienen de un servidor:

function App() {
  const [jobs, setJobs] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    fetch('/api/jobs')
      .then((response) => response.json())
      .then((data) => {
        setJobs(data)
        setLoading(false)
      })
  }, [])

  if (loading) return <div>Cargando empleos...</div>

  return (
    <div>
      {jobs.map((job) => (
        <JobCard key={job.id} job={job} />
      ))}
    </div>
  )
}

Ventajas:

  • ✨ Datos actualizados en tiempo real
  • 🔍 Filtros procesados en el servidor
  • 📄 Paginación eficiente
  • 🚀 Escalable para cualquier cantidad de datos
  • 💼 Refleja aplicaciones profesionales

Revisión: ¿Cuál era el custom hook correcto?

Antes de comenzar con la API, hagamos una reflexión sobre el custom hook que creamos anteriormente.

El hook que NO debimos crear

// ❌ Este hook NO es el correcto
function useSomeLogic() {
  // Lógica demasiado simple o no relacionada con filtros
  // Solo algunas funciones helper
}

El hook correcto: useFilters

El custom hook que realmente debimos crear es useFilters, porque contiene la lógica pesada del componente:

// ✅ Este es el hook correcto
function useFilters() {
  const [filters, setFilters] = useState({
    text: '',
    type: '',
    location: '',
  })
  const [page, setPage] = useState(1)
  const [limit] = useState(10)

  const updateFilter = (name, value) => {
    setFilters((prev) => ({ ...prev, [name]: value }))
  }

  const nextPage = () => setPage((prev) => prev + 1)
  const prevPage = () => setPage((prev) => Math.max(1, prev - 1))

  return {
    filters,
    updateFilter,
    page,
    nextPage,
    prevPage,
    limit,
  }
}

¿Por qué este hook es el correcto?

  • ✅ Encapsula toda la lógica de estados relacionados
  • ✅ Gestiona múltiples estados (filtros, paginación)
  • ✅ Proporciona funciones para modificar el estado
  • ✅ Es reutilizable en otros componentes

¿Por qué no incluir el useEffect del título en el hook?

Podrías pensar: “¿Por qué no meter el useEffect que cambia el título dentro del hook?”

// ❌ No hacer esto
function useFilters() {
  const [filters, setFilters] = useState({})

  // Esto NO pertenece aquí
  useEffect(() => {
    document.title = `Empleos - Filtros aplicados`
  }, [filters])

  return { filters, setFilters }
}

Respuesta: Ese efecto no tiene nada que ver con aplicar los filtros.

  • El efecto cambia el título de la página
  • Los filtros gestionan los criterios de búsqueda
  • Son responsabilidades diferentes

Regla de oro para custom hooks

Un hook debe representar algo concreto y cohesivo. No mezcles lógicas no relacionadas.

Hacer:

function useFilters() {
  // Todo relacionado con filtros
}

function useDocumentTitle(title) {
  // Todo relacionado con el título del documento
  useEffect(() => {
    document.title = title
  }, [title])
}

Presentación de la API

En este módulo usaremos una API real de empleos que ya está creada y desplegada:

GET https://jscamp-api.vercel.app/api/jobs

Características de la API

La API soporta los siguientes parámetros de búsqueda:

ParámetroTipoDescripciónEjemplo
limitnumberCantidad de resultados por página10
offsetnumberDesde qué resultado empezar0, 10, 20
textstringBúsqueda por texto libre"react"
technologystringFiltrar por tecnología"react", "node"
typestringFiltrar por modalidad/ubicación"remoto", "madrid"
levelstringFiltrar por nivel de experiencia"junior", "senior"

Ejemplo de respuesta real

{
  "total": 34,
  "limit": 10,
  "offset": 0,
  "results": 10,
  "data": [
    {
      "id": "7a4d1d8b-1e45-4d8c-9f1a-8c2f9a9121a4",
      "titulo": "Desarrollador de Software Senior",
      "empresa": "Tech Solutions Inc.",
      "ubicacion": "Remoto",
      "descripcion": "Buscamos un ingeniero de software con experiencia en desarrollo web y conocimientos en JavaScript, React y Node.js. El candidato ideal debe ser capaz de trabajar en equipo y tener buenas habilidades de comunicación.",
      "data": {
        "technology": ["react", "node", "javascript"],
        "modalidad": "remoto",
        "nivel": "senior"
      }
    },
    {
      "id": "d35b2c89-5d60-4f26-b19a-6cfb2f1a0f57",
      "titulo": "Analista de Datos",
      "empresa": "Data Driven Co.",
      "ubicacion": "Ciudad de México",
      "descripcion": "Estamos buscando un analista de datos con experiencia en el manejo de grandes conjuntos de datos y herramientas de visualización. Se requiere conocimiento en SQL, Python y R.",
      "data": {
        "technology": ["python", "sql", "r", "pandas"],
        "modalidad": "cdmx",
        "nivel": "junior"
      }
    }
    // ... más empleos
  ]
}

Estructura de la respuesta:

  • total: Total de empleos disponibles (34 en este caso)
  • limit: Cuántos empleos se devuelven por página (10)
  • offset: Desde qué posición se empieza (0)
  • results: Cantidad de resultados en esta respuesta (10)
  • data: Array con los empleos

Estructura de cada empleo:

  • id: Identificador único (UUID)
  • titulo: Título del puesto
  • empresa: Nombre de la empresa
  • ubicacion: Ubicación del trabajo
  • descripcion: Descripción detallada del puesto
  • data: Objeto con metadata
    • technology: Array de tecnologías requeridas
    • modalidad: Modalidad de trabajo (remoto, presencial, híbrido)
    • nivel: Nivel de experiencia requerido

💡 Puedes probar la API directamente en tu navegador visitando https://jscamp-api.vercel.app/api/jobs

Preparando el estado para la API

Antes de hacer la llamada, necesitamos preparar varios estados:

function JobSearch() {
  // Estado para los empleos (inicialmente vacío)
  const [jobs, setJobs] = useState([])

  // Estado para indicar que estamos cargando
  const [loading, setLoading] = useState(true)

  // Estado para el total de resultados
  const [total, setTotal] = useState(0)

  return (
    <div>{loading ? <div>Cargando empleos...</div> : <JobListing jobs={jobs} total={total} />}</div>
  )
}

¿Por qué tres estados?

1. jobs → Array con los empleos

  • Inicialmente vacío []
  • Se llena cuando la API responde

2. loading → Booleano para estado de carga

  • true mientras esperamos la respuesta
  • false cuando ya tenemos los datos
  • Mejora la UX mostrando un indicador de carga

3. total → Número total de resultados

  • Necesario para la paginación
  • Permite calcular cuántas páginas hay

¿Por qué NO podemos hacer fetch directamente en el render?

Podrías pensar: “¿Por qué no hacer fetch() directamente en el cuerpo del componente?”

// ❌ NUNCA hagas esto
function JobSearch() {
  const [jobs, setJobs] = useState([])

  // ¡ERROR! Esto causará un loop infinito
  fetch('/api/jobs')
    .then((response) => response.json())
    .then((data) => setJobs(data)) // Esto causa un re-render
  // El re-render vuelve a ejecutar fetch()
  // Que vuelve a causar un re-render
  // ¡Loop infinito! 💥

  return <JobListing jobs={jobs} />
}

El problema del render

React ejecuta el cuerpo del componente en cada render:

1. Componente se renderiza
2. fetch() se ejecuta
3. Respuesta llega → setJobs()
4. setJobs() causa un re-render
5. Volvemos al paso 1 → ¡Loop infinito!

La solución: useEffect

Para efectos secundarios (como llamadas a APIs) siempre usa useEffect:

function JobSearch() {
  const [jobs, setJobs] = useState([])

  useEffect(() => {
    // Esto solo se ejecuta cuando lo indiquen las dependencias
    fetch('https://jscamp-api.vercel.app/api/jobs')
      .then((response) => response.json())
      .then((data) => setJobs(data))
  }, []) // Array vacío = solo al montar el componente

  return <JobListing jobs={jobs} />
}

Implementando el fetch en un efecto

Vamos a implementar correctamente la llamada a la API:

function JobSearch() {
  const [jobs, setJobs] = useState([])
  const [loading, setLoading] = useState(true)
  const [total, setTotal] = useState(0)

  useEffect(() => {
    // Función asíncrona dentro del efecto
    async function fetchJobs() {
      try {
        // 1. Indicar que estamos cargando
        setLoading(true)

        // 2. Hacer la petición
        const response = await fetch('https://jscamp-api.vercel.app/api/jobs')
        const json = await response.json()

        // 3. Guardar los datos
        setJobs(json.data)
        setTotal(json.total)
      } catch (error) {
        // 4. Manejar errores
        console.error('Error al cargar empleos:', error)
      } finally {
        // 5. Indicar que terminamos de cargar
        setLoading(false)
      }
    }

    // Llamar a la función
    fetchJobs()
  }, []) // Dependencias vacías = solo al montar

  if (loading) return <div>Cargando empleos...</div>

  return <JobListing jobs={jobs} total={total} />
}

¿Por qué no usamos directamente el callback de useEffect como async? Porque el callback de useEffect siempre tiene que ser una función sincrónica, por lo que no podemos usar async directamente. Por eso creamos una función asíncrona dentro del efecto.

Desglosando el código

1. Función fetchJobs dentro del efecto

async function fetchJobs() {
  // Lógica de fetch aquí
}

¿Por qué definir la función dentro del efecto?

  • ✅ Mantiene la lógica cerca de donde se usa
  • ✅ Evita que sea accesible desde otros lugares
  • ✅ Tiene acceso directo a los estados del componente
  • ✅ Es más fácil de leer y mantener

2. Estados de carga

setLoading(true) // Al inicio
// ... hacer fetch
setLoading(false) // Al terminar

Esto permite mostrar feedback al usuario mientras esperamos la respuesta.

3. Manejo de la respuesta

const response = await fetch('/api/jobs')
const json = await response.json()

setJobs(json.data) // Solo el array de empleos
setTotal(json.total) // El total de resultados

La API devuelve un objeto con { data, total, limit, offset }, pero solo necesitamos data y total.

4. Try-catch para errores

try {
  // Intentar hacer fetch
} catch (error) {
  console.error('Error:', error)
}

Siempre maneja posibles errores en llamadas asíncronas.

5. Finally para cleanup

finally {
  setLoading(false)
}

Sin importar si hubo éxito o error, siempre indicamos que terminamos de cargar.

Corrección del error: jobs.map is not a function

Al implementar el fetch, es común encontrar este error:

TypeError: jobs.map is not a function

¿Por qué ocurre?

La API devuelve:

{
  "total": 143,
  "limit": 10,
  "offset": 0,
  "data": [
    /* empleos aquí */
  ]
}

Si haces esto:

// ❌ Error: guardas todo el objeto
setJobs(json) // json es un objeto, no un array

// Luego en el render:
jobs.map(job => ...) // ¡Error! jobs es un objeto, no tiene .map()

Solución: Separar datos

// ✅ Correcto: guardar solo el array
const response = await fetch('/api/jobs')
const json = await response.json()

setJobs(json.data) // Solo el array con los resultados
setTotal(json.total) // El número total en otro estado

Ahora:

  • jobs es un array → puede usar .map()
  • total es un número → podemos usarlo para paginación

Verificando que los datos vienen de la API

Una vez implementado, puedes verificar que todo funciona:

1. Abrir DevTools → Network

  • Haz clic en la pestaña Network (Red)
  • Recarga la página
  • Verás una petición a /api/jobs

2. Inspeccionar la petición

Request URL: https://jscamp-api.vercel.app/api/jobs
Request Method: GET
Status Code: 200 OK
Response:
{
  "total": 143,
  "limit": 10,
  "offset": 0,
  "data": [...]
}

3. Verificar los datos en pantalla

Los empleos mostrados ahora vienen de la API, no del archivo estático:

  • ✅ Si actualizas la base de datos, los cambios se reflejan
  • ✅ Los filtros se procesarán en el backend (próxima clase)
  • ✅ La paginación será real, no solo frontend

Simulando una conexión lenta

Para ver mejor el estado de carga, puedes simular una red lenta:

Opción 1: En DevTools

  1. Abre DevToolsNetwork
  2. Cambia “No throttling” a “Slow 3G”
  3. Recarga la página

Verás que tarda mucho más en cargar, y el estado loading es visible.

Opción 2: Delay artificial en código

Para desarrollo, puedes añadir un delay artificial:

useEffect(() => {
  async function fetchJobs() {
    try {
      setLoading(true)

      // Delay artificial de 5 segundos
      await new Promise((resolve) => setTimeout(resolve, 5000))

      const response = await fetch('/api/jobs')
      const json = await response.json()

      setJobs(json.data)
      setTotal(json.total)
    } catch (error) {
      console.error(error)
    } finally {
      setLoading(false)
    }
  }

  fetchJobs()
}, [])

Ahora verás claramente:

  1. Pantalla muestra “Cargando empleos…” durante 5 segundos
  2. Luego aparecen los datos

⚠️ Recuerda quitar el delay antes de subir a producción.

Mejorando la UX con un indicador de carga

Sin un indicador de carga, el usuario ve una pantalla en blanco y no sabe qué está pasando.

Antes (sin indicador)

function JobSearch() {
  const [jobs, setJobs] = useState([])

  useEffect(() => {
    fetch('https://jscamp-api.vercel.app/api/jobs')
      .then((r) => r.json())
      .then((data) => setJobs(data.data))
  }, [])

  return <JobListing jobs={jobs} />
}

Problema: Mientras carga la API, jobs es un array vacío → la página muestra nada.

Después (con indicador)

function JobSearch() {
  const [jobs, setJobs] = useState([])
  const [loading, setLoading] = useState(true)

  useEffect(() => {
    async function fetchJobs() {
      setLoading(true)

      const response = await fetch('https://jscamp-api.vercel.app/api/jobs')
      const data = await response.json()

      setJobs(data.data)
      setLoading(false)
    }

    fetchJobs()
  }, [])

  if (loading) {
    return (
      <div className="loading">
        <p>Cargando empleos...</p>
        <div className="spinner" />
      </div>
    )
  }

  return <JobListing jobs={jobs} />
}

Ventaja: El usuario sabe que algo está pasando, mejorando la percepción de la aplicación.

El ciclo completo de carga

Entendamos todo el flujo paso a paso:

1. Componente se monta
   └─> loading = true
   └─> jobs = []

2. useEffect se ejecuta
   └─> Llama a fetchJobs()

3. fetchJobs() hace la petición
   └─> Usuario ve: "Cargando empleos..."
   └─> Esperando respuesta del servidor...

4. API responde
   └─> setJobs(data.data)
   └─> setTotal(data.total)
   └─> setLoading(false)

5. React re-renderiza
   └─> loading ahora es false
   └─> Se renderiza JobListing con los empleos

Conceptos clave aprendidos

ConceptoDescripción
Fetch en useEffectLas llamadas asíncronas deben estar dentro de useEffect, nunca en el render directo
Estado de cargaUsar loading para mostrar feedback mientras esperamos la respuesta
Separación de datosDistinguir entre el objeto completo de la API y los datos que necesitamos
Gestión de erroresSiempre usar try-catch en operaciones asíncronas
Dependencias vacías[] en useEffect = ejecutar solo al montar el componente
UX durante cargaNunca dejar al usuario con una pantalla en blanco

Buenas prácticas

Siempre usa try-catch en llamadas asíncronas

Gestiona el estado de carga para feedback al usuario

Maneja errores apropiadamente con estados y mensajes

Usa loading/finally para garantizar que el estado se actualice

Separa responsabilidades entre diferentes hooks cuando sea necesario

NUNCA hagas fetch directamente en el render

NO mezcles lógicas no relacionadas en un mismo hook

NO ignores los errores de las peticiones

Próximos pasos

En las siguientes clases aprenderemos:

  1. Filtros dinámicos: Enviar parámetros a la API según los filtros del usuario
  2. Paginación real: Usar limit y offset para cargar páginas
  3. Debouncing: Evitar hacer peticiones en cada tecla

Conclusión

En esta clase has aprendido a:

  • ✅ Consumir una API real usando fetch
  • ✅ Gestionar estados de carga con loading
  • ✅ Manejar errores en peticiones asíncronas
  • ✅ Entender por qué usar useEffect para llamadas a APIs
  • ✅ Separar correctamente los datos de la respuesta
  • ✅ Mejorar la UX mostrando indicadores de carga
  • ✅ Evitar loops infinitos en llamadas asíncronas
  • ✅ Identificar qué lógica debe ir en qué custom hook

Has dado un paso crucial: pasar de datos estáticos a datos dinámicos. Ahora tu aplicación React puede comunicarse con un backend real, lo cual es fundamental para aplicaciones profesionales.

💡 Recuerda: En las próximas clases integraremos filtros y paginación con la API, completando el ciclo de una aplicación real.