Queda una forma de resolver problemas con funciones que todavía no has usado a propósito, y es la más desconcertante de todas: una función que se llama a sí misma. Ya te la has cruzado dos veces —la expresión de función con nombre de 03-02 existía en parte para esto, y el RangeError: Maximum call stack size exceeded de 03-05 apareció por una llamada que nunca paraba—. En esta lección le pondrás método. Y no es un ejercicio académico: Nómada Tareas tiene un problema que los bucles resuelven mal y la recursión resuelve de forma natural, porque Marta quiere que una tarea pueda descomponerse en subtareas, y esas subtareas en otras, sin límite de profundidad.

Contenido

  1. Qué es una función recursiva
  2. Caso base y caso recursivo
  3. La pila de llamadas de una recursión
  4. Calentamiento 1: factorial
  5. Calentamiento 2: Fibonacci y su coste oculto
  6. El caso real: subtareas anidadas
  7. Recorrer el árbol: sumar horas
  8. Recorrer el árbol: aplanar, contar y buscar
  9. Recursión frente a iteración
  10. Recursión de cola y por qué JavaScript no la optimiza
  11. Memoización aplicada a Fibonacci
  12. Cuándo NO usar recursión
  13. Errores Comunes y Consejos
  14. Ejercicios
  15. Conclusión

  1. Qué es una función recursiva

Una función recursiva es aquella que, dentro de su cuerpo, se llama a sí misma con un problema más pequeño, hasta llegar a un caso tan simple que puede resolverse directamente.

function cuentaAtras(n) {
  if (n <= 0) {                      // caso base: se resuelve sin recursión
    console.log('¡Listo!');
    return;
  }
  console.log(`Quedan ${n} tareas por revisar…`);
  cuentaAtras(n - 1);                // caso recursivo: el mismo problema, más pequeño
}

cuentaAtras(3);
// Quedan 3 tareas por revisar…
// Quedan 2 tareas por revisar…
// Quedan 1 tareas por revisar…
// ¡Listo!

La idea que hay detrás es una forma de pensar, no un truco de sintaxis:

Para resolver un problema grande, supón que ya sabes resolver el mismo problema un poco más pequeño, y combina ese resultado con lo que te toca hacer aquí.

Esto se llama salto de fe recursivo, y es la parte que más cuesta. Cuando escribes cuentaAtras(n - 1) no tienes que imaginar todas las llamadas encadenadas: solo tienes que confiar en que esa llamada hace bien su trabajo.

  1. Caso base y caso recursivo

Toda función recursiva correcta tiene exactamente dos partes:

Parte Qué hace Qué pasa si falta o está mal
Caso base Resuelve el problema mínimo sin volver a llamarse Recursión infinita → RangeError
Caso recursivo Se llama a sí misma con un problema más pequeño La función nunca progresa o no resuelve nada

Y dos condiciones que hay que verificar siempre:

  1. El caso base se alcanza. No basta con que exista: el argumento debe acercarse a él en cada llamada.
  2. El problema se reduce de verdad. Llamarse con el mismo argumento, o con uno mayor, es un bucle infinito con más pasos.
// ✗ Caso base inalcanzable con decimales
function malaCuenta(n) {
  if (n === 0) return 'fin';
  return malaCuenta(n - 1);
}
// malaCuenta(3.5) → 2.5, 1.5, 0.5, -0.5, -1.5… nunca es exactamente 0

// ✓ Caso base robusto
function buenaCuenta(n) {
  if (n <= 0) return 'fin';
  return buenaCuenta(n - 1);
}
flowchart TD
    A["Llamada con n"] --> B{"¿Caso base?"}
    B -->|sí| C["Devolver el resultado directo"]
    B -->|no| D["Hacer la parte que toca aquí"]
    D --> E["Llamarse con un problema menor"]
    E --> F["Combinar el resultado y devolver"]

  1. La pila de llamadas de una recursión

Aquí se retoma la pila de Hoisting y el Contexto de Ejecución. Cada llamada recursiva apila un contexto nuevo que no se libera hasta que la llamada interior termina.

function sumarHasta(n) {
  if (n <= 0) return 0;
  return n + sumarHasta(n - 1);
}

console.log(sumarHasta(4));   // 10
sequenceDiagram
    participant G as global
    participant A as sumarHasta(4)
    participant B as sumarHasta(3)
    participant C as sumarHasta(2)
    participant D as sumarHasta(1)
    participant E as sumarHasta(0)

    G->>A: llamada
    A->>B: 4 + ?
    B->>C: 3 + ?
    C->>D: 2 + ?
    D->>E: 1 + ?
    E-->>D: 0 (caso base)
    D-->>C: 1 + 0 = 1
    C-->>B: 2 + 1 = 3
    B-->>A: 3 + 3 = 6
    A-->>G: 4 + 6 = 10

Dos observaciones esenciales:

  • La fase de "bajada" apila llamadas sin calcular nada definitivo: cada una queda esperando el resultado de la siguiente.
  • La fase de "subida" es donde se producen los cálculos, de dentro hacia fuera.

Por eso una recursión de profundidad 50 000 revienta la pila, como comprobaste en el ejercicio final de 03-05: las 50 000 sumas están todas pendientes a la vez.

  1. Calentamiento 1: factorial

El factorial de n es el producto de todos los enteros de 1 a n. Su definición matemática ya es recursiva: n! = n × (n-1)!, con 0! = 1.

function factorial(n) {
  if (n < 0) throw new RangeError('El factorial no está definido para negativos.');
  if (n <= 1) return 1;              // caso base: 0! = 1 y 1! = 1
  return n * factorial(n - 1);       // caso recursivo
}

console.log(factorial(0));   // 1
console.log(factorial(5));   // 120
console.log(factorial(6));   // 720

Traza de factorial(5):

Llamada Devuelve Resultado
factorial(5) 5 * factorial(4) 5 * 24 = 120
factorial(4) 4 * factorial(3) 4 * 6 = 24
factorial(3) 3 * factorial(2) 3 * 2 = 6
factorial(2) 2 * factorial(1) 2 * 1 = 2
factorial(1) 1 (caso base) 1

Fíjate en el throw para el caso negativo: es el fallo temprano de Manejo de Errores. Sin él, factorial(-1) bajaría hasta -Infinity apilando contextos y acabaría en RangeError, un error que no explica nada.

  1. Calentamiento 2: Fibonacci y su coste oculto

La sucesión de Fibonacci empieza 0, 1, 1, 2, 3, 5, 8, 13…, donde cada término es la suma de los dos anteriores.

function fibonacci(n) {
  if (n < 0) throw new RangeError('n debe ser 0 o mayor.');
  if (n === 0) return 0;             // caso base 1
  if (n === 1) return 1;             // caso base 2
  return fibonacci(n - 1) + fibonacci(n - 2);   // DOS llamadas recursivas
}

console.log(fibonacci(10));   // 55

Elegante y correcta. Y desastrosa en rendimiento, porque cada llamada genera dos, y muchas repiten trabajo ya hecho:

flowchart TD
    A["fib(5)"] --> B["fib(4)"]
    A --> C["fib(3) ①"]
    B --> D["fib(3) ②"]
    B --> E["fib(2) ①"]
    D --> F["fib(2) ②"]
    D --> G["fib(1)"]
    C --> H["fib(2) ③"]
    C --> I["fib(1)"]

fib(3) se calcula dos veces y fib(2) tres veces, y con n mayor la duplicación explota. El número de llamadas crece exponencialmente:

let llamadas = 0;
function fibonacciContado(n) {
  llamadas++;
  if (n <= 1) return n;
  return fibonacciContado(n - 1) + fibonacciContado(n - 2);
}

llamadas = 0; fibonacciContado(10); console.log(llamadas);   // 177
llamadas = 0; fibonacciContado(20); console.log(llamadas);   // 21891
llamadas = 0; fibonacciContado(30); console.log(llamadas);   // 2692537
llamadas = 0; fibonacciContado(35); console.log(llamadas);   // 29860703

De 10 a 35, las llamadas pasan de 177 a casi 30 millones. En el apartado 11 lo arreglarás con memoización, y la mejora será espectacular. Guarda el dato de que fibonacci(35) tarda varios segundos: lo compararás.

  1. El caso real: subtareas anidadas

Hasta ahora Nómada Tareas ha trabajado con una lista plana. Pero Marta ha pedido algo razonable: que una tarea grande pueda descomponerse en subtareas, y que esas subtareas puedan descomponerse a su vez. Así queda la tarea 1 del backlog desglosada:

flowchart TD
    A["1 · Rediseñar la sala polivalente"] --> B["11 · Medir y levantar el plano<br/>3 h"]
    A --> C["12 · Elegir el mobiliario"]
    A --> D["13 · Pintar y montar<br/>5 h"]
    C --> E["121 · Pedir presupuestos<br/>2 h"]
    C --> F["122 · Visitar a dos proveedores<br/>2 h"]

Esa estructura es un árbol, y en JavaScript se representa con objetos que contienen arrays de objetos:

'use strict';

// Nota: aquí no hay más remedio que usar objetos anidados. Los objetos se estudian
// a fondo en el Módulo 4; por ahora basta con leer propiedades con el punto.
const tareaRediseno = {
  id: 1,
  titulo: 'Rediseñar la sala polivalente',
  responsable: 'Iván',
  estado: 'en-curso',
  horasEstimadas: 0,          // 0 = las horas están en las subtareas
  subtareas: [
    {
      id: 11, titulo: 'Medir y levantar el plano', responsable: 'Iván',
      estado: 'hecha', horasEstimadas: 3, subtareas: []
    },
    {
      id: 12, titulo: 'Elegir el mobiliario', responsable: 'Marta',
      estado: 'en-curso', horasEstimadas: 0,
      subtareas: [
        { id: 121, titulo: 'Pedir presupuestos', responsable: 'Marta',
          estado: 'hecha', horasEstimadas: 2, subtareas: [] },
        { id: 122, titulo: 'Visitar a dos proveedores', responsable: 'Marta',
          estado: 'pendiente', horasEstimadas: 2, subtareas: [] }
      ]
    },
    {
      id: 13, titulo: 'Pintar y montar', responsable: 'Iván',
      estado: 'pendiente', horasEstimadas: 5, subtareas: []
    }
  ]
};

¿Por qué un bucle no basta aquí? Porque no sabes cuántos niveles hay. Con un for recorres el primer nivel; con dos anidados, el segundo; con tres, el tercero. Pero si mañana Iván añade una sub-sub-subtarea, el código deja de funcionar. La recursión, en cambio, no necesita saberlo: cada nivel se trata exactamente igual que el anterior.

Esta es la señal que delata un problema recursivo: la estructura del dato se contiene a sí misma. Una tarea contiene tareas, una carpeta contiene carpetas, un comentario contiene respuestas que son comentarios.

  1. Recorrer el árbol: sumar horas

El primer cálculo que necesita Marta: cuántas horas suma una tarea contando todas sus subtareas, a cualquier profundidad.

/**
 * Suma las horas de una tarea y de todas sus subtareas, recursivamente.
 * @param {Object} tarea  nodo con horasEstimadas y subtareas
 * @returns {number} total de horas del subárbol
 */
function sumarHorasTotales(tarea) {
  let total = tarea.horasEstimadas;                 // lo que toca en este nodo

  for (const subtarea of tarea.subtareas) {         // caso recursivo
    total += sumarHorasTotales(subtarea);
  }

  return total;                                     // caso base implícito: sin subtareas, el bucle no entra
}

console.log(sumarHorasTotales(tareaRediseno));   // 12

Merece la pena analizar por qué funciona:

  • El caso base está implícito. Si subtareas es [], el for no da ninguna vuelta y la función devuelve solo tarea.horasEstimadas. No hace falta un if explícito, aunque escribirlo tampoco estaría mal.
  • Cada nodo hace lo mismo: aporta sus horas y pide a sus hijos que aporten las suyas.
  • El resultado, 12 h, coincide con las horas que la tarea 1 tenía en el backlog plano del Módulo 2 (3 + 2 + 2 + 5 = 12). La descomposición no ha cambiado el total.

Y una variante que filtra: horas abiertas (no terminadas) del subárbol.

function sumarHorasAbiertas(tarea) {
  let total = tarea.estado !== 'hecha' ? tarea.horasEstimadas : 0;

  for (const subtarea of tarea.subtareas) {
    total += sumarHorasAbiertas(subtarea);
  }

  return total;
}

console.log(sumarHorasAbiertas(tareaRediseno));   // 7

Comprobación manual: quedan abiertas «Visitar a dos proveedores» (2 h) y «Pintar y montar» (5 h). Total, 7 h. Las de «Medir y levantar el plano» (3 h) y «Pedir presupuestos» (2 h) están hechas.

  1. Recorrer el árbol: aplanar, contar y buscar

Con el mismo esquema se resuelven todas las operaciones sobre el árbol. Cambia solo qué se hace en cada nodo.

8.1 Aplanar el árbol a una lista

/**
 * Devuelve un array plano con todas las tareas del subárbol,
 * añadiendo a cada una su nivel de profundidad.
 */
function aplanarTareas(tarea, nivel = 0) {
  const resultado = [{ id: tarea.id, titulo: tarea.titulo, nivel: nivel,
                       horas: tarea.horasEstimadas, estado: tarea.estado }];

  for (const subtarea of tarea.subtareas) {
    const hijas = aplanarTareas(subtarea, nivel + 1);
    for (const h of hijas) resultado.push(h);
  }

  return resultado;
}

const planas = aplanarTareas(tareaRediseno);

for (const t of planas) {
  const sangria = '  '.repeat(t.nivel);
  const marca = t.estado === 'hecha' ? '✓' : '○';
  console.log(`${sangria}${marca} [${t.id}] ${t.titulo}${t.horas > 0 ? ` — ${t.horas} h` : ''}`);
}

Salida:

○ [1] Rediseñar la sala polivalente
  ✓ [11] Medir y levantar el plano — 3 h
  ○ [12] Elegir el mobiliario
    ✓ [121] Pedir presupuestos — 2 h
    ○ [122] Visitar a dos proveedores — 2 h
  ○ [13] Pintar y montar — 5 h

El parámetro nivel = 0 con valor por defecto (03-03) es un patrón habitual en recursión: quien llama desde fuera lo omite, y la propia función lo va incrementando hacia dentro.

8.2 Contar tareas y medir la profundidad

function contarTareas(tarea) {
  let total = 1;                                   // ella misma
  for (const sub of tarea.subtareas) total += contarTareas(sub);
  return total;
}

function profundidadMaxima(tarea) {
  if (tarea.subtareas.length === 0) return 1;      // caso base explícito

  let maxima = 0;
  for (const sub of tarea.subtareas) {
    const p = profundidadMaxima(sub);
    if (p > maxima) maxima = p;
  }
  return 1 + maxima;
}

console.log(contarTareas(tareaRediseno));        // 6
console.log(profundidadMaxima(tareaRediseno));   // 3

8.3 Buscar una tarea por identificador

/**
 * Busca una tarea por id en todo el subárbol.
 * @returns {Object|null} la tarea encontrada, o null
 */
function buscarPorId(tarea, id) {
  if (tarea.id === id) return tarea;               // caso base 1: encontrada

  for (const sub of tarea.subtareas) {
    const encontrada = buscarPorId(sub, id);
    if (encontrada !== null) return encontrada;    // corta en cuanto la halla
  }

  return null;                                     // caso base 2: no está en esta rama
}

const t = buscarPorId(tareaRediseno, 122);
console.log(t.titulo);                             // Visitar a dos proveedores
console.log(buscarPorId(tareaRediseno, 999));      // null

El if (encontrada !== null) return encontrada; es importante: sin él, la función seguiría explorando ramas inútiles después de haber encontrado el resultado. Es el equivalente recursivo del break de break, continue y Bucles Anidados.

  1. Recursión frente a iteración

Todo lo que se puede hacer con recursión se puede hacer con bucles, y al revés. La pregunta es cuál conviene en cada caso.

Criterio Recursión Iteración (bucles)
Legibilidad con datos anidados Muy alta: el código imita la estructura Baja: hay que gestionar una pila a mano
Legibilidad con datos planos Peor: añade ruido Alta
Memoria Una entrada de pila por llamada Constante
Riesgo de desbordamiento Real a partir de ~10 000 niveles Ninguno
Velocidad Algo menor (coste de cada llamada) Algo mayor
Depuración Más difícil: la pila se llena de marcos iguales Sencilla
Estado Implícito, en los parámetros Explícito, en variables

Compara las dos versiones de la misma tarea, aplanar el árbol. La recursiva ya la has visto. La iterativa necesita gestionar su propia pila:

function aplanarIterativo(raiz) {
  const resultado = [];
  const pendientes = [{ tarea: raiz, nivel: 0 }];   // pila explícita

  while (pendientes.length > 0) {
    const actual = pendientes.pop();

    resultado.push({ id: actual.tarea.id, titulo: actual.tarea.titulo, nivel: actual.nivel });

    // Se apilan en orden inverso para que salgan en el orden natural
    for (let i = actual.tarea.subtareas.length - 1; i >= 0; i--) {
      pendientes.push({ tarea: actual.tarea.subtareas[i], nivel: actual.nivel + 1 });
    }
  }

  return resultado;
}

console.log(aplanarIterativo(tareaRediseno).length);   // 6

Funciona, no puede desbordar la pila del motor y es más rápida. Pero fíjate en lo que ha costado: una pila manual, un bucle inverso poco evidente y un objeto auxiliar para llevar el nivel. Esa es la elección real: claridad frente a control.

Regla práctica para el día a día:

  • Estructura anidada de profundidad moderada (árboles de menús, comentarios, subtareas, JSON) → recursión.
  • Lista plana, o profundidad potencialmente enorme (miles de niveles, ficheros grandes) → iteración.

  1. Recursión de cola y por qué JavaScript no la optimiza

Se dice que una recursión es de cola (tail recursion) cuando la llamada recursiva es lo último que hace la función: su resultado se devuelve tal cual, sin operaciones pendientes.

// NO es de cola: al volver, todavía hay que multiplicar por n
function factorial(n) {
  if (n <= 1) return 1;
  return n * factorial(n - 1);       // ← queda pendiente la multiplicación
}

// SÍ es de cola: la llamada se devuelve directamente
function factorialCola(n, acumulado = 1) {
  if (n <= 1) return acumulado;
  return factorialCola(n - 1, n * acumulado);   // ← nada pendiente
}

console.log(factorialCola(5));   // 120

En teoría, una recursión de cola no necesita apilar contextos: como no queda nada por hacer al volver, el motor podría reutilizar el marco actual. Eso se llama tail call optimization (TCO) y convertiría la recursión en un bucle, con memoria constante.

El problema es que, en la práctica, JavaScript no la aplica:

Motor / entorno ¿Optimiza llamadas de cola?
Especificación ES2015 Sí, la exige
V8 (Chrome, Edge, Node.js) No (implementada y retirada)
SpiderMonkey (Firefox) No
JavaScriptCore (Safari) Sí, en modo estricto

Compruébalo:

function contarCola(n) {
  if (n <= 0) return 'fin';
  return contarCola(n - 1);
}

try {
  console.log(contarCola(100000));
} catch (error) {
  console.error(`${error.name}: ${error.message}`);
}
// En Node.js: RangeError: Maximum call stack size exceeded

Conclusión práctica: escribir recursión de cola en JavaScript no te protege del desbordamiento. Es una buena costumbre estilística y funciona en otros lenguajes, pero aquí, si la profundidad puede ser grande, la solución es un bucle.

  1. Memoización aplicada a Fibonacci

Volvemos al Fibonacci exponencial del apartado 5. El problema era recalcular lo mismo una y otra vez, y ya tienes la herramienta para arreglarlo: la memoización con closure de Ámbito y Closures.

/**
 * Devuelve una versión memoizada de Fibonacci.
 * La caché vive en el closure: privada y persistente entre llamadas.
 */
function crearFibonacci() {
  const cache = {};          // estado privado
  let calculos = 0;
  let aciertos = 0;

  function fib(n) {
    if (n < 0) throw new RangeError('n debe ser 0 o mayor.');
    if (n <= 1) return n;

    if (n in cache) {
      aciertos++;
      return cache[n];
    }

    calculos++;
    const resultado = fib(n - 1) + fib(n - 2);
    cache[n] = resultado;
    return resultado;
  }

  fib.estadisticas = () => `${calculos} cálculos, ${aciertos} aciertos`;
  return fib;
}

const fibonacciRapido = crearFibonacci();

let t = Date.now();
console.log(fibonacciRapido(35), `${Date.now() - t} ms`);   // 9227465  ~0 ms
console.log(fibonacciRapido.estadisticas());                // 34 cálculos, 33 aciertos

t = Date.now();
console.log(fibonacciRapido(40), `${Date.now() - t} ms`);   // 102334155  ~0 ms
console.log(fibonacciRapido(90));                           // 2880067194370816000

La comparación es demoledora:

n Llamadas sin memoizar Cálculos con memoización Tiempo aproximado
10 177 9 ~0 ms en ambos
20 21 891 19 ~0 ms en ambos
30 2 692 537 29 ~30 ms → ~0 ms
35 29 860 703 34 ~300 ms → ~0 ms
40 ~331 000 000 39 varios segundos → ~0 ms
50 inviable 49 — → ~0 ms

Se pasa de crecimiento exponencial a crecimiento lineal. El coste es la memoria de la caché, que aquí es despreciable, y la restricción de siempre: memoizar solo funciones puras (03-03). fib(n) siempre da lo mismo para el mismo n, así que es seguro.

Dos avisos sobre el resultado de fibonacciRapido(90): ese número supera Number.MAX_SAFE_INTEGER y por tanto es aproximado. Para enteros grandes hay que usar BigInt, el tipo que conociste en Variables y Tipos de Datos. Y la profundidad sigue siendo lineal: fibonacciRapido(20000) desbordaría la pila aunque la caché funcione.

  1. Cuándo NO usar recursión

Hay tres situaciones donde la recursión es la respuesta equivocada:

Situación Por qué Alternativa
Recorrer una lista plana Un bucle es más claro, rápido y seguro for / for...of
Profundidad grande o desconocida sin límite RangeError en producción, con datos reales Iteración con pila explícita
Cálculo simple con acumulador La recursión añade ruido sin aportar nada Bucle con variable acumuladora
// ✗ Recursión innecesaria sobre una lista plana
function sumarHorasRec(horas, i = 0) {
  if (i >= horas.length) return 0;
  return horas[i] + sumarHorasRec(horas, i + 1);
}

// ✓ Bucle: más claro, sin riesgo de pila
function sumarHoras(horas) {
  let total = 0;
  for (const h of horas) total += h;
  return total;
}

console.log(sumarHoras([12, 6, 14, 3, 8, 5]));   // 48

Y una advertencia de seguridad que importa más de lo que parece: si los datos anidados vienen de fuera (una API, un fichero que sube el usuario), la profundidad la controla quien envía los datos. Un árbol malicioso de 100 000 niveles tumbaría tu función recursiva. En esos casos, o iteras, o pones un límite explícito:

function sumarHorasTotales(tarea, profundidad = 0) {
  if (profundidad > 50) {
    throw new RangeError('Estructura de subtareas demasiado profunda (máximo 50 niveles).');
  }

  let total = tarea.horasEstimadas;
  for (const sub of tarea.subtareas) {
    total += sumarHorasTotales(sub, profundidad + 1);
  }
  return total;
}

Errores Comunes y Consejos

1. Olvidar el caso base. Es el error número uno y siempre produce RangeError: Maximum call stack size exceeded.

2. Caso base inalcanzable. Usa <= en lugar de === cuando el argumento pueda saltárselo (decimales, restas de más de uno).

3. No reducir el problema.

function mal(tarea) {
  return sumarHorasTotales(tarea);   // ✗ mismo argumento, recursión infinita
}

4. Olvidar el return delante de la llamada recursiva.

function buscarMal(tarea, id) {
  if (tarea.id === id) return tarea;
  for (const sub of tarea.subtareas) {
    buscarMal(sub, id);      // ✗ el resultado se pierde
  }
  return null;               // siempre null
}

Es un fallo silencioso: no da error, simplemente nunca encuentra nada.

5. Confundir un for dentro de una recursión con un bucle anidado. El for recorre los hijos de este nodo; la recursión baja de nivel. Son ejes distintos.

6. Memoizar una función impura. Ya lo sabes desde 03-04: la caché devolvería resultados obsoletos.

7. Confiar en la recursión de cola. No está optimizada en la mayoría de motores.

8. Consejo: dibuja el árbol de llamadas para tres o cuatro niveles. Casi todos los errores de recursión se ven a simple vista en el dibujo y son invisibles leyendo el código.

9. Consejo: pon un console.log con sangría al depurar.

function sumarConTraza(tarea, nivel = 0) {
  const sangria = '  '.repeat(nivel);
  console.log(`${sangria}→ entra en ${tarea.titulo}`);

  let total = tarea.horasEstimadas;
  for (const sub of tarea.subtareas) total += sumarConTraza(sub, nivel + 1);

  console.log(`${sangria}← sale de ${tarea.titulo} con ${total} h`);
  return total;
}

Ver la bajada y la subida indentadas hace comprensible cualquier recursión. En Depuración de JavaScript verás cómo hacer lo mismo con puntos de interrupción y la pila en vivo.

Ejercicios

Ejercicio 1 — Operaciones sobre el árbol de subtareas

Sobre tareaRediseno, escribe tres funciones recursivas:

  • contarPorEstado(tarea, estado): cuántas tareas del subárbol están en ese estado.
  • titulosDeResponsable(tarea, nombre): array con los títulos de las tareas asignadas a esa persona, a cualquier profundidad.
  • estaCompleta(tarea): true si la tarea y todas sus subtareas están en estado 'hecha'.

Ejercicio 2 — Ruta hasta una tarea

Escribe rutaHasta(tarea, id) que devuelva un array con los títulos que van desde la raíz hasta la tarea con ese identificador, o null si no existe. Por ejemplo, para el id 121 debe devolver ['Rediseñar la sala polivalente', 'Elegir el mobiliario', 'Pedir presupuestos'].

Ejercicio 3 — De recursivo a iterativo

La función profundidadMaxima del apartado 8.2 es recursiva. Reescríbela de forma iterativa usando una pila explícita, comprueba que da el mismo resultado (3) y explica en qué caso concreto preferirías cada versión.

Soluciones

Ejercicio 1

function contarPorEstado(tarea, estado) {
  let total = tarea.estado === estado ? 1 : 0;
  for (const sub of tarea.subtareas) {
    total += contarPorEstado(sub, estado);
  }
  return total;
}

function titulosDeResponsable(tarea, nombre) {
  const encontrados = [];

  if (tarea.responsable === nombre) encontrados.push(tarea.titulo);

  for (const sub of tarea.subtareas) {
    const hijos = titulosDeResponsable(sub, nombre);
    for (const h of hijos) encontrados.push(h);
  }

  return encontrados;
}

function estaCompleta(tarea) {
  if (tarea.estado !== 'hecha') return false;        // guarda: ella misma no lo está

  for (const sub of tarea.subtareas) {
    if (!estaCompleta(sub)) return false;            // corta en la primera que falle
  }

  return true;
}

console.log(contarPorEstado(tareaRediseno, 'hecha'));        // 2
console.log(contarPorEstado(tareaRediseno, 'pendiente'));    // 2
console.log(contarPorEstado(tareaRediseno, 'en-curso'));     // 2

console.log(titulosDeResponsable(tareaRediseno, 'Marta'));
// [ 'Elegir el mobiliario', 'Pedir presupuestos', 'Visitar a dos proveedores' ]

console.log(estaCompleta(tareaRediseno));                    // false
console.log(estaCompleta(buscarPorId(tareaRediseno, 11)));   // true

Comentario: las tres siguen el mismo esquema —hacer algo con este nodo, después con cada hijo— y solo cambia la operación. estaCompleta incorpora además la salida temprana: en cuanto encuentra una subtarea incompleta deja de mirar el resto, igual que miEvery en Funciones de Orden Superior.

Ejercicio 2

function rutaHasta(tarea, id) {
  if (tarea.id === id) return [tarea.titulo];        // caso base: es esta

  for (const sub of tarea.subtareas) {
    const rutaHija = rutaHasta(sub, id);             // salto de fe
    if (rutaHija !== null) {
      return [tarea.titulo, ...rutaHija];            // añade este nivel delante
    }
  }

  return null;                                       // no está en esta rama
}

console.log(rutaHasta(tareaRediseno, 121));
// [ 'Rediseñar la sala polivalente', 'Elegir el mobiliario', 'Pedir presupuestos' ]

console.log(rutaHasta(tareaRediseno, 13));
// [ 'Rediseñar la sala polivalente', 'Pintar y montar' ]

console.log(rutaHasta(tareaRediseno, 999));   // null

// Y para mostrarla como una miga de pan:
const ruta = rutaHasta(tareaRediseno, 122);
console.log(ruta.join(' › '));
// Rediseñar la sala polivalente › Elegir el mobiliario › Visitar a dos proveedores

Comentario: la clave es la línea return [tarea.titulo, ...rutaHija];. El ... es el operador spread, que despliega los elementos de rutaHija dentro del array nuevo; lo estudiarás formalmente en Desestructuración de Objetos, Spread y Rest. Sin él, tendrías que construir el array con un bucle. Fíjate también en que la ruta se monta en la subida: cada nivel añade su título por delante de lo que le devuelve el nivel inferior.

Ejercicio 3

function profundidadMaximaIterativa(raiz) {
  let maxima = 0;
  const pendientes = [{ tarea: raiz, nivel: 1 }];

  while (pendientes.length > 0) {
    const actual = pendientes.pop();

    if (actual.nivel > maxima) maxima = actual.nivel;

    for (const sub of actual.tarea.subtareas) {
      pendientes.push({ tarea: sub, nivel: actual.nivel + 1 });
    }
  }

  return maxima;
}

console.log(profundidadMaxima(tareaRediseno));            // 3
console.log(profundidadMaximaIterativa(tareaRediseno));   // 3

Cuándo preferir cada una:

Versión Cuándo
Recursiva Árboles de subtareas creados por el equipo de Taller Nómada: profundidad de dos o tres niveles, y el código se lee como la definición del problema
Iterativa Datos importados de una API externa, donde la profundidad no está bajo tu control y un árbol muy profundo tumbaría la aplicación

Observa además una diferencia sutil: la recursiva calcula la profundidad en la subida (1 + máximo de los hijos), mientras que la iterativa la lleva en la bajada, guardando el nivel junto a cada nodo pendiente. Es el mismo cálculo visto del revés.

Conclusión

Con esta lección cierras el Módulo 3. Ya sabes que una función recursiva se llama a sí misma con un problema más pequeño, que necesita siempre un caso base alcanzable y un caso recursivo que reduzca de verdad el problema, y que cada llamada apila un contexto que solo se libera en la subida: por eso una recursión profunda produce el RangeError que conociste en 03-05. Has practicado con factorial y fibonacci, y has visto de primera mano cómo el Fibonacci ingenuo pasa de 177 llamadas con n = 10 a casi treinta millones con n = 35, y cómo la memoización con closure de 03-04 lo devuelve a treinta y cuatro cálculos.

Sobre todo, has visto por qué la recursión existe. Cuando Marta pidió descomponer una tarea en subtareas, y esas en otras, los bucles dejaron de servir: no se puede escribir un for anidado para una profundidad que no conoces. sumarHorasTotales, aplanarTareas, contarTareas, profundidadMaxima, buscarPorId y rutaHasta resuelven ese árbol con el mismo esquema de seis líneas, porque la estructura del código imita la estructura del dato. Y conoces sus límites: la tabla de recursión frente a iteración, la recursión de cola que JavaScript no optimiza, el límite de profundidad para datos que vienen de fuera y las tres situaciones en las que un bucle es sencillamente la respuesta correcta.

Mira lo que has ganado en estas siete lecciones. Empezaste con bloques de código copiados cuatro veces y terminas con pesoDePrioridad(), estaVencida(), describirTarea(), crearTarea(), resumirCarga(), crearGeneradorDeIds(), crearAlmacenDeTareas(), un motor de informes configurable y un recorrido recursivo de subtareas. Sabes definir y llamar funciones, escribirlas como valores y como flechas, diseñar sus parámetros y sus retornos, controlar dónde vive cada variable, encerrar estado en un closure, entender qué prepara el motor antes de ejecutar, pasar funciones a otras funciones y hacer que una función se llame a sí misma. Eso es, con diferencia, el salto más grande del curso hasta ahora.

Pero fíjate en el precio que has ido pagando sin quejarte. Todo este módulo ha trabajado con variables sueltas y arrays paralelos: titulos[i], responsables[i], prioridades[i], estados[i], horas[i], fechasLimite[i]. Has tenido que filtrar índices en vez de tareas, pasar ocho argumentos a describirTarea, arrastrar seis arrays a cada función y confiar en que ninguno se desalinee. Y en cuanto ha aparecido una estructura de verdad —el árbol de subtareas— no ha habido más remedio que usar objetos anidados, avisando de que eso venía después. Ese "después" es ahora. En el Módulo 4: Objetos y Arrays dejarás de simular el modelo de datos y lo escribirás de verdad: cada tarea será un objeto con sus nueve campos, el backlog será un array de objetos, y todas las funciones de este módulo se reescribirán con firmas limpias como describirTarea(tarea, hoy). Empieza con Introducción a los Objetos, y el primer alivio llegará en la primera página.

Curso de JavaScript: De Principiante a Avanzado

Módulo 1: Introducción a JavaScript

Módulo 2: Estructuras de Control

Módulo 3: Funciones

Módulo 4: Objetos y Arrays

Módulo 5: Objetos y Funciones Avanzadas

Módulo 6: El Modelo de Objetos del Documento (DOM)

Módulo 7: APIs del Navegador y Temas Avanzados

Módulo 8: Pruebas y Depuración

Módulo 9: Rendimiento y Optimización

Módulo 10: Frameworks y Librerías de JavaScript

Módulo 11: Proyecto Final

© Copyright 2026. Todos los derechos reservados