Conceptos básicos de patrones de componentes
10 ejemplos para comenzar con patrones de componentes - 7 básicos y 3 intermedios.
Busca en todas las páginas de la documentación
10 ejemplos para comenzar con patrones de componentes - 7 básicos y 3 intermedios.
Estos patrones se aplican a cualquier proyecto de React Native. Los fragmentos asumen una aplicación TypeScript estándar de Expo SDK 57 generada con create-expo-app.
npx create-expo-app@latest MyPatternsApp --template blank-typescript
cd MyPatternsApp
npx expo startReemplaza App.tsx con cada ejemplo para ejecutarlo inmediatamente. No se requieren paquetes adicionales más allá de la plantilla predeterminada de Expo - los patrones tratan sobre cómo estructuras tus componentes, no sobre qué bibliotecas instalas.
Herramientas: Estos ejemplos tienen como objetivo Expo SDK 57 (
expo~57.0.4), React Native 0.86.0 y React 19.2.3.
Una pantalla es propietaria de las preocupaciones de enrutamiento, carga de datos y efectos secundarios. Un componente presentacional recibe props y renderiza UI - sin fetchs, sin llamadas de navegación.
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}>Usuario no 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 cuándo mostrar cargando, error o contenido - esa orquestación pertenece al límite de la pantallaProfileView es presentación pura: dado un user, siempre renderiza el mismo árbol - ideal para Storybook y snapshot testsapp/ (Expo Router) o en una carpeta screens/; los presentadores viven junto a ellas bajo components/fetch o router.push, divídelo de nuevo - las responsabilidades mixtas se convierten en god screens rápidamenteRelacionado: Container/Presenter en Mobile - nombres, pruebas y límites de datos | Anti-Patrones: God Screens - cuando el split falla
Agrupa todo lo que una característica necesita en un directorio para que los ingenieros puedan integrarse en "Órdenes" sin buscar en todo el repositorio.
src/features/orders/
├── index.ts # exportaciones públicas de la característica
├── screens/
│ └── OrdersScreen.tsx # entrada de ruta, conecta hooks + presenter
├── components/
│ └── OrderList.tsx # lista presentacional
├── hooks/
│ └── useOrders.ts # fetch, refresh, paginación
└── types.ts # Order, OrderStatus, etc.// src/features/orders/index.ts
export { OrdersScreen } from "./screens/OrdersScreen";
export type { Order } from "./types";screens/ contiene contenedores que enfrentan rutas; components/ contiene UI reutilizable dentro de la característicahooks/ mantiene el comportamiento con estado fuera de JSX - las pantallas permanecen como capas de orquestación finasindex.ts es la API pública de la característica - otras características importan desde aquí, no desde rutas profundassrc/components/ui/; las carpetas de características son propietarias de la composición específica del productoRelacionado: Mejores prácticas de patrones de componentes - consistencia sin sobre-abstracción
Los tipos de props explícitos documentan qué UI necesita un componente y evitan que las pantallas filtren detalles de implementación.
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="Auriculares inalámbricos"
total="$129.00"
status="shipped"
onPress={() => {}}
/>
</View>
);
}status) hacen que los estados imposibles sean irrepresentables - el mapa de estilos del badge permanece exhaustivonavigation o queryClient sin procesaraccessibilityRole="button" en filas Pressable le da a VoiceOver/TalkBack un rol correcto sin envoltorios adicionalesexport type OrderRowProps) para que las historias de Storybook y las pruebas compartan el mismo contratoRelacionado: Container/Presenter en Mobile - qué props cruzan el límite del contenedor
Mueve el estado de actualización, paginación o alternancia a un hook para que múltiples pantallas reutilicen el mismo comportamiento sin copiar bloques 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 sincronización: {lastSynced}</Text>
<Pressable
style={[styles.button, refreshing && styles.buttonDisabled]}
onPress={refresh}
disabled={refreshing}
>
<Text style={styles.buttonText}>
{refreshing ? "Actualizando…" : "Actualizar"}
</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* pueden contener estado de UI (actualización, expandido, índice de paso) - no solo datos remotosrefresh estable vía useCallback para que FlatList onRefresh no thrash la memoización del hijorefreshing; la pantalla la conecta a RefreshControl o a disabled del botónRelacionado: Hooks personalizados para lógica de UI - reglas de extracción y escapes de prop drilling
Comparte el estado de la pestaña a través del contexto para que los consumidores compongan Tabs, TabList y TabPanel sin prop drilling activeTab a través de cada hijo.
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("Los subcomponentes de Tabs deben renderizarse 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óximas</Tab>
<Tab value="past">Pasadas</Tab>
</TabList>
<TabPanel value="upcoming">
<Text>No hay eventos próximos.</Text>
</TabPanel>
<TabPanel value="past">
<Text>Tres eventos pasados.</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 tiene active); los hijos leen/escriben a través del contexto - la marca de la API de componentes compuestosTab renderizado fuera de Tabs en el tiempo de desarrollo en lugar de fallar silenciosamenteTabPanel devuelve null para paneles inactivos - cambia a montaje perezoso si los paneles son costososTabList como un subcomponente separado para que los equipos puedan intercambiar diseño sin cambiar la lógica de estadoRelacionado: Componentes compuestos - tarjetas, grupos de campos y diseño de API
Haz el split obvio en los nombres de archivo para que la revisión de código muestre instantáneamente qué archivo puede traer datos y cuál es UI pura.
// Presentador - sin efectos secundarios
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>
);
}
// Contenedor - carga datos, asigna a props del presentador
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 no disponible</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 o navegación - si lo hace, renómbralo; ahora es una pantallaWeatherContainer asigna formas remotas a props del presentador para que la renovación de API no se filtre en los estilos{...data} solo cuando los nombres de campo se alinean; prefiere asignación explícita cuando los nombres de API difieren del vocabulario de UIRelacionado: Container/Presenter en Mobile - límites asincrónico y test doubles
Acepta children para shells de diseño flexible en lugar de agregar una prop para cada posible ranura.
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="Bandeja de entrada">
<Card>
<Text style={styles.row}>Envío retrasado - toca para detalles.</Text>
</Card>
<Card>
<Text style={styles.row}>Tu reembolso fue procesado.</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 mantiene las APIs de Screen y Card pequeñas - los consumidores deciden qué va dentro sin nuevas props por diseñoReactNode acepta elementos, cadenas, fragmentos y null - el tipo renderable más amplio para props de ranurafooter={<Actions />}) junto a childrenRelacionado: Render Props y patrones de ranura - ranuras nombradas y renderizadores de lista
Un slice de característica completo: la pantalla es una capa de pegamento fina; el hook es propietario del trabajo asincrónico; el presentador 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("No se pudieron cargar los 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 : "Error desconocido");
} 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 ? "Hecho" : "Abierto"}</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}>Reintentar</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 es el hook de datos de la característica - las pantallas y pruebas importan el mismo móduloTodoScreen maneja ramas de cargando/error; TodoListPresenter asume datos de lista de happy-pathrefreshing) puede vivir en la pantalla o en un hook dedicado useRefresh - mantenlo fuera del presentador cuando sea posibletypes.ts, mueve archivos a features/todos/, exporta TodoScreen desde index.tsRelacionado: Hooks personalizados para lógica de UI - cuándo fusionar o dividir hooks | Anti-Patrones: God Screens - signos de que la pantalla está haciendo demasiado
Devuelve la renderización de lista al padre cuando las UIs vacía, cargando y error difieren por pantalla pero la lógica de paginación permanece compartida.
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}>Bandeja vacía</Text>
<Text style={styles.emptyBody}>Los nuevos mensajes aparecerán aquí.</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) permiten que cada pantalla personalice las UIs de fila y vacío sin bifurcar el shell de lista<T> mantiene la lista reutilizable en Message, Order y tipos NotificationListEmptyComponent y una rama de loading temprana cubren las dos rutas vacías que FlatList necesita en móvilRelacionado: Render Props y patrones de ranura - props de ranura vs render props en árboles profundos
Devuelve estado y manejadores de eventos desde un hook de lógica pura; deja que los componentes del sistema de diseño decidan colores, espaciado y retroalimentación de 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="Envío">Llega en 3-5 días hábiles.</AccordionRow>
<AccordionRow title="Devoluciones">Devoluciones gratuitas dentro de 30 días.</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 es headless - sin importes de View requeridos, para que el mismo hook impulse modales, menús y acordeonesaccessibilityState={{ expanded: open }} conecta la semántica del acordeón para lectores de pantallaRelacionado: Componentes headless - primitivas de solo lógica para sistemas de diseño | Patrones polimórfico y AsChild - elementos host flexibles para primitivas
Versiones de stack: Esta página fue escrita para React 19.2.3, React Native 0.86.0 y Expo SDK 57 (
expo~57.0.4).
Revisado por Chris St. John·Última actualización: 16 jul 2026