Skip to content

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 dev

Estructura ​

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.jsx

Paso 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 vitest

Paso 6: desplegar ​

bash
npm run build

Sube 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 ​

  1. Agrega una fecha límite a cada tarea y resalta en rojo las vencidas.
  2. Permite reordenar tareas arrastrándolas (librería @dnd-kit/core).
  3. Agrega categorías/proyectos con una barra lateral.
  4. 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 ​

  • useReducer concentra la lógica en una función pura, fácil de probar.
  • Un custom hook (useTareas) expone estado, selectores y acciones con nombres claros.
  • localStorage se 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.

Academia Web — aprende, copia y construye.