O Problema Real do useContext em Aplicações Escaláveis
Quando iniciamos com React e descobrimos o useContext, parece uma solução mágica para evitar prop drilling. No entanto, conforme sua aplicação cresce, você descobrirá um problema fundamental: o useContext causa re-renders em todos os componentes que o consomem, independentemente de qual parte do estado eles realmente utilizam. Isso não é um bug; é o comportamento padrão do React, e ignorar essa realidade custará performance.
Imagine uma aplicação com um Context global contendo dados de usuário, tema, notificações e configurações. Se você tiver 50 componentes consumindo esse Context e apenas o tema mudar, todos esses 50 componentes sofrerão re-render, mesmo que 45 deles não usem a informação de tema. Essa cascata de re-renders desnecessários é exatamente o que exploraremos para resolver.
Context Splitting: Dividindo para Vencer
O Conceito Fundamental
Context Splitting é a prática de dividir um grande Context em múltiplos Contexts menores e especializados. Em vez de ter um AppContext monolítico, você cria ThemeContext, UserContext, NotificationContext etc. Cada componente então consome apenas os Contexts que realmente precisa, reduzindo drasticamente os re-renders.
A lógica é simples: se um componente só precisa do tema, deve estar conectado apenas ao ThemeContext. Quando o tema muda, somente os consumidores do ThemeContext sofrem re-render.
Exemplo Prático: Antes do Splitting
// ❌ Contexto monolítico - problema de performance
import React, { createContext, useState } from 'react';
export const AppContext = createContext();
export function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState('light');
const [notifications, setNotifications] = useState([]);
const [settings, setSettings] = useState({});
return (
<AppContext.Provider value={{ user, setUser, theme, setTheme, notifications, setNotifications, settings, setSettings }}>
{children}
</AppContext.Provider>
);
}
Agora vamos ao componente que consome:
// Componente que só precisa do tema
function Header() {
const { theme, setTheme } = useContext(AppContext);
return (
<header style={{ background: theme === 'light' ? '#fff' : '#333' }}>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</header>
);
}
// Componente que só precisa do usuário
function Profile() {
const { user } = useContext(AppContext);
return <div>User: {user?.name}</div>;
}
Quando o tema muda no Header, o Profile também sofre re-render, mesmo sem usar a informação de tema. Se você tiver 20 componentes diferentes, são 20 re-renders desnecessários.
Exemplo Prático: Depois do Splitting
// ✅ Context especializado - apenas para tema
import React, { createContext, useState } from 'react';
export const ThemeContext = createContext();
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = React.useMemo(
() => ({ theme, setTheme }),
[theme]
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
// Context especializado - apenas para usuário
export const UserContext = createContext();
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const value = React.useMemo(
() => ({ user, setUser }),
[user]
);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
// Context especializado - apenas para notificações
export const NotificationContext = createContext();
export function NotificationProvider({ children }) {
const [notifications, setNotifications] = useState([]);
const value = React.useMemo(
() => ({ notifications, setNotifications }),
[notifications]
);
return (
<NotificationContext.Provider value={value}>
{children}
</NotificationContext.Provider>
);
}
Agora a estrutura no App:
function App() {
return (
<ThemeProvider>
<UserProvider>
<NotificationProvider>
<Header />
<Profile />
<Sidebar />
</NotificationProvider>
</UserProvider>
</ThemeProvider>
);
}
// Header só sofre re-render quando tema muda
function Header() {
const { theme, setTheme } = useContext(ThemeContext);
return (
<header style={{ background: theme === 'light' ? '#fff' : '#333' }}>
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
Toggle Theme
</button>
</header>
);
}
// Profile só sofre re-render quando usuário muda
function Profile() {
const { user } = useContext(UserContext);
return <div>User: {user?.name}</div>;
}
Agora, quando o tema muda, apenas Header sofre re-render. Profile e todos os componentes que consomem apenas UserContext ou NotificationContext permanecem intactos.
Otimizando Performance com useMemo e useCallback
Entendendo o Problema de Referência
Um erro comum é não memoizar o objeto value passado ao Provider. A cada render do Provider, um novo objeto é criado, causando re-render de todos os consumidores mesmo que os dados não tenham mudado.
// ❌ Erro - novo objeto a cada render
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
// Novo objeto a cada render, mesmo se theme não mudou
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ✅ Correto - objeto memoizado
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = React.useMemo(
() => ({ theme, setTheme }),
[theme] // Só recria o objeto se theme mudar
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
Callbacks e Performance
Quando você expõe funções através do Context, também deve memoizá-las com useCallback:
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
// Sem useCallback - nova função a cada render
const login = async (email, password) => {
setLoading(true);
// chamada à API
setLoading(false);
};
// ✅ Com useCallback - mesma função referencial
const login = useCallback(async (email, password) => {
setLoading(true);
// chamada à API
setLoading(false);
}, []);
const value = useMemo(
() => ({ user, login, loading }),
[user, loading, login]
);
return (
<UserContext.Provider value={value}>
{children}
</UserContext.Provider>
);
}
Separando Dados de Ações: O Padrão de Divisão Avançada
O Problema de Misturar Estado e Setters
Quando você coloca dados e suas funções de atualização no mesmo Context, consumidores que só querem ler dados ainda sofrem re-render quando as ações mudam. A solução é criar dois Contexts: um para dados e outro para ações.
// Context para dados (apenas leitura)
export const UserDataContext = createContext();
// Context para ações (apenas escrita)
export const UserActionsContext = createContext();
export function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(false);
// Valores que mudam frequentemente
const dataValue = useMemo(
() => ({ user, loading }),
[user, loading]
);
// Ações que não mudam (memoizadas com useCallback)
const login = useCallback(async (email, password) => {
setLoading(true);
try {
// chamada à API
const response = await fetch('/api/login', {
method: 'POST',
body: JSON.stringify({ email, password })
});
const userData = await response.json();
setUser(userData);
} finally {
setLoading(false);
}
}, []);
const logout = useCallback(() => {
setUser(null);
}, []);
const actionsValue = useMemo(
() => ({ login, logout }),
[login, logout]
);
return (
<UserDataContext.Provider value={dataValue}>
<UserActionsContext.Provider value={actionsValue}>
{children}
</UserActionsContext.Provider>
</UserDataContext.Provider>
);
}
Agora você pode criar hooks customizados para abstração:
// Hook para ler dados
export function useUserData() {
const context = useContext(UserDataContext);
if (!context) {
throw new Error('useUserData deve estar dentro de UserProvider');
}
return context;
}
// Hook para acessar ações
export function useUserActions() {
const context = useContext(UserActionsContext);
if (!context) {
throw new Error('useUserActions deve estar dentro de UserProvider');
}
return context;
}
// Componente que só lê dados - re-render apenas quando user/loading mudam
function UserCard() {
const { user, loading } = useUserData();
if (loading) return <div>Carregando...</div>;
return <div>{user?.name}</div>;
}
// Componente que precisa apenas das ações
function LoginButton() {
const { login } = useUserActions();
return (
<button onClick={() => login('test@example.com', 'password')}>
Login
</button>
);
}
A beleza deste padrão é que LoginButton nunca sofre re-render quando os dados de usuário mudam, porque consome apenas UserActionsContext, que tem referência estável graças ao useCallback.
Evitando Re-renders Desnecessários: Técnicas Avançadas
Selector Pattern com useContext
Quando você realmente precisa de um Context grande, use selectors para extrair apenas a parte que precisa:
// Grande Context consolidado (quando necessário)
export const AppContext = createContext();
export function AppProvider({ children }) {
const [appState, setAppState] = useState({
user: null,
theme: 'light',
notifications: [],
settings: {}
});
const value = useMemo(() => appState, [appState]);
return (
<AppContext.Provider value={value}>
{children}
</AppContext.Provider>
);
}
// ✅ Usar Selector para extrair apenas o que você precisa
export function useAppSelector(selector) {
const context = useContext(AppContext);
return useMemo(() => selector(context), [context, selector]);
}
// Uso
function Header() {
// Selector função que extrai apenas tema
const theme = useAppSelector(state => state.theme);
return <header style={{ background: theme === 'light' ? '#fff' : '#333' }} />;
}
function Profile() {
// Selector função que extrai apenas usuário
const user = useAppSelector(state => state.user);
return <div>{user?.name}</div>;
}
Atom Pattern com Composição
Para máxima granularidade, implemente um padrão de átomos similares ao Jotai:
// Sistema de átomos minimalista
const atomStore = new Map();
function createAtom(key, initialValue) {
const context = createContext(initialValue);
atomStore.set(key, context);
return { context, key };
}
export const themeAtom = createAtom('theme', 'light');
export const userAtom = createAtom('user', null);
function AtomProvider({ atom, initialValue, children }) {
const [value, setValue] = useState(initialValue);
const atomValue = useMemo(
() => ({ value, setValue }),
[value]
);
return (
<atom.context.Provider value={atomValue}>
{children}
</atom.context.Provider>
);
}
// Uso
function App() {
return (
<AtomProvider atom={themeAtom} initialValue="light">
<AtomProvider atom={userAtom} initialValue={null}>
<Header />
<Profile />
</AtomProvider>
</AtomProvider>
);
}
// Hook para consumir átomos
function useAtom(atom) {
const { value, setValue } = useContext(atom.context);
return [value, setValue];
}
function Header() {
const [theme, setTheme] = useAtom(themeAtom);
return (
<button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
{theme}
</button>
);
}
Conclusão
Ao trabalhar com useContext em aplicações escaláveis, três princípios transformam seu código de uma bomba de re-renders para uma máquina bem-oleada. Primeiro, sempre use Context Splitting: divida grandes Contexts em especializados por domínio. Segundo, memoize agressivamente com useMemo e useCallback, garantindo que referências de objetos e funções só mudem quando realmente necessário. Terceiro, considere separar dados de ações quando seu estado é volátil, mantendo ações com referência estável enquanto dados variam. Essas três técnicas combinadas eliminam praticamente todos os re-renders desnecessários sem você precisar migrar para Redux ou Zustand.