Noções Básicas de Padrões de Componentes
10 exemplos para você começar com Padrões de Componentes - 7 básicos e 3 intermediários.
Busque em todas as páginas da documentação
10 exemplos para você começar com Padrões de Componentes - 7 básicos e 3 intermediários.
Estes padrões se aplicam a qualquer projeto React Native. Os trechos assumem um aplicativo TypeScript padrão Expo SDK 57 criado com create-expo-app.
npx create-expo-app@latest MyPatternsApp --template blank-typescript
cd MyPatternsApp
npx expo startSubstitua App.tsx por cada exemplo para executá-lo imediatamente. Nenhum pacote adicional é necessário além do modelo padrão do Expo - os padrões são sobre como você estrutura os componentes, não quais bibliotecas você instala.
Ferramentas: Estes exemplos visam Expo SDK 57 (
expo~57.0.4), React Native 0.86.0 e React 19.2.3.
Uma tela cuida das preocupações de roteamento, carregamento de dados e efeitos colaterais. Um componente apresentacional recebe props e renderiza UI - sem fetches, sem chamadas de navegação.
import { useEffect, useState } from "react";
import { ActivityIndicator, StyleSheet, Text, View } from "react-native";
type User = { id: string; name: string };
function ProfileView({ user }: { user: User }) {
return (
<View style={styles.card}>
<Text style={styles.name}>{user.name}</Text>
<Text style={styles.meta}>ID: {user.id}</Text>
</View>
);
}
export default function ProfileScreen() {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
(async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/users/1");
const data = (await response.json()) as User;
if (!cancelled) {
setUser(data);
setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, []);
if (loading) return <ActivityIndicator style={styles.loader} />;
if (!user) return <Text style={styles.error}>Usuário não encontrado</Text>;
return <ProfileView user={user} />;
}
const styles = StyleSheet.create({
loader: { flex: 1, justifyContent: "center" },
error: { flex: 1, textAlign: "center", marginTop: 48, color: "#b91c1c" },
card: { flex: 1, padding: 24, gap: 4 },
name: { fontSize: 22, fontWeight: "700" },
meta: { fontSize: 14, color: "#6b7280" },
});ProfileScreen decide quando mostrar carregamento, erro ou conteúdo - essa orquestração pertence ao limite da tela.ProfileView é pura apresentação: dado um user, ele sempre renderiza a mesma árvore - ideal para Storybook e testes de snapshot.app/ (Expo Router) ou em uma pasta screens/; apresentadores vivem ao lado delas em components/.fetch ou router.push, divida-o novamente - responsabilidades mistas se tornam "god screens" rapidamente.Relacionado: Container/Apresentador no Mobile - nomenclatura, testes e limites de dados | Anti-Padrões: God Screens - quando a divisão falha.
Agrupe tudo que uma funcionalidade precisa em um único diretório para que os engenheiros possam se familiarizar com "Pedidos" sem procurar pelo repositório.
src/features/orders/
├── index.ts # exports públicos para a funcionalidade
├── screens/
│ └── OrdersScreen.tsx # entrada da rota, conecta hooks + apresentador
├── components/
│ └── OrderList.tsx # lista apresentacional
├── hooks/
│ └── useOrders.ts # fetch, refresh, paginação
└── types.ts # Order, OrderStatus, etc.// src/features/orders/index.ts
export { OrdersScreen } from "./screens/OrdersScreen";
export type { Order } from "./types";screens/ contém containers voltados para rotas; components/ contém UI reutilizável dentro da funcionalidade.hooks/ mantém o comportamento com estado fora do JSX - as telas permanecem finas camadas de orquestração.index.ts é a API pública da funcionalidade - outras funcionalidades importam daqui, não de caminhos profundos.src/components/ui/; pastas de funcionalidades possuem composição específica do produto.Relacionado: Melhores Práticas de Padrões de Componentes - consistência sem super-abstração.
Tipos de props explícitos documentam qual UI um componente precisa e evitam que telas vazem detalhes de implementação.
import { Pressable, StyleSheet, Text, View } from "react-native";
type OrderRowProps = {
title: string;
total: string;
status: "pending" | "shipped" | "delivered";
onPress: () => void;
};
export function OrderRow({ title, total, status, onPress }: OrderRowProps) {
return (
<Pressable style={styles.row} onPress={onPress} accessibilityRole="button">
<View style={styles.textBlock}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.total}>{total}</Text>
</View>
<Text style={[styles.badge, styles[`badge_${status}`]]}>{status}</Text>
</Pressable>
);
}
const styles = StyleSheet.create({
row: {
flexDirection: "row",
alignItems: "center",
padding: 16,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e5e7eb",
},
textBlock: { flex: 1, gap: 2 },
title: { fontSize: 16, fontWeight: "600" },
total: { fontSize: 14, color: "#6b7280" },
badge: { fontSize: 12, fontWeight: "600", textTransform: "capitalize" },
badge_pending: { color: "#d97706" },
badge_shipped: { color: "#2563eb" },
badge_delivered: { color: "#16a34a" },
});
export default function App() {
return (
<View style={{ flex: 1, paddingTop: 48 }}>
<OrderRow
title="Fones de ouvido sem fio"
total="$129.00"
status="shipped"
onPress={() => {}}
/>
</View>
);
}status) tornam estados impossíveis não representáveis - o mapa de estilos de badge permanece exaustivo.navigation ou queryClient.accessibilityRole="button" em linhas Pressable dá ao VoiceOver/TalkBack um papel correto sem wrappers extras.export type OrderRowProps) para que histórias do Storybook e testes compartilhem o mesmo contrato.Relacionado: Container/Apresentador no Mobile - quais props cruzam o limite do container.
Mova o estado de atualização, paginação ou alternância para um hook para que várias telas reutilizem o mesmo comportamento sem copiar e colar blocos de useState.
import { useCallback, useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
function useRefresh(onRefresh: () => Promise<void>) {
const [refreshing, setRefreshing] = useState(false);
const refresh = useCallback(async () => {
setRefreshing(true);
try {
await onRefresh();
} finally {
setRefreshing(false);
}
}, [onRefresh]);
return { refreshing, refresh };
}
export default function App() {
const [lastSynced, setLastSynced] = useState("Nunca");
const { refreshing, refresh } = useRefresh(async () => {
await new Promise((resolve) => setTimeout(resolve, 800));
setLastSynced(new Date().toLocaleTimeString());
});
return (
<View style={styles.container}>
<Text style={styles.label}>Última sincronização: {lastSynced}</Text>
<Pressable
style={[styles.button, refreshing && styles.buttonDisabled]}
onPress={refresh}
disabled={refreshing}
>
<Text style={styles.buttonText}>
{refreshing ? "Atualizando…" : "Atualizar"}
</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, justifyContent: "center", alignItems: "center", gap: 16 },
label: { fontSize: 16, color: "#374151" },
button: { backgroundColor: "#2563eb", paddingHorizontal: 20, paddingVertical: 12, borderRadius: 10 },
buttonDisabled: { opacity: 0.6 },
buttonText: { color: "#fff", fontWeight: "600" },
});use* podem conter estado de UI (atualizando, expandido, índice da etapa) - não apenas dados remotos.refresh estável via useCallback para que o onRefresh do FlatList não sobrecarregue a memoização do filho.refreshing; a tela o conecta ao RefreshControl ou ao disabled do botão.Relacionado: Hooks Personalizados para Lógica de UI - regras de extração e "escape hatches" de prop drilling.
Compartilhe o estado das abas via context para que os consumidores componham Tabs, TabList e TabPanel sem prop drilling de activeTab por todos os filhos.
import {
createContext,
useContext,
useState,
type ReactNode,
} from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
type TabsContextValue = {
active: string;
setActive: (value: string) => void;
};
const TabsContext = createContext<TabsContextValue | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error("Subcomponentes de Tabs devem renderizar dentro de <Tabs>");
return ctx;
}
function Tabs({
defaultValue,
children,
}: {
defaultValue: string;
children: ReactNode;
}) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
<View style={styles.root}>{children}</View>
</TabsContext.Provider>
);
}
function TabList({ children }: { children: ReactNode }) {
return <View style={styles.tabList}>{children}</View>;
}
function Tab({ value, children }: { value: string; children: ReactNode }) {
const { active, setActive } = useTabsContext();
const selected = active === value;
return (
<Pressable
onPress={() => setActive(value)}
style={[styles.tab, selected && styles.tabSelected]}
accessibilityRole="tab"
accessibilityState={{ selected }}
>
<Text style={[styles.tabText, selected && styles.tabTextSelected]}>
{children}
</Text>
</Pressable>
);
}
function TabPanel({ value, children }: { value: string; children: ReactNode }) {
const { active } = useTabsContext();
if (active !== value) return null;
return <View style={styles.panel}>{children}</View>;
}
export default function App() {
return (
<Tabs defaultValue="upcoming">
<TabList>
<Tab value="upcoming">Próximos</Tab>
<Tab value="past">Passados</Tab>
</TabList>
<TabPanel value="upcoming">
<Text>Nenhum evento próximo.</Text>
</TabPanel>
<TabPanel value="past">
<Text>Três eventos passados.</Text>
</TabPanel>
</Tabs>
);
}
const styles = StyleSheet.create({
root: { flex: 1, padding: 24, gap: 16 },
tabList: { flexDirection: "row", gap: 8 },
tab: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: 999, backgroundColor: "#f3f4f6" },
tabSelected: { backgroundColor: "#dbeafe" },
tabText: { color: "#4b5563", fontWeight: "600" },
tabTextSelected: { color: "#1d4ed8" },
panel: { padding: 12, backgroundColor: "#f9fafb", borderRadius: 12 },
});Tabs contém active); os filhos leem/escrevem via context - a marca registrada da API de componentes compostos.Tab renderizado fora de Tabs em tempo de desenvolvimento em vez de falhar silenciosamente.TabPanel retorna null para painéis inativos - troque por montagem preguiçosa se os painéis forem caros.TabList como um subcomponente separado para que as equipes possam trocar o layout sem alterar a lógica de estado.Relacionado: Componentes Compostos - cards, grupos de campos e design de API.
Torne a divisão óbvia nos nomes dos arquivos para que a revisão de código mostre instantaneamente qual arquivo pode buscar dados e qual é UI pura.
// Apresentador - sem efeitos colaterais
import { StyleSheet, Text, View } from "react-native";
export type WeatherPresenterProps = {
city: string;
temperature: number;
unit: "C" | "F";
};
export function WeatherPresenter({ city, temperature, unit }: WeatherPresenterProps) {
return (
<View style={styles.card}>
<Text style={styles.city}>{city}</Text>
<Text style={styles.temp}>
{temperature}°{unit}
</Text>
</View>
);
}
// Container - carrega dados, mapeia para props do apresentador
import { useEffect, useState } from "react";
import { ActivityIndicator, StyleSheet, Text } from "react-native";
import { WeatherPresenter } from "./WeatherPresenter";
export function WeatherContainer() {
const [data, setData] = useState<WeatherPresenterProps | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
const timer = setTimeout(() => {
setData({ city: "Austin", temperature: 72, unit: "F" });
setLoading(false);
}, 400);
return () => clearTimeout(timer);
}, []);
if (loading) return <ActivityIndicator style={styles.loader} />;
if (!data) return <Text>Clima indisponível</Text>;
return <WeatherPresenter {...data} />;
}
const styles = StyleSheet.create({
loader: { flex: 1, justifyContent: "center" },
card: { flex: 1, justifyContent: "center", alignItems: "center", gap: 8 },
city: { fontSize: 18, color: "#6b7280" },
temp: { fontSize: 48, fontWeight: "700" },
});
export default function App() {
return <WeatherContainer />;
}WeatherPresenter nunca importa fetch, useEffect ou navegação - se importar, renomeie-o; agora é uma tela.WeatherContainer mapeia formas remotas para props do apresentador para que mudanças na API não se espalhem para os estilos.{...data} apenas quando os nomes dos campos coincidirem; prefira mapeamento explícito quando os nomes da API diferirem do vocabulário da UI.Relacionado: Container/Apresentador no Mobile - limites assíncronos e duplos de teste.
childrenAceite children para shells de layout flexíveis em vez de adicionar uma prop para cada slot possível.
import { type ReactNode } from "react";
import { StyleSheet, Text, View } from "react-native";
function Screen({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
return (
<View style={styles.screen}>
<Text style={styles.title}>{title}</Text>
<View style={styles.body}>{children}</View>
</View>
);
}
function Card({ children }: { children: ReactNode }) {
return <View style={styles.card}>{children}</View>;
}
export default function App() {
return (
<Screen title="Caixa de Entrada">
<Card>
<Text style={styles.row}>Envio atrasado - toque para detalhes.</Text>
</Card>
<Card>
<Text style={styles.row}>Seu reembolso foi processado.</Text>
</Card>
</Screen>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, gap: 16, backgroundColor: "#f9fafb" },
title: { fontSize: 28, fontWeight: "700" },
body: { gap: 12 },
card: {
backgroundColor: "#fff",
borderRadius: 12,
padding: 16,
borderWidth: StyleSheet.hairlineWidth,
borderColor: "#e5e7eb",
},
row: { fontSize: 15, lineHeight: 22 },
});children mantém as APIs de Screen e Card pequenas - os consumidores decidem o que vai dentro sem novas props por layout.ReactNode aceita elementos, strings, fragmentos e null - o tipo renderizável mais amplo para props de slot.footer={<Actions />}) ao lado de children.Relacionado: Render Props & Padrões de Slot - slots nomeados e renderizadores de lista.
Uma fatia completa de funcionalidade: a tela é uma camada fina de cola; o hook é o dono do trabalho assíncrono; o apresentador renderiza props.
import { useCallback, useEffect, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
RefreshControl,
StyleSheet,
Text,
View,
} from "react-native";
type Todo = { id: number; title: string; completed: boolean };
function useTodos() {
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
const response = await fetch("https://jsonplaceholder.typicode.com/todos?_limit=8");
if (!response.ok) throw new Error("Falha ao carregar todos");
return (await response.json()) as Todo[];
}, []);
const refresh = useCallback(async () => {
try {
setError(null);
setTodos(await load());
} catch (e) {
setError(e instanceof Error ? e.message : "Erro desconhecido");
} finally {
setLoading(false);
}
}, [load]);
useEffect(() => {
refresh();
}, [refresh]);
return { todos, loading, error, refresh };
}
function TodoListPresenter({
todos,
refreshing,
onRefresh,
}: {
todos: Todo[];
refreshing: boolean;
onRefresh: () => void;
}) {
return (
<FlatList
data={todos}
keyExtractor={(item) => String(item.id)}
contentContainerStyle={styles.list}
refreshControl={
<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />
}
renderItem={({ item }) => (
<View style={styles.row}>
<Text style={styles.title}>{item.title}</Text>
<Text style={styles.meta}>{item.completed ? "Concluído" : "Aberto"}</Text>
</View>
)}
/>
);
}
export default function TodoScreen() {
const { todos, loading, error, refresh } = useTodos();
const [refreshing, setRefreshing] = useState(false);
const handleRefresh = useCallback(async () => {
setRefreshing(true);
await refresh();
setRefreshing(false);
}, [refresh]);
if (loading) return <ActivityIndicator style={styles.centered} />;
if (error) {
return (
<View style={styles.centered}>
<Text style={styles.error}>{error}</Text>
<Pressable style={styles.button} onPress={refresh}>
<Text style={styles.buttonText}>Tentar Novamente</Text>
</Pressable>
</View>
);
}
return (
<TodoListPresenter
todos={todos}
refreshing={refreshing}
onRefresh={handleRefresh}
/>
);
}
const styles = StyleSheet.create({
centered: { flex: 1, justifyContent: "center", alignItems: "center", gap: 12 },
list: { padding: 16, gap: 8 },
row: {
padding: 14,
backgroundColor: "#fff",
borderRadius: 10,
borderWidth: StyleSheet.hairlineWidth,
borderColor: "#e5e7eb",
gap: 4,
},
title: { fontSize: 15, fontWeight: "600" },
meta: { fontSize: 13, color: "#6b7280" },
error: { color: "#b91c1c", fontSize: 16 },
button: { backgroundColor: "#2563eb", paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8 },
buttonText: { color: "#fff", fontWeight: "600" },
});useTodos é o hook de dados da funcionalidade - telas e testes importam o mesmo módulo.TodoScreen lida com os ramos de carregamento/erro; TodoListPresenter assume dados de lista em caso de sucesso.refreshing) pode viver na tela ou em um hook useRefresh dedicado - mantenha-o fora do apresentador sempre que possível.types.ts, mova arquivos para features/todos/, exporte TodoScreen de index.ts.Relacionado: Hooks Personalizados para Lógica de UI - quando mesclar ou dividir hooks | Anti-Padrões: God Screens - sinais de que a tela está fazendo demais.
Entregue a renderização da lista de volta ao pai quando os UIs de vazio, carregamento e erro diferirem por tela, mas a lógica de paginação permanecer compartilhada.
import { type ReactNode } from "react";
import { FlatList, StyleSheet, Text, View } from "react-native";
type PaginatedListProps<T> = {
data: T[];
loading: boolean;
renderItem: (item: T) => ReactNode;
renderEmpty: () => ReactNode;
keyExtractor: (item: T) => string;
};
function PaginatedList<T>({
data,
loading,
renderItem,
renderEmpty,
keyExtractor,
}: PaginatedListProps<T>) {
if (loading) {
return <View style={styles.centered}>{renderEmpty()}</View>;
}
return (
<FlatList
data={data}
keyExtractor={keyExtractor}
contentContainerStyle={data.length === 0 ? styles.centered : styles.list}
ListEmptyComponent={renderEmpty}
renderItem={({ item }) => <>{renderItem(item)}</>}
/>
);
}
type Message = { id: string; body: string };
export default function App() {
const messages: Message[] = [];
return (
<PaginatedList
data={messages}
loading={false}
keyExtractor={(item) => item.id}
renderItem={(item) => (
<View style={styles.row}>
<Text>{item.body}</Text>
</View>
)}
renderEmpty={() => (
<View style={styles.empty}>
<Text style={styles.emptyTitle}>Caixa de entrada vazia</Text>
<Text style={styles.emptyBody}>Novas mensagens aparecerão aqui.</Text>
</View>
)}
/>
);
}
const styles = StyleSheet.create({
list: { padding: 16, gap: 8 },
centered: { flexGrow: 1, justifyContent: "center", alignItems: "center" },
row: { padding: 12, backgroundColor: "#fff", borderRadius: 8 },
empty: { alignItems: "center", gap: 8, padding: 24 },
emptyTitle: { fontSize: 18, fontWeight: "700" },
emptyBody: { fontSize: 14, color: "#6b7280", textAlign: "center" },
});renderItem, renderEmpty) permitem que cada tela personalize a UI da linha e do estado vazio sem bifurcar o shell da lista.<T> mantém a lista reutilizável entre os tipos Message, Order e Notification.ListEmptyComponent e um branch inicial de loading cobrem os dois caminhos vazios que FlatList precisa no mobile.Relacionado: Render Props & Padrões de Slot - props de slot vs render props em árvores profundas.
Retorne estado e manipuladores de eventos de um hook apenas de lógica; deixe os componentes do design system cuidarem das cores, espaçamento e feedback da plataforma.
import { useCallback, useState, type ReactNode } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";
function useDisclosure(initial = false) {
const [open, setOpen] = useState(initial);
const toggle = useCallback(() => setOpen((value) => !value), []);
const close = useCallback(() => setOpen(false), []);
return { open, toggle, close };
}
function AccordionRow({
title,
children,
}: {
title: string;
children: ReactNode;
}) {
const { open, toggle } = useDisclosure();
return (
<View style={styles.row}>
<Pressable
onPress={toggle}
style={styles.header}
accessibilityRole="button"
accessibilityState={{ expanded: open }}
>
<Text style={styles.title}>{title}</Text>
<Text style={styles.chevron}>{open ? "−" : "+"}</Text>
</Pressable>
{open ? <Text style={styles.body}>{children}</Text> : null}
</View>
);
}
export default function App() {
return (
<View style={styles.screen}>
<AccordionRow title="Envio">Chega em 3–5 dias úteis.</AccordionRow>
<AccordionRow title="Devoluções">Devoluções gratuitas em 30 dias.</AccordionRow>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, padding: 24, gap: 12 },
row: {
backgroundColor: "#fff",
borderRadius: 12,
borderWidth: StyleSheet.hairlineWidth,
borderColor: "#e5e7eb",
overflow: "hidden",
},
header: {
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
padding: 16,
},
title: { fontSize: 16, fontWeight: "600" },
chevron: { fontSize: 20, color: "#6b7280" },
body: { paddingHorizontal: 16, paddingBottom: 16, color: "#4b5563", lineHeight: 20 },
});useDisclosure é headless - nenhuma importação de View é necessária, então o mesmo hook alimenta modais, menus e acordions.accessibilityState={{ expanded: open }} conecta semântica de acordeão para leitores de tela.Relacionado: Componentes Headless - primitivas apenas de lógica para design systems | Padrões Polimórficos & AsChild - elementos hospedeiros flexíveis para primitivas.
Versões da Stack: Esta página foi escrita para React 19.2.3, React Native 0.86.0 e Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última atualização: 16 de jul. de 2026