
Estratégias de Code Splitting em Aplicações React Modernas
Uma vez peguei uma aplicação React de herança onde a página de login carregava todo o JavaScript do dashboard administrativo — gráficos, tabelas de dados, um editor de texto rico — nada disso o usuário conseguia alcançar antes de se autenticar. Ninguém fez isso de propósito. Aconteceu um import de cada vez, porque nada obrigava ninguém a pensar sobre onde o código deveria carregar.
Code splitting não é uma técnica avançada que você usa ocasionalmente. É um padrão que você deveria aplicar desde o primeiro dia, do mesmo jeito que você já usa lazy-loading em imagens por padrão.
O que o code splitting realmente resolve
Por padrão, bundlers como Webpack ou Turbopack colocam tudo que você importa em um único arquivo JavaScript. Cada componente, cada biblioteca, cada rota — um bundle só, baixado e interpretado antes da sua aplicação ficar interativa.
Code splitting quebra esse bundle único em pedaços menores, carregados sob demanda em vez de tudo de uma vez. O navegador só busca um pedaço quando algo realmente precisa dele — uma rota que o usuário navega até, um modal que ele abre, uma feature que ele nunca toca.
O resultado: seu bundle inicial só contém o necessário para renderizar a primeira tela. O resto espera.
Três estratégias de divisão, três gatilhos diferentes
Divisão por rota
Esse é o ganho mais fácil e o que você recebe quase de graça no Next.js. Cada rota dentro do diretório app/ já é seu próprio pedaço — você não escreve código nenhum especial para conseguir isso.
// app/dashboard/page.tsx
// Esse arquivo inteiro, e tudo que ele importa, vira seu próprio pedaço automaticamente
export default function DashboardPage() {
return <Dashboard />
}
Se você está em React puro com React Router, você consegue o mesmo comportamento com React.lazy:
// ❌ O código de cada rota carrega imediatamente, mesmo rotas que o usuário nunca visita
import Dashboard from './pages/Dashboard'
import Settings from './pages/Settings'
import Reports from './pages/Reports'
// ✅ Cada rota só carrega quando o usuário navega até ela
import { lazy, Suspense } from 'react'
import { Routes, Route } from 'react-router-dom'
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const Reports = lazy(() => import('./pages/Reports'))
function App() {
return (
<Suspense fallback={<PageSpinner />}>
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/reports" element={<Reports />} />
</Routes>
</Suspense>
)
}
Divisão por componente
A divisão por rota não ajuda quando o componente pesado vive dentro de uma rota que o usuário já carregou. Uma página de configurações com cinco abas não precisa do código das cinco abas antes do usuário escolher uma.
// ❌ O JS das cinco abas vai junto, mesmo que só uma renderize por vez
import BillingTab from './tabs/BillingTab'
import SecurityTab from './tabs/SecurityTab'
import IntegrationsTab from './tabs/IntegrationsTab'
function SettingsPage({ activeTab }: { activeTab: string }) {
return (
<>
{activeTab === 'billing' && <BillingTab />}
{activeTab === 'security' && <SecurityTab />}
{activeTab === 'integrations' && <IntegrationsTab />}
</>
)
}
// ✅ Só o código da aba ativa é buscado
import dynamic from 'next/dynamic'
const BillingTab = dynamic(() => import('./tabs/BillingTab'))
const SecurityTab = dynamic(() => import('./tabs/SecurityTab'))
const IntegrationsTab = dynamic(() => import('./tabs/IntegrationsTab'))
function SettingsPage({ activeTab }: { activeTab: string }) {
return (
<>
{activeTab === 'billing' && <BillingTab />}
{activeTab === 'security' && <SecurityTab />}
{activeTab === 'integrations' && <IntegrationsTab />}
</>
)
}
Divisão por interação
Essa é a que as pessoas mais pulam: adiar o carregamento do código até uma ação do usuário, não só uma rota ou uma renderização condicional.
// ✅ O editor de texto rico — e suas ~150KB de dependências — só carrega no clique
import { useState } from 'react'
import dynamic from 'next/dynamic'
const RichTextEditor = dynamic(() => import('./RichTextEditor'), {
loading: () => <p>Carregando editor...</p>,
ssr: false, // a maioria dos editores de texto rico mexe direto no DOM e não roda no servidor
})
function CommentBox() {
const [editing, setEditing] = useState(false)
if (!editing) {
return <button onClick={() => setEditing(true)}>Escrever um comentário</button>
}
return <RichTextEditor />
}
A opção ssr: false importa aqui — bibliotecas como editores de texto rico ou renderizadores de gráfico costumam assumir que window existe, e você não quer que isso falhe durante a renderização no servidor.
Medindo o que realmente foi dividido
Não assuma que sua divisão funcionou — verifique. Rode o bundle analyzer:
ANALYZE=true npm run build
Procure duas coisas: o pedaço que você separou está de fato isolado do bundle principal, e ele tem o tamanho que você esperava? Já vi desenvolvedores separarem um componente só para descobrir que uma dependência compartilhada manteve tudo junto de qualquer jeito.
Erros comuns
- ❌ Dividir tudo, incluindo componentes minúsculos. Um componente de 2KB não vale uma requisição de rede separada — o overhead da requisição extra pode custar mais do que os bytes economizados. Divida em dependências de 20KB+ pra cima, não em cada arquivo.
- ❌ Esquecer o estado de loading. Um import
dynamic()sem umloadingde fallback não mostra nada enquanto o pedaço baixa, o que parece uma UI quebrada em conexões lentas. - ❌ Dividir conteúdo acima da dobra. Se você faz lazy-load da sua seção de destaque ou navegação principal, você atrasa a primeira coisa que o usuário precisa ver. Divida o que está escondido ou condicional, não o que renderiza imediatamente.
- ❌ Não checar dependências compartilhadas. Se dois componentes lazy-loaded importam a mesma biblioteca pesada, o bundler pode não deduplicar do jeito que você espera. Confira a saída do analyzer, não chute.
Boas práticas
- Divida por rota primeiro, sempre. É a mudança de maior impacto e menor esforço, e o Next.js já te dá isso automaticamente.
- Divida modais, abas e acordeões que não estão visíveis na primeira renderização. Esses quase sempre são ganhos seguros, sem nenhum trade-off de UX.
- Use
ssr: falsepara bibliotecas que só funcionam no navegador. Qualquer coisa que mexe emwindow,documentou APIs exclusivas do navegador precisa disso para evitar erros de hidratação. - Defina um orçamento de bundle e cheque em cada PR. Uma regressão de 40KB por causa de uma dependência adicionada é muito mais fácil de pegar na revisão do que em produção.
- Faça prefetch no hover para rotas que você espera que o usuário visite em seguida. O
<Link>do Next.js já faz isso automaticamente; para gatilhos customizados, chamerouter.prefetch()manualmente.
O que fazer agora
Abra seu bundle analyzer e procure os três maiores pedaços que não fazem parte do seu layout principal ou da home. Para cada um, pergunte: isso precisa carregar antes do usuário fazer algo específico? Se a resposta for não, envolva ele em dynamic() ou lazy() e siga para o próximo.
Faça isso para toda rota com mais de um estado interativo, e seu bundle inicial vai encolher até o tamanho que ele sempre deveria ter tido — só o que a primeira tela precisa, nada além disso.