Ir al contenido principal

Estados de las promesas

Introducción

Las promesas en JavaScript son objetos que representan el resultado eventual de una operación asíncrona. Uno de los aspectos más importantes para comprender completamente su funcionamiento es entender los diferentes estados por los que puede pasar una promesa y cómo estos estados determinan su comportamiento. A diferencia de los callbacks tradicionales, las promesas proporcionan un mecanismo más estructurado para seguir el ciclo de vida de una operación asíncrona.

En este artículo, profundizaremos en los tres estados fundamentales de las promesas —pending, fulfilled y rejected—, cómo se realizan las transiciones entre estos estados, y exploraremos técnicas para crear y trabajar con promesas en diferentes estados. Este conocimiento nos permitirá aprovechar mejor el potencial de las promesas en nuestro código y evitar errores comunes.

Los tres estados fundamentales

Una promesa en JavaScript siempre se encuentra en uno de estos tres estados:

  1. Pending (Pendiente): Estado inicial de una promesa. La operación asíncrona aún no se ha completado.
  2. Fulfilled (Cumplida): La operación asíncrona se completó con éxito. La promesa tiene un valor resultante.
  3. Rejected (Rechazada): La operación asíncrona falló. La promesa tiene una razón de rechazo (generalmente un error).

Adicionalmente, se utiliza el término settled (resuelta) para referirse a una promesa que ya no está pendiente, es decir, que ha sido cumplida o rechazada.

Veamos un diagrama conceptual de estos estados:

         ┌─────────┐
         │ Pending │
         └────┬────┘
              │
              ▼
┌─────────────┴─────────────┐
│                           │
▼                           ▼
┌─────────────┐     ┌───────────────┐
│  Fulfilled  │     │    Rejected   │
└─────────────┘     └───────────────┘

Transiciones entre estados

Las transiciones entre estados de una promesa siguen estas reglas importantes:

  1. Una promesa comienza siempre en estado pending.
  2. Una promesa pendiente puede pasar a estado fulfilled (con un valor) o rejected (con una razón de rechazo).
  3. Una vez que una promesa está en estado fulfilled o rejected, su estado es inmutable y no puede cambiar a ningún otro estado.

Estas reglas garantizan que una promesa solo puede resolverse o rechazarse una vez, lo que es crucial para la consistencia de las operaciones asíncronas.

Visualización de estados mediante código

Visualicemos estos estados con un ejemplo práctico:

// Función que crea una promesa con un estado predecible
function crearPromesaAleatoria() {
    return new Promise((resolve, reject) => {
        console.log('La promesa está en estado: PENDING');
        
        // Simulamos una operación asíncrona
        setTimeout(() => {
            // Generamos un número aleatorio para determinar si la promesa se cumple o rechaza
            const exito = Math.random() > 0.5;
            
            if (exito) {
                console.log('La promesa pasa a estado: FULFILLED');
                resolve('Operación completada con éxito');
            } else {
                console.log('La promesa pasa a estado: REJECTED');
                reject(new Error('La operación ha fallado'));
            }
        }, 2000);
    });
}

// Creamos y utilizamos la promesa
console.log('Iniciando la promesa...');

const miPromesa = crearPromesaAleatoria();

miPromesa
    .then(valor => {
        console.log(`✅ Éxito: ${valor}`);
    })
    .catch(error => {
        console.log(`❌ Error: ${error.message}`);
    })
    .finally(() => {
        console.log('La promesa ahora está en estado: SETTLED (fulfilled o rejected)');
    });

console.log('Código después de crear la promesa (se ejecuta antes de que la promesa se resuelva)');

Este ejemplo muestra cómo una promesa pasa de estado pendiente a cumplida o rechazada, y cómo podemos manejar ambos casos con .then() y .catch().

Comprobación del estado de una promesa

JavaScript no proporciona un método directo para verificar el estado actual de una promesa. Sin embargo, podemos crear una función de utilidad para inferir su estado:

function obtenerEstadoPromesa(promesa) {
    const estadoTemp = {};
    
    // Creamos una nueva promesa que se resuelve inmediatamente
    return Promise.race([promesa, estadoTemp])
        .then(
            valor => valor === estadoTemp ? "pending" : "fulfilled",
            () => "rejected"
        );
}

// Ejemplos de uso
const promesaPendiente = new Promise(() => {}); // Nunca se resuelve
const promesaCumplida = Promise.resolve(42);
const promesaRechazada = Promise.reject(new Error("Error de ejemplo"));

// Manejamos la promesa rechazada para evitar error no capturado
promesaRechazada.catch(() => {});

// Verificamos los estados
async function verificarEstados() {
    console.log("Estado de promesaPendiente:", await obtenerEstadoPromesa(promesaPendiente));
    console.log("Estado de promesaCumplida:", await obtenerEstadoPromesa(promesaCumplida));
    console.log("Estado de promesaRechazada:", await obtenerEstadoPromesa(promesaRechazada));
}

verificarEstados();

Esta aproximación funciona, pero tiene limitaciones. En la práctica, rara vez necesitaremos verificar el estado de una promesa de esta manera, ya que el patrón habitual es manejar los resultados directamente con .then() y .catch().

Creación de promesas en diferentes estados

Podemos crear promesas que ya estén en un estado específico utilizando métodos estáticos de la clase Promise:

Promise.resolve() - Crear una promesa cumplida

// Creación de una promesa ya cumplida
const promesaCumplida = Promise.resolve('Valor inmediato');

console.log('Antes de .then()');

promesaCumplida.then(valor => {
    console.log(`La promesa ya estaba cumplida con el valor: ${valor}`);
});

console.log('Después de .then()');

En este ejemplo, aunque .then() se llamará de forma asíncrona, la promesa ya está en estado fulfilled cuando la creamos.

Promise.reject() - Crear una promesa rechazada

// Creación de una promesa ya rechazada
const promesaRechazada = Promise.reject(new Error('Error inmediato'));

console.log('Antes de .catch()');

// Es importante capturar el rechazo para evitar errores no manejados
promesaRechazada.catch(error => {
    console.log(`La promesa ya estaba rechazada con el error: ${error.message}`);
});

console.log('Después de .catch()');

Comportamiento con valores "thenable"

Un aspecto interesante de Promise.resolve() es que puede "desenvolver" objetos thenable (objetos que tienen un método .then()):

// Un objeto "thenable" (pero no una promesa real)
const objetoThenable = {
    then: function(resolve, reject) {
        setTimeout(() => resolve('Valor del thenable'), 1000);
    }
};

// Promise.resolve convierte el thenable a una promesa real
const promesaDeThenable = Promise.resolve(objetoThenable);

promesaDeThenable.then(valor => {
    console.log(`Resultado del thenable: ${valor}`);
});

Esto es útil para estandarizar el comportamiento cuando trabajamos con diferentes implementaciones de promesas o bibliotecas.

Captura del valor o razón de rechazo

Cuando una promesa cambia de estado, guarda el valor con el que se resolvió o la razón por la que se rechazó:

// Promesa que se cumple con un valor
const promesaConValor = Promise.resolve({ id: 1, nombre: 'Producto' });

promesaConValor.then(valor => {
    console.log('Valor almacenado en la promesa:', valor);
    console.log('Podemos acceder a sus propiedades:', valor.nombre);
});

// Promesa que se rechaza con un error
const promesaConError = Promise.reject(new Error('Fallo en la operación'));

promesaConError.catch(error => {
    console.log('Error almacenado en la promesa:', error);
    console.log('Mensaje de error:', error.message);
    console.log('Stack trace:', error.stack);
});

Es una buena práctica rechazar promesas con objetos Error en lugar de strings simples, ya que proporcionan información adicional como el stack trace.

Comportamiento de then() según el estado

El método .then() actúa de manera diferente dependiendo del estado actual de la promesa:

function demostrarComportamientoThen() {
    // Caso 1: Promesa ya cumplida
    console.log('--- Caso 1: Promesa ya cumplida ---');
    Promise.resolve('Valor A')
        .then(valor => {
            console.log(`Then con promesa cumplida: ${valor}`);
            return 'Valor B';
        })
        .then(valor => {
            console.log(`Then encadenado: ${valor}`);
        });
    
    // Caso 2: Promesa ya rechazada
    console.log('--- Caso 2: Promesa ya rechazada ---');
    Promise.reject(new Error('Error A'))
        .then(
            valor => {
                // Esta función nunca se ejecuta
                console.log('Esta línea no se mostrará');
                return 'Nunca llegaremos aquí';
            },
            error => {
                // Manejo de error en el segundo parámetro de then
                console.log(`Then con promesa rechazada (manejada): ${error.message}`);
                return 'Recuperado de error';
            }
        )
        .then(valor => {
            console.log(`Then después de manejo de error: ${valor}`);
        });
    
    // Caso 3: Promesa pendiente
    console.log('--- Caso 3: Promesa pendiente ---');
    new Promise((resolve, reject) => {
        console.log('Promesa inicialmente pendiente');
        setTimeout(() => {
            console.log('Resolviendo promesa después de 1 segundo');
            resolve('Valor tardío');
        }, 1000);
    })
    .then(valor => {
        console.log(`Then con promesa que estaba pendiente: ${valor}`);
    });
    
    console.log('Demostración iniciada (observa el orden de ejecución)');
}

demostrarComportamientoThen();

Este ejemplo muestra cómo .then() responde a promesas en diferentes estados:

  • Con promesas ya cumplidas, el callback de éxito se ejecuta asíncronamente.
  • Con promesas ya rechazadas, el callback de error se ejecuta asíncronamente.
  • Con promesas pendientes, los callbacks se quedan en espera hasta que la promesa cambie de estado.

Inmutabilidad del estado final

Una vez que una promesa alcanza el estado fulfilled o rejected, su estado y valor asociado no pueden cambiar:

function demostrarInmutabilidad() {
    let resolverPromesa, rechazarPromesa;
    
    // Creamos una promesa y capturamos sus funciones resolve/reject
    const promesa = new Promise((resolve, reject) => {
        resolverPromesa = resolve;
        rechazarPromesa = reject;
    });
    
    // Configuramos handlers múltiples
    promesa.then(valor => {
        console.log(`Handler 1: La promesa se cumplió con ${valor}`);
    });
    
    promesa.then(valor => {
        console.log(`Handler 2: La promesa se cumplió con ${valor}`);
    });
    
    // Resolvemos la promesa
    console.log('Resolviendo la promesa con valor "primera resolución"');
    resolverPromesa('primera resolución');
    
    // Intentamos resolver de nuevo (esto no tendrá efecto)
    console.log('Intentando resolver de nuevo (no tendrá efecto)');
    resolverPromesa('intento de cambiar el valor');
    
    // Intentamos rechazar (tampoco tendrá efecto)
    console.log('Intentando rechazar (no tendrá efecto)');
    rechazarPromesa(new Error('intento de rechazo'));
    
    // Agregamos otro handler después de resolver
    setTimeout(() => {
        console.log('Agregando handler adicional después de que la promesa ya se resolvió');
        
        promesa.then(valor => {
            console.log(`Handler tardío: La promesa ya estaba cumplida con ${valor}`);
        });
    }, 1000);
}

demostrarInmutabilidad();

Este ejemplo muestra que:

  1. Una vez que llamamos a resolve(), la promesa queda permanentemente en estado fulfilled.
  2. Intentos posteriores de llamar a resolve() o reject() no tienen efecto.
  3. Los handlers agregados después de que la promesa se haya cumplido también reciben el valor original.

Promise.resolve() y Promise.reject()

Estos métodos estáticos son útiles para crear promesas prefabricadas en un estado específico:

// Diferentes usos de Promise.resolve

// 1. Con un valor simple
const p1 = Promise.resolve(42);
p1.then(valor => console.log(`P1: ${valor}`)); // P1: 42

// 2. Con undefined
const p2 = Promise.resolve();
p2.then(valor => console.log(`P2: ${valor}`)); // P2: undefined

// 3. Con una promesa existente
const promesaOriginal = new Promise(resolve => 
    setTimeout(() => resolve('valor original'), 1000)
);
const p3 = Promise.resolve(promesaOriginal); // No envuelve, devuelve la misma promesa
console.log('¿Son la misma promesa?', p3 === promesaOriginal); // true

// 4. Con un objeto thenable
const p4 = Promise.resolve({
    then: function(resolve) {
        setTimeout(() => resolve('desde thenable'), 500);
    }
});
p4.then(valor => console.log(`P4: ${valor}`)); // P4: desde thenable

// Diferentes usos de Promise.reject
const e1 = Promise.reject(new Error('Algo falló'));
e1.catch(error => console.log(`E1: ${error.message}`)); // E1: Algo falló

// Nota: A diferencia de Promise.resolve(), Promise.reject() no desenvuelve thenables
const e2 = Promise.reject({
    then: function() { /* ... */ }
});
e2.catch(razon => {
    console.log('E2 es rechazada con un objeto completo:', razon);
    console.log('¿Es thenable?', typeof razon.then === 'function'); // true
});

Depuración de estados de promesas

La depuración de promesas puede ser complicada porque los errores no manejados pueden no ser evidentes. Veamos algunas técnicas útiles:

1. Usar el método finally()

El método .finally() se ejecuta independientemente de si la promesa se cumple o rechaza, lo que lo hace útil para depuración:

funcionAsincrona()
    .then(resultado => {
        console.log('Éxito:', resultado);
    })
    .catch(error => {
        console.error('Error:', error);
    })
    .finally(() => {
        console.log('Estado final alcanzado (settled)');
    });

2. Capturar rechazos no manejados

En un entorno de navegador, podemos registrar eventos para detectar promesas rechazadas no manejadas:

// Este código se ejecutaría en un navegador
window.addEventListener('unhandledrejection', evento => {
    console.warn('Promesa rechazada no manejada:', evento.promise);
    console.warn('Razón:', evento.reason);
    
    // Evitamos que se muestre en la consola del navegador
    evento.preventDefault();
});

// Creamos una promesa rechazada sin catch
const promesaSinManejar = Promise.reject(new Error('Error no manejado'));

// Si no agregamos un .catch(), el evento unhandledrejection se disparará

3. Agregar timeout a promesas

A veces, las promesas pueden quedarse pendientes indefinidamente. Podemos crear una utilidad para agregar un timeout:

function conTimeout(promesa, tiempoMs, mensaje) {
    // Creamos una promesa que se rechaza después del tiempo especificado
    const timeoutPromise = new Promise((_, reject) => {
        setTimeout(() => {
            reject(new Error(mensaje || `Operación cancelada por timeout (${tiempoMs}ms)`));
        }, tiempoMs);
    });
    
    // Devolvemos la primera que se complete
    return Promise.race([promesa, timeoutPromise]);
}

// Ejemplo de uso
function operacionLenta() {
    return new Promise(resolve => {
        // Esta operación tarda demasiado...
        setTimeout(() => resolve('Resultado tardío'), 5000);
    });
}

// Establecemos un timeout de 2 segundos
conTimeout(operacionLenta(), 2000)
    .then(resultado => {
        console.log('Éxito:', resultado);
    })
    .catch(error => {
        console.error('Error:', error.message); // Operación cancelada por timeout (2000ms)
    });

Ejemplo práctico: Máquina de estados con promesas

Para ilustrar la utilidad de comprender los estados de las promesas, implementemos una máquina de estados simple:

class MaquinaEstados {
    constructor(estadoInicial) {
        this.estado = estadoInicial;
        this.transiciones = {};
    }
    
    definirTransicion(estadoActual, evento, estadoNuevo, accion) {
        if (!this.transiciones[estadoActual]) {
            this.transiciones[estadoActual] = {};
        }
        
        this.transiciones[estadoActual][evento] = {
            estadoNuevo,
            accion
        };
    }
    
    procesarEvento(evento, datos) {
        return new Promise((resolve, reject) => {
            const estadoActual = this.estado;
            
            // Verificamos si existe la transición
            if (!this.transiciones[estadoActual] || !this.transiciones[estadoActual][evento]) {
                return reject(new Error(`No hay transición definida para estado '${estadoActual}' y evento '${evento}'`));
            }
            
            const { estadoNuevo, accion } = this.transiciones[estadoActual][evento];
            
            console.log(`Transición: ${estadoActual} --[${evento}]--> ${estadoNuevo}`);
            
            // Si hay una acción asociada, la ejecutamos
            if (accion) {
                Promise.resolve(accion(datos))
                    .then(() => {
                        // Actualizamos el estado
                        this.estado = estadoNuevo;
                        resolve(this.estado);
                    })
                    .catch(reject);
            } else {
                // Si no hay acción, simplemente actualizamos el estado
                this.estado = estadoNuevo;
                resolve(this.estado);
            }
        });
    }
    
    obtenerEstado() {
        return this.estado;
    }
}

// Ejemplo: Máquina de estados para un pedido
const maquinaPedido = new MaquinaEstados('inicial');

// Definimos las transiciones
maquinaPedido.definirTransicion('inicial', 'crear', 'creado', 
    () => console.log('Creando nuevo pedido...'));

maquinaPedido.definirTransicion('creado', 'pagar', 'pagado', 
    (datos) => {
        console.log(`Procesando pago de ${datos.monto}€...`);
        // Simulamos una operación asíncrona
        return new Promise(resolve => setTimeout(resolve, 1000));
    });

maquinaPedido.definirTransicion('pagado', 'enviar', 'enviado', 
    () => console.log('Enviando pedido...'));

maquinaPedido.definirTransicion('enviado', 'entregar', 'entregado', 
    () => console.log('Entregando pedido...'));

// También podemos definir transiciones de error
maquinaPedido.definirTransicion('pagado', 'cancelar', 'cancelado', 
    () => console.log('Cancelando pedido y reembolsando...'));

// Uso de la máquina de estados
async function gestionarPedido() {
    try {
        console.log(`Estado inicial: ${maquinaPedido.obtenerEstado()}`);
        
        await maquinaPedido.procesarEvento('crear');
        console.log(`Después de crear: ${maquinaPedido.obtenerEstado()}`);
        
        await maquinaPedido.procesarEvento('pagar', { monto: 99.99 });
        console.log(`Después de pagar: ${maquinaPedido.obtenerEstado()}`);
        
        // Simulamos una decisión basada en alguna condición
        const todoOK = Math.random() > 0.3;
        
        if (todoOK) {
            await maquinaPedido.procesarEvento('enviar');
            console.log(`Después de enviar: ${maquinaPedido.obtenerEstado()}`);
            
            await maquinaPedido.procesarEvento('entregar');
            console.log(`Estado final: ${maquinaPedido.obtenerEstado()}`);
        } else {
            await maquinaPedido.procesarEvento('cancelar');
            console.log(`Después de cancelar: ${maquinaPedido.obtenerEstado()}`);
        }
    } catch (error) {
        console.error('Error en la gestión del pedido:', error.message);
    }
}

gestionarPedido();

Este ejemplo muestra cómo las transiciones de estado en una máquina de estados se pueden modelar utilizando promesas, aprovechando sus propios estados (pending, fulfilled, rejected) para gestionar el flujo de la aplicación.

Resumen

En este artículo, hemos explorado en profundidad los tres estados fundamentales de las promesas en JavaScript: pending, fulfilled y rejected. Hemos aprendido que:

  • Cada promesa comienza en estado pending y puede transicionar a fulfilled o rejected.
  • Una vez que una promesa alcanza un estado final (fulfilled o rejected), su estado y valor asociado son inmutables.
  • Existen métodos como Promise.resolve() y Promise.reject() para crear promesas que ya están en un estado específico.
  • El comportamiento de los métodos .then() y .catch() depende del estado actual de la promesa.
  • La gestión adecuada de los estados de las promesas es crucial para manejar correctamente operaciones asíncronas.

Comprender estos conceptos nos proporciona una base sólida para trabajar con promesas de manera efectiva y nos prepara para los temas más avanzados, como el encadenamiento de promesas, que exploraremos en el próximo artículo.