Executando verificação de segurança...
3

Einstein Components: o que são e como lidar com eles

Todo desenvolvedor já esbarrou num componente que parece inteligente demais. Ele calcula estado, chama API, decide layout, valida formulário, renderiza oito variações diferentes e ainda sabe quando mostrar modal. Esses são os famosos Einstein Components: componentes que sabem tanto que você precisa de um doutorado para alterar uma prop sem quebrar tudo.

Enquanto o padrão clássico do front recomenda “Componentes burros” (ou dumb componentes), aqueles que são simples, previsíveis e burros no melhor sentido possível, não sabem nada além da responsabilidade dele. O "Einstein Component" é aquele que sabe tanto sobre diversos assuntos diferentes que, a menor mudança, causa insegurança de lidar com N efeitos colaterais.

Então qual a forma prática de lidar com esses monstrinhos? Reescrever tudo do zero? Até dá, mas é receita clássica para: levar mais tempo do que deveria, reinventar problemas que já estavam resolvidos, e o pior, acabar esquecendo algum detalhe que o componente antigo já fazia direitinho desde 2019. E seguir empilhando funcionalidades nesse caos organizado? Aí você só adia a dor: cada nova linha vira uma aposta contra o próprio futuro.

Na literatura de design de software existe um consenso: começar pelo mínimo, organizando a bagunça antes de mover peças, e então aplicar pequenos refactors seguros e graduais.

Neste artigo, vou mostrar um passo a passo completo usando um componente fictício (mas dolorosamente realista). Se você quiser se aprofundar ainda mais no assunto, da uma olhada nessas obras:

Tidy First? - Kent Beck: Um livro curto e prático sobre como pequenas minirrefatorações, feitas do jeito certo melhoram o código sem tocar na lógica.
Refatoração - Martin Fowler: Esse ainda ta na minha fila, mas é uma das maiores referencias pra esse tema.

Agora, bora pro código!

O Einstein Component

Abaixo, segue um exemplo comum do dia a dia de um componente com uma tarefa relativamente simples: buscar dados de uma API e mostrar na tela.

const ProductPanel = ({ user, productId, mode, showSummary }) => {
  const [loading, setLoading] = useState(true);
  const [product, setProduct] = useState(null);
  const [toast, setToast] = useState(null);

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then((res) => res.json())
      .then((data) => {
        if (mode === "full") {
          setProduct({ ...data, price: Number(data.price) });
        } else {
          setProduct({ name: data.name });
        }
      })
      .finally(() => setLoading(false));
  }, [productId, mode]);

  const handleBuy = async () => {
    if (!user) {
      setToast("Faça login!");
      return;
    }
    // Finja que tem 158 linhas de lógica aqui xD
  };

  if (loading) return <Loader />;

  return (
    <div>
      {showSummary && <Summary user={user} product={product} />}
      {product && (
        <button className={mode === "full" ? "big" : "small"} onClick={handleBuy}>
          Comprar
        </button>
      )}
      {toast && <Toast message={toast} />}
    </div>
  );
};

Mesmo para uma tarefa simples como essa, o componente acaba lidando com muita coisa ao mesmo tempo: requisição da API, controle de estado de carregamento, lógica de interação com o botão (handleBuy), determinação dinâmica de classes com base em mode e renderização condicional de UI dependendo do retorno da API.

Uma parte dessa complexidade é essencial: ela vem diretamente do problema que estamos tentando resolver. O problema está na complexidade acidental: aquela que surge das escolhas que fazemos ao estruturar o código. Nesse caso, a decisão de concentrar todas essas responsabilidades dentro de um único componente transforma algo simples em um pequeno aglomerado de comportamentos misturados.

Refatorando o Einstein Component

Passo 0 - Testes

Antes de pensar em refatorar qualquer coisa, garanta que existem testes automatizados protegendo o comportamento atual do componente. Isso evita que você tenha que fazer rollback em produção numa sexta-feira às 18h99, enquanto explica para os stakeholders por que a sua “grande melhoria” acabou virando um bug.

O segredo aqui é sempre testar o comportamento, nunca a implementação. Ou seja: valide que, dado um certo retorno da API, o componente renderiza o que deveria, em vez de testar se uma função interna específica foi chamada. Esse tipo de teste te dá liberdade para mover código, dividir responsabilidades e reorganizar estruturas sem medo de quebrar funcionalidades invisíveis.

Passo 1 - Deixar explícito o que está implícito

Primeiro, vamos identificar pequenas mudanças que não alterem comportamento, mas melhorem legibilidade.

// Antes
.then((data) => {
  if (mode === "full") {
    setProduct({ ...data, price: Number(data.price) });
  } else {
    setProduct({ name: data.name });
  }
})

// Depois
.then((data) => {
  const normalizedProduct =
    mode === "full"
      ? { ...data, price: Number(data.price) }
      : { name: data.name };
    setProduct(normalizedProduct);
})

Nomear normalizedProduct torna explicitamente visível que ali há uma responsabilidade, facilitando tanto a leitura do código atual, como o próximo passo da reorganização do código.

Passo 2 - Extrair responsabilidades

Agora é hora de identificar trechos que realizam mais de uma ação e separá-los, para isolar responsabilidades e reduzir efeitos colaterais.

No caso do nosso componente, o useEffect é responsável por buscar dados, normalizá-los e controlar o estado visual de loading, tudo ao mesmo tempo.

Antes

useEffect(() => {
  fetch(`/api/products/${productId}`)
    .then((res) => res.json())
    .then((data) => {
      const normalizedProduct =
        mode === "full"
          ? { ...data, price: Number(data.price) }
          : { name: data.name };
      setProduct(normalizedProduct);
    })
    .finally(() => setLoading(false));
}, [productId, mode]);

Depois

Separe a função que se comunica com a API para uma camada própria para isso.

const fetchProduct = async (id, mode) => {
  const data = await fetch(`/api/products/${id}`).then((r) => r.json());
  const normalizedProduct =
    mode === "full"
      ? { ...data, price: Number(data.price) }
      : { name: data.name };
  return normalizedProduct;
};

Importe o fetchProduct e o consuma no useEffect.

import { fetchProduct } from "..."

useEffect(() => {
  fetchProduct(productId, mode)
    .then((normalized) => {
      setProduct(normalized);
    })
    .finally(() => {
      setLoading(false);
    });
}, [productId, mode]);

Dessa forma, a lógica de decidir como recuperar os dados e transformá-los, já não pertence mais ao nosso componente, e sim a uma camada apenas responsável por isso. E o useEffect apenas coordena a lógica de visualização.

Além de que, agora é extremamente fácil de criar testes automatizados para fetchProduct, basta mockar o retorno da API e descrever os cenários de normalização dos dados. Também podemos reaproveitá-lo em outras partes da nossa aplicação.

Passo 3 - Separar lógica e declaração visual

O próximo objetivo é garantir que o componente se preocupe apenas com apresentação. Para isso, toda lógica que não é puramente visual deve ser extraída para módulos separados.

Antes

Hoje, o componente contém tanto o useEffect quanto toda a lógica de compra. Vamos isolá-los para melhorar a separação de responsabilidades.

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then((res) => res.json())
      .then((data) => {
        if (mode === "full") {
          setProduct({ ...data, price: Number(data.price) });
        } else {
          setProduct({ name: data.name });
        }
      })
      .finally(() => setLoading(false));
  }, [productId, mode]);

  const handleBuy = async () => {
    if (!user) {
      setToast("Faça login!");
      return;
    }
    // Finja que tem 158 linhas de lógica aqui xD
  };

Depois

Vamos criar hooks customizáveis:

const useBuyAction = (user) => {
  const [toast, setToast] = useState(null);

  const handleBuy = async (product) => {
    if (!user) {
      setToast("Faça login!");
      return;
    }
    // Finja que tem 158 linhas de lógica aqui xD
  };

  return { toast, handleBuy };
};
const useProduct = (id, mode) => {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    let active = true;
    setLoading(true);

    fetchProduct(id, mode).then((data) => {
      if (active) setProduct(data);
      setLoading(false);
    });

    return () => { active = false; };
  }, [id, mode]);

  return { product, loading };
};

E então utiliza-los em nosso componente:

const ProductPanel = ({ productId, mode, showSummary, user }) => {
  const { product, loading } = useProduct(productId, mode);
  const { toast, handleBuy } = useBuyAction(user);
};

Dessa forma, ProductPanel apenas se preocupa em orquestrar os dados, mas não sabe nada sobre a implementação deles.

Passo 4 - Separar lógicas visuais

Se o componente ainda contém decisões visuais que pertencem a contextos diferentes, significa que é possível extrair novos componentes e reduzir ainda mais o acoplamento.

Antes

Além de lidar com Summary e Toast, o componente ainda decide qual classe aplicar no botão com base em mode:

  return (
    <div>
      {showSummary && <Summary user={user} product={product} />}
      {product && (
        <button className={mode === "full" ? "big" : "small"} onClick={handleBuy}>
          Comprar
        </button>
      )}
      {toast && <Toast message={toast} />}
    </div>
  );

Depois

Podemos então separar essa decisão para um componente menor, apenas responsável por isso:

const BuyButton = ({ mode, onClick }) => (
  <button className={mode === "full" ? "big" : "small"} onClick={onClick}>
    Comprar
  </button>
);

E então utilizá-lo no nosso componente principal.

return (
  <div>
    {showSummary && <Summary user={user} product={product} />}
    {product && <BuyButton mode={mode} onClick={handleBuy} />)}
    {toast && <Toast message={toast} />}
  </div>
);

Resultado final

Agora, nosso componente apenas junta as peças. A carga cognitiva é muito menor: basta um rápido olhar para entender de onde cada informação vem e para onde vai.

const ProductPanel = ({ productId, mode, showSummary, user }) => {
  const { product, loading } = useProduct(productId, mode);
  const { toast, handleBuy } = useBuyAction(user);

  if (loading) return <Loader />;

  return (
    <>
      {showSummary && <Summary product={product} user={user} />}
      {product && <BuyButton mode={mode} onClick={handleBuy} />)}
      {toast && <Toast message={toast} />}
    </>
  );
};

Cada extração, cada nomeação mais clara, cada responsabilidade removida do componente principal contribui para algo muito maior: previsibilidade.

O código final não é apenas mais limpo. Ele é mais fácil de testar, de expandir, de revisar. E isso é o que separa um sistema saudável de uma bomba-relógio.

Da próxima vez que você encontrar um componente que parece saber demais, lembre-se: comece pequeno, proteja comportamentos com testes, extraia responsabilidades com cuidado e avance em passos seguros.

Seu eu do futuro (e sua sexta-feira às 18h99) agradecem.

Qualquer tolo escreve um código que um computador possa entender.
Bons programadores escrevem códigos que os seres humanos podem entender.

  • Martin Fowler
Carregando publicação patrocinada...
1

Eu comecei recentemente no Mundo do Next.js e React, e estava pensando em fazer componentes que poderiam ser reutilizaveis para manter a mesma estética do site.

Por exemplo tenho um Button dentro eu posso colocar se é um Button para redirecionar para outro página ou para Submeter algo.

Gerar uma tabela que forneça colunas e dados, ou formulário para colocar os inputs e as regras.

Isso é ruim pq esses button vão fazer muitas coisas e estaram espalhados pela codebase?