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,loadingyerror - 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
dangerouslySetInnerHTMLde 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
jobempieza ennullporque todavía no sabemos si existe la ofertaloadingempieza entrueporque nada más entrar en la página vamos a hacer un fetcherrornos 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.okesfalselanzamos unError('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
.theny 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
titley elcontentpor 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
dangerouslySetInnerHTMLestá 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
innerHTMLen el DOM
Cuándo es seguro usarlo
Solo deberías usarlo cuando:
- El contenido viene de una fuente que controlas tú: Como tu propia API o CMS
- Ya has filtrado o sanitizado el HTML si viene de usuarios
- 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:
- Sanitizar el HTML antes de renderizarlo
- 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:
containerbreadcrumbsectionsectionTitlesectionContentprose- 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,loadingyerrorpara el detalle de empleo - ✅ Hacer un fetch a la API pasando la ID que viene de la URL, y comprobar
response.okantes 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
JobSectionpara no repetir layout en las secciones de contenido - ✅ Convertir Markdown a HTML con
snarkdowny renderizarlo en React condangerouslySetInnerHTML, entendiendo riesgos y contexto de seguridad
Ideas para mejoras futuras
- Extraer la lógica de fetch a un
useJobDetailo 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.