Tema
Proyecto: app de tareas
Tu primer proyecto completo: una app de tareas con todo lo que has aprendido. Usarás useReducer para la lógica, un custom hook para guardar en localStorage, filtros, edición en línea, prioridades, estadísticas y tests. Es un excelente proyecto para tu portafolio.
Qué vas a construir
TaskFlow, una app donde puedes:
- Agregar tareas con prioridad (alta, media, baja).
- Marcar como completadas, editar con doble clic y eliminar.
- Filtrar por estado (todas, pendientes, completadas) y buscar por texto.
- Ver estadísticas y una barra de progreso.
- Borrar todas las completadas.
- Conservar todo al recargar la página (
localStorage).
txt
┌─────────────────────────────────────────┐
│ TaskFlow 3/5 ✔ │
│ ███████████░░░░░░░ 60% │
├─────────────────────────────────────────┤
│ [ ¿Qué necesitas hacer? ] [Media ▾] [+]│
│ [Todas] [Pendientes] [Completadas] 🔍 │
├─────────────────────────────────────────┤
│ ☐ Enviar cotización ALTA ✕ │
│ ☑ Diseñar logo MEDIA ✕ │
│ ☐ Comprar dominio BAJA ✕ │
├─────────────────────────────────────────┤
│ 2 pendientes Borrar completadas │
└─────────────────────────────────────────┘Crear el proyecto
bash
npm create vite@latest taskflow -- --template react
cd taskflow
npm install
npm i -D vitest jsdom @testing-library/react @testing-library/dom @testing-library/user-event @testing-library/jest-dom
npm run devEstructura
txt
src/
├── components/
│ ├── FormTarea.jsx
│ ├── Filtros.jsx
│ ├── ItemTarea.jsx
│ ├── ListaTareas.jsx
│ └── Estadisticas.jsx
├── hooks/
│ ├── useLocalStorageReducer.js
│ └── useTareas.js
├── state/
│ ├── tareasReducer.js
│ └── tareasReducer.test.js
├── App.jsx
├── App.css
└── main.jsxPaso 1: el reducer (la lógica)
Toda la lógica vive en una función pura: fácil de entender y de probar.
jsx
// src/state/tareasReducer.js
export const PRIORIDADES = ['alta', 'media', 'baja']
export const estadoInicial = {
tareas: [],
filtro: 'todas', // 'todas' | 'pendientes' | 'completadas'
busqueda: ''
}
export function tareasReducer(estado, accion) {
switch (accion.type) {
case 'agregar': {
const texto = accion.texto.trim()
if (!texto) return estado
const nueva = {
id: crypto.randomUUID(),
texto,
prioridad: accion.prioridad ?? 'media',
completada: false,
creadaEn: Date.now()
}
return { ...estado, tareas: [nueva, ...estado.tareas] }
}
case 'alternar':
return {
...estado,
tareas: estado.tareas.map((t) => (t.id === accion.id ? { ...t, completada: !t.completada } : t))
}
case 'editar': {
const texto = accion.texto.trim()
if (!texto) return estado
return {
...estado,
tareas: estado.tareas.map((t) => (t.id === accion.id ? { ...t, texto } : t))
}
}
case 'eliminar':
return { ...estado, tareas: estado.tareas.filter((t) => t.id !== accion.id) }
case 'limpiarCompletadas':
return { ...estado, tareas: estado.tareas.filter((t) => !t.completada) }
case 'filtrar':
return { ...estado, filtro: accion.filtro }
case 'buscar':
return { ...estado, busqueda: accion.busqueda }
default:
throw new Error(`Acción desconocida: ${accion.type}`)
}
}
// Selector: tareas visibles según filtro y búsqueda, ordenadas por prioridad
export function seleccionarVisibles({ tareas, filtro, busqueda }) {
const q = busqueda.trim().toLowerCase()
return tareas
.filter((t) => (filtro === 'pendientes' ? !t.completada : filtro === 'completadas' ? t.completada : true))
.filter((t) => t.texto.toLowerCase().includes(q))
.toSorted(
(a, b) =>
Number(a.completada) - Number(b.completada) ||
PRIORIDADES.indexOf(a.prioridad) - PRIORIDADES.indexOf(b.prioridad) ||
b.creadaEn - a.creadaEn
)
}
export function seleccionarEstadisticas({ tareas }) {
const total = tareas.length
const completadas = tareas.filter((t) => t.completada).length
return {
total,
completadas,
pendientes: total - completadas,
porcentaje: total === 0 ? 0 : Math.round((completadas / total) * 100)
}
}Paso 2: persistencia y hook de la feature
jsx
import { useEffect, useReducer } from 'react'
// Como useReducer, pero guarda el estado en localStorage
export function useLocalStorageReducer(clave, reducer, inicial, { persistir } = {}) {
const [estado, dispatch] = useReducer(reducer, inicial, (base) => {
try {
const guardado = localStorage.getItem(clave)
return guardado ? { ...base, ...JSON.parse(guardado) } : base
} catch {
return base
}
})
useEffect(() => {
try {
// persistir permite guardar sólo una parte (p. ej. no la búsqueda)
const datos = persistir ? persistir(estado) : estado
localStorage.setItem(clave, JSON.stringify(datos))
} catch {
// almacenamiento lleno o modo privado
}
}, [clave, estado, persistir])
return [estado, dispatch]
}jsx
import { useMemo } from 'react'
import { useLocalStorageReducer } from './useLocalStorageReducer'
import {
tareasReducer,
estadoInicial,
seleccionarVisibles,
seleccionarEstadisticas
} from '../state/tareasReducer'
// Fuera del componente para que sea siempre la misma función
const persistir = ({ tareas, filtro }) => ({ tareas, filtro })
export function useTareas() {
const [estado, dispatch] = useLocalStorageReducer('taskflow', tareasReducer, estadoInicial, { persistir })
const visibles = useMemo(() => seleccionarVisibles(estado), [estado])
const stats = useMemo(() => seleccionarEstadisticas(estado), [estado])
// Acciones con nombres claros para los componentes
const acciones = useMemo(
() => ({
agregar: (texto, prioridad) => dispatch({ type: 'agregar', texto, prioridad }),
alternar: (id) => dispatch({ type: 'alternar', id }),
editar: (id, texto) => dispatch({ type: 'editar', id, texto }),
eliminar: (id) => dispatch({ type: 'eliminar', id }),
limpiarCompletadas: () => dispatch({ type: 'limpiarCompletadas' }),
filtrar: (filtro) => dispatch({ type: 'filtrar', filtro }),
buscar: (busqueda) => dispatch({ type: 'buscar', busqueda })
}),
[dispatch]
)
return { ...estado, visibles, stats, ...acciones }
}Paso 3: componentes
jsx
import { useState } from 'react'
import { PRIORIDADES } from '../state/tareasReducer'
export default function FormTarea({ onAgregar }) {
const [texto, setTexto] = useState('')
const [prioridad, setPrioridad] = useState('media')
function enviar(e) {
e.preventDefault()
if (!texto.trim()) return
onAgregar(texto, prioridad)
setTexto('')
}
return (
<form className="form-tarea" onSubmit={enviar}>
<label htmlFor="nueva" className="sr-only">Nueva tarea</label>
<input
id="nueva"
value={texto}
onChange={(e) => setTexto(e.target.value)}
placeholder="¿Qué necesitas hacer?"
maxLength={120}
autoComplete="off"
/>
<select value={prioridad} onChange={(e) => setPrioridad(e.target.value)} aria-label="Prioridad">
{PRIORIDADES.map((p) => (
<option key={p} value={p}>{p[0].toUpperCase() + p.slice(1)}</option>
))}
</select>
<button type="submit" disabled={!texto.trim()}>Agregar</button>
</form>
)
}jsx
const FILTROS = [
{ valor: 'todas', texto: 'Todas' },
{ valor: 'pendientes', texto: 'Pendientes' },
{ valor: 'completadas', texto: 'Completadas' }
]
export default function Filtros({ filtro, busqueda, onFiltrar, onBuscar }) {
return (
<div className="filtros">
<div role="group" aria-label="Filtrar tareas">
{FILTROS.map((f) => (
<button
key={f.valor}
className={filtro === f.valor ? 'activo' : ''}
aria-pressed={filtro === f.valor}
onClick={() => onFiltrar(f.valor)}
>
{f.texto}
</button>
))}
</div>
<input
type="search"
value={busqueda}
onChange={(e) => onBuscar(e.target.value)}
placeholder="🔍 Buscar"
aria-label="Buscar tareas"
/>
</div>
)
}jsx
import { memo, useRef, useState, useEffect } from 'react'
function ItemTarea({ tarea, onAlternar, onEditar, onEliminar }) {
const [editando, setEditando] = useState(false)
const [borrador, setBorrador] = useState(tarea.texto)
const inputRef = useRef(null)
useEffect(() => {
if (editando) inputRef.current?.select()
}, [editando])
function guardar() {
if (borrador.trim() && borrador !== tarea.texto) onEditar(tarea.id, borrador)
else setBorrador(tarea.texto)
setEditando(false)
}
function manejarTecla(e) {
if (e.key === 'Enter') guardar()
if (e.key === 'Escape') {
setBorrador(tarea.texto)
setEditando(false)
}
}
return (
<li className={`item ${tarea.completada ? 'item--hecha' : ''}`}>
<input
type="checkbox"
checked={tarea.completada}
onChange={() => onAlternar(tarea.id)}
aria-label={`Marcar "${tarea.texto}" como ${tarea.completada ? 'pendiente' : 'completada'}`}
/>
{editando ? (
<input
ref={inputRef}
className="item__editar"
value={borrador}
onChange={(e) => setBorrador(e.target.value)}
onBlur={guardar}
onKeyDown={manejarTecla}
aria-label="Editar tarea"
/>
) : (
<span className="item__texto" onDoubleClick={() => setEditando(true)} title="Doble clic para editar">
{tarea.texto}
</span>
)}
<span className={`prioridad prioridad--${tarea.prioridad}`}>{tarea.prioridad}</span>
<button className="item__eliminar" onClick={() => onEliminar(tarea.id)} aria-label={`Eliminar "${tarea.texto}"`}>
✕
</button>
</li>
)
}
export default memo(ItemTarea)jsx
import ItemTarea from './ItemTarea'
const MENSAJES_VACIO = {
todas: '¡No hay tareas! Agrega la primera arriba ✨',
pendientes: '¡Todo listo! No tienes pendientes 🎉',
completadas: 'Aún no has completado tareas.'
}
export default function ListaTareas({ tareas, filtro, busqueda, ...acciones }) {
if (tareas.length === 0) {
return (
<p className="vacio">
{busqueda ? `Sin resultados para "${busqueda}".` : MENSAJES_VACIO[filtro]}
</p>
)
}
return (
<ul className="lista">
{tareas.map((t) => (
<ItemTarea
key={t.id}
tarea={t}
onAlternar={acciones.onAlternar}
onEditar={acciones.onEditar}
onEliminar={acciones.onEliminar}
/>
))}
</ul>
)
}jsx
export default function Estadisticas({ stats }) {
return (
<div className="stats">
<div className="stats__fila">
<span>{stats.completadas}/{stats.total} completadas</span>
<strong>{stats.porcentaje}%</strong>
</div>
<div
className="barra"
role="progressbar"
aria-valuenow={stats.porcentaje}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Progreso"
>
<div className="barra__relleno" style={{ width: `${stats.porcentaje}%` }} />
</div>
</div>
)
}Paso 4: la app
jsx
import { useTareas } from './hooks/useTareas'
import FormTarea from './components/FormTarea'
import Filtros from './components/Filtros'
import ListaTareas from './components/ListaTareas'
import Estadisticas from './components/Estadisticas'
import './App.css'
export default function App() {
const t = useTareas()
return (
<main className="app">
<header className="app__header">
<h1>TaskFlow</h1>
<Estadisticas stats={t.stats} />
</header>
<FormTarea onAgregar={t.agregar} />
<Filtros filtro={t.filtro} busqueda={t.busqueda} onFiltrar={t.filtrar} onBuscar={t.buscar} />
<ListaTareas
tareas={t.visibles}
filtro={t.filtro}
busqueda={t.busqueda}
onAlternar={t.alternar}
onEditar={t.editar}
onEliminar={t.eliminar}
/>
{t.stats.total > 0 && (
<footer className="app__footer">
<span>{t.stats.pendientes} pendiente{t.stats.pendientes === 1 ? '' : 's'}</span>
<button onClick={t.limpiarCompletadas} disabled={t.stats.completadas === 0}>
Borrar completadas
</button>
</footer>
)}
</main>
)
}css
:root {
--primario: #6d28d9;
--fondo: #f5f3ff;
--tarjeta: #ffffff;
--texto: #1f2937;
--suave: #6b7280;
--borde: #e5e7eb;
font-family: system-ui, -apple-system, sans-serif;
color: var(--texto);
background: var(--fondo);
}
@media (prefers-color-scheme: dark) {
:root { --fondo: #0f0a1e; --tarjeta: #1a1330; --texto: #ede9fe; --suave: #a1a1aa; --borde: #2e2550; }
}
* { box-sizing: border-box; }
body { margin: 0; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.app { max-width: 640px; margin: 2rem auto; padding: 1.5rem; background: var(--tarjeta); border-radius: 16px; box-shadow: 0 10px 40px rgb(0 0 0 / 0.08); }
.app__header h1 { margin: 0 0 0.5rem; color: var(--primario); }
.stats__fila { display: flex; justify-content: space-between; font-size: 0.9rem; color: var(--suave); }
.barra { height: 8px; background: var(--borde); border-radius: 99px; overflow: hidden; margin-top: 6px; }
.barra__relleno { height: 100%; background: var(--primario); transition: width 0.4s ease; }
.form-tarea { display: flex; gap: 8px; margin: 1.25rem 0; }
.form-tarea input { flex: 1; }
input, select, button { font: inherit; padding: 0.55rem 0.8rem; border: 1px solid var(--borde); border-radius: 8px; background: var(--tarjeta); color: var(--texto); }
button { cursor: pointer; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.form-tarea button { background: var(--primario); color: white; border: 0; }
.filtros { display: flex; justify-content: space-between; gap: 8px; flex-wrap: wrap; margin-bottom: 1rem; }
.filtros [role='group'] { display: flex; gap: 4px; }
.filtros .activo { background: var(--primario); color: white; border-color: var(--primario); }
.lista { list-style: none; padding: 0; margin: 0; }
.item { display: flex; align-items: center; gap: 10px; padding: 10px 4px; border-bottom: 1px solid var(--borde); }
.item__texto { flex: 1; cursor: text; word-break: break-word; }
.item__editar { flex: 1; }
.item--hecha .item__texto { text-decoration: line-through; color: var(--suave); }
.item__eliminar { border: 0; background: none; color: var(--suave); opacity: 0; transition: opacity 0.2s; }
.item:hover .item__eliminar, .item__eliminar:focus-visible { opacity: 1; }
@media (hover: none) { .item__eliminar { opacity: 1; } }
.prioridad { font-size: 0.7rem; text-transform: uppercase; font-weight: 700; padding: 2px 8px; border-radius: 99px; }
.prioridad--alta { background: #fee2e2; color: #b91c1c; }
.prioridad--media { background: #fef3c7; color: #a16207; }
.prioridad--baja { background: #dcfce7; color: #15803d; }
.vacio { text-align: center; color: var(--suave); padding: 2rem 0; }
.app__footer { display: flex; justify-content: space-between; align-items: center; margin-top: 1rem; color: var(--suave); font-size: 0.9rem; }
@media (max-width: 480px) {
.form-tarea { flex-wrap: wrap; }
.form-tarea input { flex-basis: 100%; }
}Paso 5: tests
Configura Vitest como en la lección de testing y prueba el reducer (lógica pura) y el flujo principal:
jsx
import { tareasReducer, estadoInicial, seleccionarVisibles, seleccionarEstadisticas } from './tareasReducer'
function conTareas(...textos) {
return textos.reduce((e, texto) => tareasReducer(e, { type: 'agregar', texto }), estadoInicial)
}
describe('tareasReducer', () => {
it('agrega tareas al inicio e ignora textos vacíos', () => {
let e = conTareas('Uno', 'Dos')
e = tareasReducer(e, { type: 'agregar', texto: ' ' })
expect(e.tareas.map((t) => t.texto)).toEqual(['Dos', 'Uno'])
})
it('alterna, edita y elimina', () => {
let e = conTareas('Original')
const id = e.tareas[0].id
e = tareasReducer(e, { type: 'alternar', id })
e = tareasReducer(e, { type: 'editar', id, texto: 'Editada' })
expect(e.tareas[0]).toMatchObject({ texto: 'Editada', completada: true })
e = tareasReducer(e, { type: 'eliminar', id })
expect(e.tareas).toHaveLength(0)
})
it('filtra, busca y calcula estadísticas', () => {
let e = conTareas('Comprar café', 'Llamar cliente', 'Comprar dominio')
e = tareasReducer(e, { type: 'alternar', id: e.tareas[0].id }) // completa "Comprar dominio"
expect(seleccionarVisibles({ ...e, filtro: 'pendientes' })).toHaveLength(2)
expect(seleccionarVisibles({ ...e, busqueda: 'comprar' })).toHaveLength(2)
expect(seleccionarEstadisticas(e)).toEqual({ total: 3, completadas: 1, pendientes: 2, porcentaje: 33 })
})
})jsx
import { render, screen } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import App from './App'
beforeEach(() => localStorage.clear())
it('flujo completo: agregar, completar y limpiar', async () => {
const user = userEvent.setup()
render(<App />)
await user.type(screen.getByPlaceholderText('¿Qué necesitas hacer?'), 'Enviar factura{Enter}')
await user.type(screen.getByPlaceholderText('¿Qué necesitas hacer?'), 'Revisar correo{Enter}')
expect(screen.getAllByRole('listitem')).toHaveLength(2)
await user.click(screen.getByRole('checkbox', { name: /Enviar factura/ }))
expect(screen.getByText('1/2 completadas')).toBeInTheDocument()
await user.click(screen.getByRole('button', { name: 'Borrar completadas' }))
expect(screen.queryByText('Enviar factura')).not.toBeInTheDocument()
expect(screen.getByText('Revisar correo')).toBeInTheDocument()
})bash
npx vitestPaso 6: desplegar
bash
npm run buildSube dist/ a Netlify o Vercel siguiendo la lección de despliegue. Como esta app no usa rutas, no necesitas configuración extra.
Retos para mejorarla
- Agrega una fecha límite a cada tarea y resalta en rojo las vencidas.
- Permite reordenar tareas arrastrándolas (librería
@dnd-kit/core). - Agrega categorías/proyectos con una barra lateral.
- Agrega un botón Deshacer tras eliminar una tarea (guarda la última eliminada durante 5 segundos).
Ver solución del reto 4
jsx
// En tareasReducer agrega:
case 'eliminar': {
const eliminada = estado.tareas.find((t) => t.id === accion.id)
return { ...estado, tareas: estado.tareas.filter((t) => t.id !== accion.id), ultimaEliminada: eliminada }
}
case 'deshacer':
if (!estado.ultimaEliminada) return estado
return { ...estado, tareas: [estado.ultimaEliminada, ...estado.tareas], ultimaEliminada: null }
case 'olvidarEliminada':
return { ...estado, ultimaEliminada: null }
// En useTareas, agrega a las acciones:
// deshacer: () => dispatch({ type: 'deshacer' }),
// olvidarEliminada: () => dispatch({ type: 'olvidarEliminada' })
// Componente Toast:
import { useEffect } from 'react'
export function ToastDeshacer({ tarea, onDeshacer, onCerrar }) {
useEffect(() => {
if (!tarea) return
const id = setTimeout(onCerrar, 5000)
return () => clearTimeout(id)
}, [tarea, onCerrar])
if (!tarea) return null
return (
<div role="status" className="toast">
Eliminaste "{tarea.texto}" <button onClick={onDeshacer}>Deshacer</button>
</div>
)
}
// En App:
// <ToastDeshacer tarea={t.ultimaEliminada} onDeshacer={t.deshacer} onCerrar={t.olvidarEliminada} />Como persistir sólo guarda tareas y filtro, la tarea eliminada no se guarda en localStorage.
Resumen
useReducerconcentra la lógica en una función pura, fácil de probar.- Un custom hook (
useTareas) expone estado, selectores y acciones con nombres claros. localStoragese sincroniza con un efecto; selectores derivan las listas visibles y las estadísticas.- Componentes pequeños, accesibles y probados: un proyecto listo para tu portafolio.