Engineering
Engineering

O guia de migração para React 18 em ambientes de produção de alto risco

Brendon Matos
Brendon Matos
8 min read
bg-image
bg-image
O guia de migração para React 18 em ambientes de produção de alto risco
Subscribe to newsletter
Share

Preparando o terreno: alto risco e alta escala

Na Sardine, nosso dashboard é a interface principal da nossa plataforma de detecção de fraude e compliance. Analistas de compliance, operadores de risco e equipes de engenharia o usam diariamente para investigar transações, gerenciar regras e monitorar alertas. É um monorepo grande de React e TypeScript com centenas de componentes, dezenas de pacotes internos e integrações profundas com serviços de backend.

O React 18 trouxe melhorias significativas, como batching automático, recursos concorrentes e uma API de renderização modernizada. No entanto, para uma aplicação em produção na nossa escala, a atualização exigiu mais do que uma simples troca de versão.

As mudanças de comportamento entre React 17 e 18 são sutis o suficiente para passar por testes automatizados e QA manual. Elas frequentemente só aparecem sob condições de dados específicas em produção; por exemplo, quando um cliente relata uma tela congelada causada por um loop infinito de re-renderização. Esses problemas são difíceis de encontrar e ainda mais difíceis de corrigir depois do fato. Essa realidade moldou nossa estratégia: precisávamos de uma abordagem que nos permitisse validar mudanças de forma incremental com tráfego real, mantendo a capacidade de reverter instantaneamente. Um único PR grande com uma sessão dedicada de caça a bugs foi considerado, mas uma atualização monolítica é difícil de reverter, difícil de bissectar quando surgem problemas, e difícil de corrigir sob pressão de tempo.

Este post cobre a arquitetura que construímos para resolver esses desafios: uma troca de versão do React por usuário, controlada por feature flag e baseada em mapas de importação. Também compartilhamos os codemods e as regras personalizadas de ESLint que escrevemos para resolver os problemas de comportamento descobertos durante o lançamento.

Nota: Os trechos de código ao longo deste post estão simplificados para maior clareza e não representam a implementação exata em produção.

O desafio técnico: mudanças sutis e falhas silenciosas

O React 18 introduz mudanças que parecem simples no papel, mas se comportam de forma imprevisível em bases de código grandes. Dois problemas principais se destacaram durante nossa migração:

O batching automático muda quando as atualizações de estado são aplicadas

No React 17, atualizações dentro de promises ou timeouts disparavam uma re-renderização para cada setState. O React 18 agrupa essas em uma única passagem. Embora geralmente seja uma vitória de performance, código que lê o DOM ou uma ref imediatamente após uma chamada de setState, esperando uma renderização intermediária, agora verá dados "obsoletos" porque a renderização ainda não foi aplicada. Isso falha silenciosamente; a interface eventualmente parece correta, mas a lógica intermediária está quebrada.

Igualdade referencial mais rígida expõe referências instáveis

A reconciliação do React 18 é mais agressiva em desistir de re-renderizações quando as props não mudaram. Esse é o comportamento correto, mas expõe uma classe de bugs que o React 17 tolerava silenciosamente: valores inline como [], {}, e () => {} usados como valores padrão criam novas referências a cada renderização. Isso dispara loops infinitos de re-renderização quando consumidos por useEffect, useMemo, ou bibliotecas como @tanstack/react-table que dependem de igualdade referencial.

Sabíamos que esses problemas seriam quase impossíveis de detectar de forma abrangente antes do lançamento. Por isso não tratamos isso como uma simples troca de versão; tratamos como um lançamento controlado, com infraestrutura projetada para encontrar e corrigir problemas de forma incremental.

Nota: Como nosso dashboard é uma SPA pura sem renderização no servidor, evitamos completamente a aplicação mais rígida de incompatibilidades de hidratação do React 18 (se sua aplicação usa SSR, espere uma onda de erros de hidratação durante a migração).

Escolhendo o caminho certo: dos codemods à troca em nível de infraestrutura

Antes de construir uma solução personalizada, avaliamos ferramentas e arquiteturas existentes para encontrar o caminho de lançamento mais seguro possível.

Ferramentas oficiais de migração

A equipe do React oferece o react-codemod, um conjunto de scripts de migração automatizados. Executamos as transformações relevantes: atualizando métodos de ciclo de vida obsoletos, migrando definições de tipo (removendo React.VFC em favor de React.FC onde também removia os children implícitos, atualizando os tipos de retorno de render), e aplicando o codemod update-react-imports para a nova transformação JSX. Esses cuidaram das mudanças mecânicas em nível de API e foram um primeiro passo necessário.

Mas codemods só conseguem corrigir o que é identificável sintaticamente. Os problemas de comportamento (referências instáveis, mudanças de batching) não são problemas de sintaxe. São problemas de runtime que dependem de como os componentes interagem entre si e com os dados. Nenhum codemod consegue detectar que seu useEffect vai entrar em loop infinito sob uma combinação específica de props. Para isso, precisávamos de uma forma de executar a nova versão contra tráfego real.

Estratégias de troca de versão

Consideramos três abordagens e as listamos na tabela abaixo:

Abordagem

Prós

Contras

Mapas de importação + CDN

Não requer rebuilds; permite reversões instantâneas e segmentação granular por usuário.

Introduz dependências de CDN/ESM estático e um pequeno aumento de latência ao carregar o React.

Aliases duplos de npm

Opera inteiramente sem dependência de CDN.

Requer artefatos de build duplos, resultando em pacotes maiores e sobrecarga significativa de infraestrutura.

Baseado em Kubernetes (a abordagem da Airbnb)

Oferece isolamento completo de processos.

Requisitos pesados de infraestrutura, excessivo para nossa configuração.

Os mapas de importação nos deram a maior flexibilidade com a menor sobrecarga de infraestrutura. Trocar versões do React é uma substituição de string no servidor. Sem redeploy, sem pipeline de build separado, sem contêineres adicionais. A única ressalva é o suporte de navegadores, já que mapas de importação são um padrão web relativamente recente. Antes de nos comprometermos com essa abordagem, cruzamos nossos dados de analytics com o Can I use e confirmamos que 100% da nossa base de usuários ativos usa navegadores com suporte completo. Como um produto B2B usado por equipes de compliance e risco, nosso cenário de navegadores é previsível e atual, o que tornou essa uma aposta segura.

__wf_reserved_inherit

A arquitetura: troca de versão de assets de biblioteca remotos

Em vez de empacotar o React dentro da nossa aplicação, nós o externalizamos nos builds de produção. O servidor verifica um feature flag e injeta o mapa de importação apropriado no index.html antes de servi-lo.

Como o mapa de importação é gerado no servidor, podemos controlar a origem do asset por ambiente sem qualquer atrito. Se precisássemos, poderíamos servir o React do nosso próprio bucket S3 sem mudanças de código.

__wf_reserved_inherit

Primeiro, externalizamos o React, para que ele não seja incluído no bundle final:

No desenvolvimento, o Vite empacota o React 18 diretamente, então cada engenheiro trabalha contra a versão-alvo durante seu fluxo de trabalho normal.

A lógica do lado do servidor:

O template HTML contém um placeholder que é substituído pelo mapa de importação.

Ao fazer isso, o motor do navegador vai resolver nativamente todos os import React from 'react', chamando o CDN.

Ponto de entrada de renderização dupla

O ponto de entrada da aplicação detecta qual API do React está disponível em tempo de execução:

Uma estratégia de lançamento em fases

__wf_reserved_inherit

O lançamento seguiu cinco fases, cada uma controlada pelo feature flag:

Fase 0: Pré-merge

Todo o código de migração foi mesclado à main com o flag desativado por padrão. A produção continuou rodando React 17 sem qualquer mudança.

Fase 1: Desenvolvimento

Os engenheiros fizeram seu trabalho diário contra o React 18 localmente (o Vite sempre empacota React 18 em dev). Isso capturou problemas cedo, dentro do fluxo normal de desenvolvimento.

Fase 2: Sandbox

Nosso ambiente de Sandbox tem dois propósitos: clientes o usam para avaliar nossa plataforma, e todo release é implantado lá antes da produção. Habilitamos o flag aqui primeiro. Isso nos deu semanas de padrões de uso real; clientes executando seus próprios fluxos de avaliação, sem nenhum risco para a produção.

Fase 3: Usuários internos

Flag habilitado para funcionários da Sardine em produção. Como o feature flag suporta segmentação por usuário e por organização, pudemos delimitar o lançamento com precisão: primeiro nossa equipe de engenharia, depois a empresa em geral. Isso nos permitiu exercitar fluxos de trabalho críticos com dados reais antes de qualquer cliente ver a mudança.

Fase 4: Lançamento gradual

Lançamento baseado em porcentagem para usuários externos, subindo de 5% para 100%. Como a versão do React é determinada no servidor a cada carregamento de página, não há estado em cache para invalidar. Basta alternar o flag, e a próxima requisição recebe a versão anterior.

O que encontramos: referências instáveis em escala

Assim que o lançamento chegou aos usuários internos no Ambiente Local e Sandbox, começamos a enfrentar loops infinitos de re-renderização. Componentes que funcionavam perfeitamente no React 17 congelavam o navegador no React 18. A causa raiz era sempre a mesma classe de problema: referências instáveis.

Aqui está um exemplo simplificado do padrão:

Toda vez que esse hook é executado, o fallback [] e () => {} criam novas referências de objeto. Qualquer useEffect ou useMemo que dependa desses valores é reexecutado, o que pode disparar outra renderização, que cria novas referências novamente, entrando em um loop infinito.

O React 17 tolerava isso porque sua reconciliação era menos agressiva quanto às verificações de referência. O React 18 expõe esses bugs de forma mais agressiva devido ao Strict Mode invocar efeitos duas vezes em desenvolvimento e à Renderização Concorrente agendar atualizações de forma diferente. O React 18 faz a coisa certa, mas fazer a coisa certa expôs anos de atalhos acumulados.

Constantes estáveis

A correção é um conjunto de referências singleton congeladas:

Quando aplicadas, elas ficam assim:

Esse era um conceito simples. O desafio foi aplicá-lo de forma consistente em centenas de arquivos. E precisávamos nos mover rápido, porque esses problemas estavam bloqueando o lançamento final.

Codemods para transformação automatizada

Construímos dois codemods de jscodeshift para lidar com o trabalho repetitivo:

fix-unstable-hook-returns escaneia toda função que corresponde a use[A-Z]* e substitui os valores de retorno contendo [], {}, ou () => {} pela constante estável correspondente.

fix-unstable-destructuring-defaults captura a variante de desestruturação:

Ambos os codemods gerenciam os imports automaticamente, adicionando ou estendendo o import de @/react-migration-support conforme necessário.

No final, depois que todos os codemods rodaram, tivemos mais de 1 mil arquivos alterados.

Regras personalizadas de ESLint para evitar regressão

Embora corrigir a base de código existente fosse necessário, prevenir regressões durante o lançamento em múltiplas fases era igualmente crítico. Para aplicar esses padrões automaticamente e garantir a estabilidade de longo prazo da migração, desenvolvemos cinco regras personalizadas de ESLint:

Regra

O que ela detecta

no-unstable-default-props

Literais inline como props padrão de componentes.

no-unstable-hook-return

Valores de retorno instáveis de hooks personalizados.

no-unstable-destructuring-defaults

const { data = [] } = useHook() padrões.

no-unstable-react-table-props

Sem memoização de data/columns em useReactTable

no-unmemoized-array-transform

.map() / .filter() resultados usados diretamente na renderização.

A regra no-unstable-react-table-props merece destaque. @tanstack/react-table usa igualdade referencial internamente. Se data ou columns não estiverem memoizados, a tabela re-renderiza a cada atualização do pai. Essa regra rastreia declarações de useMemo e useState e sinaliza qualquer valor não memoizado passado para useReactTable:

Essas regras continuam úteis muito além da migração. Os padrões que elas aplicam são boas práticas independentemente da versão do React, e vão capturar a mesma classe de problemas quando eventualmente migrarmos para o React 19.

Considerações finais: um plano para migrações em grande escala

  • Mapas de importação são subestimados para migração de versão. Eles nos permitiram implementar a troca de versão por usuário sem exigir infraestrutura de build adicional. Embora exista uma dependência externa de CDN, ela é gerenciável através de provedores confiáveis como jsDelivr ou adicionando um proxy de cache.
  • Invista em prevenção, não apenas em remediação. Embora codemods fossem essenciais para corrigir código existente em uma única passagem, nossas regras personalizadas de ESLint garantem que esses mesmos padrões nunca voltem. Essas regras exigiram esforço para escrever, mas se pagam toda vez que capturam um erro antes de um commit em vez de como um relatório de bug em produção.
  • Feature flags transformam atualizações de alto risco em deploys rotineiros. A capacidade de segmentar usuários específicos, organizações, ou porcentagens de tráfego nos deu controle total sobre o lançamento. Essa granularidade, combinada com a capacidade de fazer uma reversão instantânea, removeu a pressão de tempo tradicional associada a grandes atualizações de versão.
  • Resolva problemas em produção, com segurança e sem pressão de tempo. Os problemas de referências instáveis que descobrimos teriam sido quase impossíveis de capturar apenas por testes isolados. Nossa arquitetura de mapas de importação criou um ambiente seguro para encontrar esses casos extremos usando tráfego e dados reais, apoiada pela rede de segurança de uma reversão instantânea.

Olhando para o futuro: preparando-se para o React 19 e além

A beleza da arquitetura de mapas de importação é que ela é totalmente agnóstica de versão. Quando o React 19 chegar, essa mesma infraestrutura vai gerenciar o lançamento com o mesmo nível de precisão e segurança.

O padrão que construímos — externalizar uma dependência central, trocar de versão via mapas de importação, e controlar a transição com feature flags — não é específico do React. Ele fornece um caminho de lançamento repetível, reversível e gradual para qualquer atualização importante de biblioteca em um ambiente de missão crítica.