Promises: resolvendo o callback hell

1. O problema do callback hell

Se você já trabalhou com JavaScript assíncrono por algum tempo, provavelmente esbarrou no temido callback hell — também conhecido como "pyramid of doom". Esse padrão surge quando múltiplas operações assíncronas dependem umas das outras, criando níveis profundos de aninhamento que tornam o código praticamente ilegível.

Vejamos um exemplo clássico em Node.js com leitura de arquivos:

const fs = require('fs');

fs.readFile('usuario.json', 'utf8', (err, data) => {
  if (err) {
    console.error('Erro ao ler usuário:', err);
    return;
  }
  const usuario = JSON.parse(data);

  fs.readFile(`pedidos_${usuario.id}.json`, 'utf8', (err, data) => {
    if (err) {
      console.error('Erro ao ler pedidos:', err);
      return;
    }
    const pedidos = JSON.parse(data);

    fs.readFile(`detalhes_${pedidos[0].id}.json`, 'utf8', (err, data) => {
      if (err) {
        console.error('Erro ao ler detalhes:', err);
        return;
      }
      const detalhes = JSON.parse(data);
      console.log('Detalhes do pedido:', detalhes);
    });
  });
});

Os impactos desse padrão são severos:
- Legibilidade comprometida: o código se expande para a direita, dificultando o acompanhamento do fluxo
- Manutenção complexa: adicionar ou modificar etapas exige reestruturar todo o aninhamento
- Tratamento de erros repetitivo: cada callback precisa verificar err manualmente, levando a duplicação e possíveis omissões

2. Introdução às Promises

Uma Promise é um objeto que representa a eventual conclusão (ou falha) de uma operação assíncrona e seu valor resultante. Em vez de passar callbacks para funções, você recebe uma Promise que pode ser "consumida" posteriormente.

Uma Promise pode estar em três estados:
- pending: estado inicial, a operação ainda não foi concluída
- fulfilled: a operação foi concluída com sucesso
- rejected: a operação falhou

Sintaxe básica de criação:

const minhaPromise = new Promise((resolve, reject) => {
  const sucesso = true;

  setTimeout(() => {
    if (sucesso) {
      resolve('Operação concluída!');
    } else {
      reject(new Error('Algo deu errado'));
    }
  }, 1000);
});

3. Consumindo Promises: then, catch, finally

Para consumir uma Promise, utilizamos três métodos fundamentais:

minhaPromise
  .then((resultado) => {
    console.log('Sucesso:', resultado);
    return 'Próximo valor';
  })
  .catch((erro) => {
    console.error('Erro:', erro.message);
  })
  .finally(() => {
    console.log('Sempre executa, independente do resultado');
  });
  • .then(): recebe o valor resolvido e permite encadeamento
  • .catch(): captura qualquer erro na cadeia
  • .finally(): executado sempre, útil para limpeza ou finalização de loading states

4. Transformando callbacks em Promises

O Node.js oferece util.promisify() para converter funções baseadas em callback em funções que retornam Promises:

const util = require('util');
const fs = require('fs');
const readFileAsync = util.promisify(fs.readFile);

readFileAsync('arquivo.txt', 'utf8')
  .then((conteudo) => console.log(conteudo))
  .catch((err) => console.error('Erro:', err));

Também podemos criar wrappers manualmente para maior controle:

function readFilePromise(caminho, encoding) {
  return new Promise((resolve, reject) => {
    fs.readFile(caminho, encoding, (err, data) => {
      if (err) reject(err);
      else resolve(data);
    });
  });
}

Esse padrão é essencial para refatorar código legado, substituindo callbacks aninhados por cadeias lineares de Promises.

5. Encadeamento de Promises

O verdadeiro poder das Promises está no encadeamento. Cada .then() pode retornar um valor ou uma nova Promise, permitindo sequenciar operações de forma linear:

readFileAsync('usuario.json', 'utf8')
  .then((data) => {
    const usuario = JSON.parse(data);
    return readFileAsync(`pedidos_${usuario.id}.json`, 'utf8');
  })
  .then((data) => {
    const pedidos = JSON.parse(data);
    return readFileAsync(`detalhes_${pedidos[0].id}.json`, 'utf8');
  })
  .then((data) => {
    const detalhes = JSON.parse(data);
    console.log('Detalhes do pedido:', detalhes);
  })
  .catch((err) => {
    console.error('Erro em qualquer etapa:', err);
  });

Comparação visual:

Antes (callback hell):

function1(callback1 -> function2(callback2 -> function3(callback3)))

Depois (encadeamento linear):

promise1.then(promise2).then(promise3).catch(erro)

6. Tratamento avançado de erros com Promises

Em cadeias longas, um único .catch() no final captura erros de qualquer etapa:

operacao1()
  .then((res1) => operacao2(res1))
  .then((res2) => operacao3(res2))
  .then((res3) => operacao4(res3))
  .catch((err) => {
    // Captura erro de qualquer operação acima
    console.error('Falha na cadeia:', err);
  });

Erros não capturados em Promises podem gerar o evento unhandledrejection:

process.on('unhandledRejection', (reason, promise) => {
  console.error('Promise rejeitada não tratada:', reason);
});

Boas práticas: sempre finalize suas cadeias com .catch(). Em ambientes Node.js modernos, Promises rejeitadas sem tratamento podem encerrar o processo.

7. Promises no contexto React

No React, Promises são amplamente usadas para busca de dados. O padrão típico em componentes funcionais:

import React, { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    const abortController = new AbortController();

    fetch(`https://api.exemplo.com/users/${userId}`, {
      signal: abortController.signal
    })
      .then((response) => {
        if (!response.ok) {
          throw new Error('Erro na requisição');
        }
        return response.json();
      })
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== 'AbortError') {
          setError(err.message);
          setLoading(false);
        }
      });

    // Cleanup: aborta requisição se componente desmontar
    return () => abortController.abort();
  }, [userId]);

  if (loading) return <div>Carregando...</div>;
  if (error) return <div>Erro: {error}</div>;
  return <div>{user.name}</div>;
}

O uso de AbortController previne memory leaks ao cancelar requisições quando o componente é desmontado antes da conclusão.

8. Conclusão e próximos passos

As Promises revolucionaram o tratamento de código assíncrono em JavaScript, transformando o caos do callback hell em cadeias lineares e compreensíveis. Com estados claros (pending, fulfilled, rejected) e métodos como then, catch e finally, você ganha controle total sobre operações assíncronas.

Limitações: embora muito melhores que callbacks, Promises ainda podem ser verbosas para fluxos complexos. Por isso, a evolução natural é o async/await, que oferece uma sintaxe ainda mais limpa — tema do próximo artigo desta série.

Para aprofundar, explore também Promise.all, Promise.race e Promise.allSettled, que permitem trabalhar com múltiplas Promises simultaneamente.


Referências