Trabajando en los estilos del detalle de cada empleo

Introducción: de ruta funcional a página real

En esta clase partimos de algo que ya funciona a nivel de lógica: tenemos una ruta de detalle de empleo con un jobId que llega desde la URL, pero la página:

  • ❌ No está maquetada
  • ❌ No tiene estados de carga ni de error
  • ❌ No usa datos reales de la API para mostrar el detalle

El objetivo de la clase será transformar esa ruta mínima en una página de detalle completa:

  • Fetch a la API pasando el jobId
  • Estados job, loading y error
  • Vista de oferta no encontrada con botón para volver
  • Layout con breadcrumb, header, botón de aplicar y secciones
  • Render de contenido que viene en Markdown usando snarkdown
  • Uso de dangerouslySetInnerHTML de forma controlada y segura

Preparando el componente de detalle

Vamos a trabajar sobre el componente JobDetail (o similar) donde ya usamos el jobId que viene de la URL.

Importando hooks y definiendo tipos

Primero importamos los hooks que vamos a necesitar:

import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router'
import styles from './detail.module.css'

Creamos el estado para el detalle del trabajo, el loading y el error:

export const JobDetail = () => {
  const { jobId } = useParams()
  const navigate = useNavigate()

  const [job, setJob] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  // ...
}

Puntos clave a remarcar

  • job empieza en null porque todavía no sabemos si existe la oferta
  • loading empieza en true porque nada más entrar en la página vamos a hacer un fetch
  • error nos permite guardar un mensaje en caso de que la llamada falle o la oferta no exista

Consumir la API de detalle por ID

La API permite hacer algo como:

GET /api/jobs/:id

Y nos devuelve toda la información del trabajo: título, empresa, ubicación, descripción, modalidad, nivel y contenido de la oferta.

useEffect para hacer el fetch

En el componente:

useEffect(() => {
  if (!jobId) return

  const controller = new AbortController()

  setLoading(true)
  setError(null)

  fetch(`https://tu-api.dev/api/jobs/${jobId}`, {
    signal: controller.signal,
  })
    .then((response) => {
      // Muy importante: fetch no lanza error con 404 o 500
      if (!response.ok) {
        throw new Error('Job not found')
      }
      return response.json()
    })
    .then((data) => {
      setJob(data)
    })
    .catch((error) => {
      // Si hemos cancelado, no hacemos nada
      if (error.name === 'AbortError') return
      setError(error.message)
      setJob(null)
    })
    .finally(() => {
      setLoading(false)
    })

  return () => {
    controller.abort()
  }
}, [jobId])

Puntos didácticos a resaltar

Fetch no lanza error con códigos 400, 404 o 500

  • Siempre entra en el .then
  • Por eso hay que revisar response.ok
  • Si response.ok es false lanzamos un Error('Job not found')

finally se ejecuta tanto si ha ido bien como si ha ido mal

  • Buen lugar para poner setLoading(false) una sola vez
  • No tienes que repetir esta línea en el .then y en el .catch

AbortController para cancelar la petición

  • Si el usuario navega a otra página antes de que termine
  • Evitamos memory leaks
  • Evitamos actualizar estado en componentes desmontados

Render condicional: loading, error y oferta

Con los estados listos, podemos organizar el render.

Vista de loading

if (loading) {
  return (
    <div className={styles.loading}>
      <p>Cargando oferta...</p>
      {/* aquí puedes pegar el HTML de skeleton que ya tienes preparado */}
    </div>
  )
}

Vista de error

if (error || !job) {
  return (
    <div className={styles.notFound}>
      <h1>Oferta no encontrada</h1>
      <p>Puede que esta oferta haya caducado o que la URL no sea correcta.</p>
      <button className={styles.backButton} onClick={() => navigate('/jobs')}>
        Volver a la lista de empleos
      </button>
    </div>
  )
}

Claves a explicar

  • Guard clauses: si está cargando o hay error, devolvemos rápido esas vistas
  • El mensaje de error es genérico, pero podrías personalizarlo según el status
  • Botón de volver usando useNavigate: navegación programática de React Router

Maquetando la página de detalle

Ahora que sabemos que job existe, podemos renderizar la página principal.

return (
  <div className={styles.container}>
    {/* Breadcrumb */}
    <nav className={styles.breadcrumb}>
      <a href="/jobs" className={styles.breadcrumbLink}>
        Empleos
      </a>
      <span className={styles.breadcrumbSeparator}>/</span>
      <span className={styles.breadcrumbTitle}>{job.title}</span>
    </nav>

    {/* Header principal */}
    <header className={styles.header}>
      <h1 className={styles.title}>{job.title}</h1>
      <div className={styles.meta}>
        <p className={styles.company}>{job.company}</p>
        <p className={styles.location}>{job.location}</p>
      </div>
      <button className={styles.applyButton}>Aplicar a esta oferta</button>
    </header>

    {/* Aquí irán las secciones de contenido */}
  </div>
)

Conceptos que puedes remarcar

  • Uso de CSS Modules con styles.algo: nombres de clase locales al componente
  • Separación clara de responsabilidades: breadcrumb arriba, header con título y metadatos, y luego el cuerpo de la oferta
  • El botón de aplicar todavía no hace nada, pero deja lista la UI para integrarlo con otra funcionalidad más adelante

Creando un componente JobSection reutilizable

El contenido del trabajo se compone de varias secciones muy parecidas:

  • Descripción del puesto
  • Responsabilidades
  • Requisitos
  • Acerca de la empresa

Como todas tienen el mismo layout, creamos un componente reutilizable:

const JobSection = ({ title, content }) => {
  return (
    <section className={styles.section}>
      <h2 className={styles.sectionTitle}>{title}</h2>
      <div className={styles.sectionContent}>
        {/* Aquí todavía no tratamos el Markdown */}
        {content}
      </div>
    </section>
  )
}

Usando el componente

Y lo usamos en JobDetail:

<JobSection
  title="Descripción del puesto"
  content={job.content}
/>

<JobSection
  title="Responsabilidades"
  content={job.responsibilities}
/>

<JobSection
  title="Requisitos"
  content={job.requirements}
/>

<JobSection
  title="Acerca de la empresa"
  content={job.about}
/>

Ventajas

  • Abstractar en un componente evita repetir 4 bloques enormes de JSX
  • Solo pasamos el title y el content por props y ya está
  • Si necesitamos cambiar el estilo o estructura, lo hacemos en un solo lugar

La API devuelve Markdown, no HTML

La API está devolviendo el contenido de la oferta en Markdown:

  • Saltos de línea
  • Listas
  • Posibles negritas
  • Pero no HTML ni componentes de React

Si intentamos renderizar ese HTML directamente como string, React lo mostrará tal cual, sin interpretarlo como etiquetas.

Ejemplo del problema

<div className={styles.sectionContent}>
  {markdownContent} {/* React lo muestra como texto plano */}
</div>

Para transformarlo a HTML necesitamos una librería pequeña que convierta Markdown a HTML.

Transformando Markdown a HTML con snarkdown

Usamos snarkdown, una librería muy ligera del creador de Preact.

Instalación

npm install snarkdown

O con pnpm:

pnpm add snarkdown

Uso básico

import snarkdown from 'snarkdown'

const html = snarkdown(markdownContent)

Integramos esto en JobSection

import snarkdown from 'snarkdown'

const JobSection = ({ title, content }) => {
  const html = snarkdown(content ?? '')

  return (
    <section className={styles.section}>
      <h2 className={styles.sectionTitle}>{title}</h2>
      <div className={`${styles.sectionContent} ${styles.prose}`}>
        {/* Aquí ya vamos a usar dangerouslySetInnerHTML */}
        <div dangerouslySetInnerHTML={{ __html: html }} />
      </div>
    </section>
  )
}

Así conseguimos

  • Separar la transformación de datos (Markdown → HTML) de la lógica de layout
  • Que todas las secciones usen la misma conversión
  • Código limpio y reutilizable

dangerouslySetInnerHTML: por qué existe y por qué es “peligroso”

Si metemos HTML como string normal, React lo escapa para evitar ataques XSS.

Por eso, para inyectar HTML arbitrario, React exige usar una API especial:

<div dangerouslySetInnerHTML={{ __html: html }} />

¿Qué es XSS?

XSS (Cross-Site Scripting) es un tipo de vulnerabilidad donde un atacante inyecta código malicioso (JavaScript) en tu aplicación.

Ejemplo clásico:

// Usuario malicioso escribe en un comentario:
"Hola! <script>alert('Hacked!')</script>"

// Si renderizas esto sin escapar:
<div>{comentario}</div>

// El script se ejecutará en el navegador de otros usuarios

Problemas históricos

  • Ataques masivos en redes sociales
  • MySpace tuvo un ataque famoso donde un usuario inyectó un script que se propagaba automáticamente
  • El script añadía al atacante como amigo de cualquiera que visitara un perfil infectado

Por qué React te obliga a usar dangerouslySetInnerHTML

  • React se diseñó para escapar HTML por defecto
  • dangerouslySetInnerHTML está diseñado para que te pares un segundo y pienses
  • El nombre largo y feo está hecho a propósito
  • Es la forma controlada de hacer algo equivalente a innerHTML en el DOM

Cuándo es seguro usarlo

Solo deberías usarlo cuando:

  1. El contenido viene de una fuente que controlas tú: Como tu propia API o CMS
  2. Ya has filtrado o sanitizado el HTML si viene de usuarios
  3. Aceptas el riesgo de inyectar HTML en la página

En nuestro caso

Estamos convirtiendo Markdown a HTML usando snarkdown. El Markdown:

  • Viene de tu base de datos
  • Lo has creado tú (o tu equipo)
  • No viene de usuarios externos

Por lo tanto, es seguro usar dangerouslySetInnerHTML aquí.

Si el contenido viniera de usuarios

Si permitieras que usuarios escribieran las descripciones de empleo, deberías:

  1. Sanitizar el HTML antes de renderizarlo
  2. Usar una librería como DOMPurify:
import DOMPurify from 'dompurify'

const JobSection = ({ title, content }) => {
  const html = snarkdown(content ?? '')
  const cleanHtml = DOMPurify.sanitize(html)

  return <div dangerouslySetInnerHTML={{ __html: cleanHtml }} />
}

Importando y usando los estilos preparados

Ya tienes un archivo detail.module.css con todos los estilos preparados:

  • container
  • breadcrumb
  • section
  • sectionTitle
  • sectionContent
  • prose
  • etc.

Solo hay que

import styles from './detail.module.css'

Y usarlos en el JSX:

<div className={styles.container}>{/* ... */}</div>

Ventajas de CSS Modules

  • Nombres de clase locales al componente: No hay colisiones con otros estilos
  • Más fácil de mantener: Sabes exactamente dónde se usan los estilos
  • Cómo se combinan clases con template literals:
<div className={`${styles.sectionContent} ${styles.prose}`}>

La clase prose

Sirve para mejorar el estilo del HTML generado por el Markdown:

  • Espaciado entre párrafos
  • Estilos para listas
  • Tipografía legible
  • Similar a lo que hace Tailwind Typography

Código completo del componente

Juntando todas las piezas:

import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router'
import snarkdown from 'snarkdown'
import styles from './detail.module.css'

const JobSection = ({ title, content }) => {
  const html = snarkdown(content ?? '')

  return (
    <section className={styles.section}>
      <h2 className={styles.sectionTitle}>{title}</h2>
      <div className={`${styles.sectionContent} ${styles.prose}`}>
        <div dangerouslySetInnerHTML={{ __html: html }} />
      </div>
    </section>
  )
}

export const JobDetail = () => {
  const { jobId } = useParams()
  const navigate = useNavigate()

  const [job, setJob] = useState(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState(null)

  useEffect(() => {
    if (!jobId) return

    const controller = new AbortController()

    setLoading(true)
    setError(null)

    fetch(`https://tu-api.dev/api/jobs/${jobId}`, {
      signal: controller.signal,
    })
      .then((response) => {
        if (!response.ok) {
          throw new Error('Job not found')
        }
        return response.json()
      })
      .then((data) => {
        setJob(data)
      })
      .catch((error) => {
        if (error.name === 'AbortError') return
        setError(error.message)
        setJob(null)
      })
      .finally(() => {
        setLoading(false)
      })

    return () => {
      controller.abort()
    }
  }, [jobId])

  if (loading) {
    return (
      <div className={styles.loading}>
        <p>Cargando oferta...</p>
      </div>
    )
  }

  if (error || !job) {
    return (
      <div className={styles.notFound}>
        <h1>Oferta no encontrada</h1>
        <p>Puede que esta oferta haya caducado o que la URL no sea correcta.</p>
        <button className={styles.backButton} onClick={() => navigate('/jobs')}>
          Volver a la lista de empleos
        </button>
      </div>
    )
  }

  return (
    <div className={styles.container}>
      <nav className={styles.breadcrumb}>
        <a href="/jobs" className={styles.breadcrumbLink}>
          Empleos
        </a>
        <span className={styles.breadcrumbSeparator}>/</span>
        <span className={styles.breadcrumbTitle}>{job.title}</span>
      </nav>

      <header className={styles.header}>
        <h1 className={styles.title}>{job.title}</h1>
        <div className={styles.meta}>
          <p className={styles.company}>{job.company}</p>
          <p className={styles.location}>{job.location}</p>
        </div>
        <button className={styles.applyButton}>Aplicar a esta oferta</button>
      </header>

      <JobSection title="Descripción del puesto" content={job.content} />

      <JobSection title="Responsabilidades" content={job.responsibilities} />

      <JobSection title="Requisitos" content={job.requirements} />

      <JobSection title="Acerca de la empresa" content={job.about} />
    </div>
  )
}

Resumen y siguientes pasos

Al final de la clase hemos conseguido:

  • ✅ Crear los estados job, loading y error para el detalle de empleo
  • ✅ Hacer un fetch a la API pasando la ID que viene de la URL, y comprobar response.ok antes de leer el JSON
  • ✅ Mostrar una vista de carga y una vista de “oferta no encontrada” con botón de volver
  • ✅ Maquetar el detalle de empleo con breadcrumb, título, empresa, ubicación y botón de aplicar
  • ✅ Crear un componente JobSection para no repetir layout en las secciones de contenido
  • ✅ Convertir Markdown a HTML con snarkdown y renderizarlo en React con dangerouslySetInnerHTML, entendiendo riesgos y contexto de seguridad

Ideas para mejoras futuras

  • Extraer la lógica de fetch a un useJobDetail o a un client de datos compartido
  • Añadir sanitización del HTML antes de inyectarlo si el contenido viene de usuarios
  • Conectar el botón de aplicar con una URL externa o con un flujo de formulario dentro de la propia app
  • Añadir meta tags para SEO (título, descripción, Open Graph)
  • Skeleton loading más elaborado para mejor UX

Con esto, el detalle de empleo deja de ser una simple ruta y pasa a ser una página cuidada, funcional y lista para producción.