Component Patterns Basics
10 examples to get you started with Component Patterns - 7 basic and 3 intermediate.
Search across all documentation pages
10 examples to get you started with Component Patterns - 7 basic and 3 intermediate.
These patterns apply to any React Native project. The snippets assume a standard Expo SDK 57 TypeScript app scaffolded with create-expo-app.
npx create-expo-app@latest MyPatternsApp --template blank-typescript
cd MyPatternsApp
npx expo startReplace App.tsx with each example to run it immediately. No extra packages are required beyond the default Expo template - patterns are about how you structure components, not which libraries you install.
Tooling: These examples target Expo SDK 57 (
expo~57.0.4), React Native 0.86.0, and React 19.2.3.
A screen owns routing concerns, data loading, and side effects. A presentational component receives props and renders UI - no fetches, no navigation calls.
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}>User not found</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 decides when to show loading, error, or content - that orchestration belongs at the screen boundaryProfileView is pure presentation: given a user, it always renders the same tree - ideal for Storybook and snapshot testsapp/ (Expo Router) or a screens/ folder; presenters live beside them under components/fetch or router.push, split it again - mixed responsibilities become god screens fastRelated: Container/Presenter on Mobile - naming, testing, and data boundaries | Anti-Patterns: God Screens - when the split fails
Group everything a feature needs in one directory so engineers can onboard to "Orders" without hunting across the repo.
src/features/orders/
├── index.ts # public exports for the feature
├── screens/
│ └── OrdersScreen.tsx # route entry, wires hooks + presenter
├── components/
│ └── OrderList.tsx # presentational list
├── hooks/
│ └── useOrders.ts # fetch, refresh, pagination
└── types.ts # Order, OrderStatus, etc.// src/features/orders/index.ts
export { OrdersScreen } from "./screens/OrdersScreen";
export type { Order } from "./types";screens/ holds route-facing containers; components/ holds reusable UI inside the featurehooks/ keeps stateful behavior out of JSX - screens stay thin orchestration layersindex.ts is the feature's public API - other features import from here, not deep pathssrc/components/ui/; feature folders own product-specific compositionRelated: Component Patterns Best Practices - consistency without over-abstraction
Explicit prop types document what UI a component needs and prevent screens from leaking implementation details.
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="Wireless earbuds"
total="$129.00"
status="shipped"
onPress={() => {}}
/>
</View>
);
}status) make impossible states unrepresentable - the badge style map stays exhaustivenavigation or queryClient objectsaccessibilityRole="button" on Pressable rows gives VoiceOver/TalkBack a correct role without extra wrappersexport type OrderRowProps) so Storybook stories and tests share the same contractRelated: Container/Presenter on Mobile - which props cross the container boundary
Move refresh, pagination, or toggle state into a hook so multiple screens reuse the same behavior without copy-pasting useState blocks.
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("Never");
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}>Last synced: {lastSynced}</Text>
<Pressable
style={[styles.button, refreshing && styles.buttonDisabled]}
onPress={refresh}
disabled={refreshing}
>
<Text style={styles.buttonText}>
{refreshing ? "Refreshing…" : "Refresh"}
</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* can hold UI state (refreshing, expanded, step index) - not only remote datarefresh callback via useCallback so FlatList onRefresh does not thrash child memoizationrefreshing flag; the screen wires it to RefreshControl or button disabledRelated: Custom Hooks for UI Logic - extraction rules and prop-drilling escape hatches
Share tab state through context so consumers compose Tabs, TabList, and TabPanel without prop drilling activeTab through every child.
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("Tabs subcomponents must render inside <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">Upcoming</Tab>
<Tab value="past">Past</Tab>
</TabList>
<TabPanel value="upcoming">
<Text>No upcoming events.</Text>
</TabPanel>
<TabPanel value="past">
<Text>Three past events.</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 holds active); children read/write through context - the hallmark compound-component APITab rendered outside Tabs at dev time instead of silently failingTabPanel returns null for inactive panels - swap for lazy mounting if panels are expensiveTabList as a separate subcomponent so teams can swap layout without changing state logicRelated: Compound Components - cards, field groups, and API design
Make the split obvious in filenames so code review instantly shows which file is allowed to fetch and which is pure UI.
// Presenter - no side effects
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 - loads data, maps to presenter props
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>Weather unavailable</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 never imports fetch, useEffect, or navigation - if it does, rename it; it is a screen nowWeatherContainer maps remote shapes to presenter props so API churn does not ripple into styles{...data} only when field names align; prefer explicit mapping when API names differ from UI vocabularyRelated: Container/Presenter on Mobile - async boundaries and test doubles
Accept children for flexible layout shells instead of adding a prop for every possible slot.
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="Inbox">
<Card>
<Text style={styles.row}>Shipment delayed - tap for details.</Text>
</Card>
<Card>
<Text style={styles.row}>Your refund was processed.</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 keeps Screen and Card APIs small - consumers decide what goes inside without new props per layoutReactNode accepts elements, strings, fragments, and null - the widest renderable type for slot propsfooter={<Actions />}) alongside childrenRelated: Render Props & Slot Patterns - named slots and list renderers
A complete feature slice: the screen is a thin glue layer; the hook owns async work; the presenter renders 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("Failed to load 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 : "Unknown error");
} 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 ? "Done" : "Open"}</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}>Retry</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 is the feature's data hook - screens and tests import the same moduleTodoScreen handles loading/error branches; TodoListPresenter assumes happy-path list datarefreshing) can live in the screen or a dedicated useRefresh hook - keep it out of the presenter when possibletypes.ts, move files into features/todos/, export TodoScreen from index.tsRelated: Custom Hooks for UI Logic - when to merge or split hooks | Anti-Patterns: God Screens - signs the screen is doing too much
Hand list rendering back to the parent when empty, loading, and error UIs differ per screen but pagination logic stays shared.
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}>Inbox zero</Text>
<Text style={styles.emptyBody}>New messages will appear here.</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) let each screen customize row and empty UI without forking the list shell<T> keeps the list reusable across Message, Order, and Notification typesListEmptyComponent and an early loading branch cover the two empty paths FlatList needs on mobileRelated: Render Props & Slot Patterns - slot props vs render props on deep trees
Return state and event handlers from a logic-only hook; let design-system components own colors, spacing, and platform feedback.
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="Shipping">Arrives in 3–5 business days.</AccordionRow>
<AccordionRow title="Returns">Free returns within 30 days.</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 is headless - no View imports required, so the same hook powers modals, menus, and accordionsaccessibilityState={{ expanded: open }} wires accordion semantics for screen readersRelated: Headless Components - logic-only primitives for design systems | Polymorphic & AsChild Patterns - flexible host elements for primitives
Stack versions: This page was written for React 19.2.3, React Native 0.86.0, and Expo SDK 57 (
expo~57.0.4).
Reviewed by Chris St. John·Last updated Jul 16, 2026