Props, State & Re-renders on Mobile
How updates propagate on native threads vs the JS runtime.
Search across all documentation pages
How updates propagate on native threads vs the JS runtime.
Quick-reference recipe card - copy-paste ready.
import { useCallback, useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";
interface CounterProps {
label: string;
initialCount?: number;
}
export function Counter({ label, initialCount = 0 }: CounterProps) {
const [count, setCount] = useState(initialCount);
const increment = useCallback(() => {
setCount((prev) => prev + 1);
}, []);
return (
<View style={styles.row}>
<Text style={styles.label}>{label}</Text>
<Text style={styles.value}>{count}</Text>
<Pressable onPress={increment} style={styles.button}>
<Text style={styles.buttonText}>+1</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: "row", alignItems: "center", gap: 12, padding: 12 },
label: { flex: 1, fontSize: 16 },
value: { fontSize: 18, fontWeight: "700", minWidth: 32, textAlign: "right" },
button: { backgroundColor: "#2563eb", paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8 },
buttonText: { color: "#fff", fontWeight: "600" },
});When to reach for this: Any interactive screen where parent props configure children and local useState drives UI that must stay in sync with native views.
import { memo, useCallback, useMemo, useState } from "react";
import {
ActivityIndicator,
FlatList,
Pressable,
StyleSheet,
Text,
View,
} from "react-native";
interface Todo {
id: string;
title: string;
done: boolean;
}
interface TodoRowProps {
item: Todo;
onToggle: (id: string) => void;
}
const TodoRow = memo(function TodoRow({ item, onToggle }: TodoRowProps) {
return (
<Pressable
onPress={() => onToggle(item.id)}
style={[styles.row, item.done && styles.rowDone]}
>
<Text style={[styles.title, item.done && styles.titleDone]}>{item.title}</Text>
<Text style={styles.badge}>{item.done ? "Done" : "Open"}</Text>
</Pressable>
);
});
const SEED: Todo[] = [
{ id: "1", title: "Profile layout with View + Text", done: true },
{ id: "2", title: "Wire Pressable handlers", done: false },
{ id: "3", title: "Measure list scroll perf", done: false },
];
export default function TodoScreen() {
const [todos, setTodos] = useState<Todo[]>(SEED);
const [filter, setFilter] = useState<"all" | "open">("all");
const [loading, setLoading] = useState(false);
const visible = useMemo(
() => (filter === "open" ? todos.filter((t) => !t.done) : todos),
[todos, filter]
);
const openCount = useMemo(() => todos.filter((t) => !t.done).length, [todos]);
const handleToggle = useCallback((id: string) => {
setTodos((prev) =>
prev.map((t) => (t.id === id ? { ...t, done: !t.done } : t))
);
}, []);
const handleRefresh = useCallback(async () => {
setLoading(true);
await new Promise((r) => setTimeout(r, 400));
setTodos(SEED);
setLoading(false);
}, []);
return (
<View style={styles.screen}>
<View style={styles.header}>
<Text style={styles.heading}>Todos ({openCount} open)</Text>
<View style={styles.filters}>
{(["all", "open"] as const).map((key) => (
<Pressable
key={key}
onPress={() => setFilter(key)}
style={[styles.chip, filter === key && styles.chipActive]}
>
<Text style={[styles.chipText, filter === key && styles.chipTextActive]}>
{key === "all" ? "All" : "Open"}
</Text>
</Pressable>
))}
</View>
</View>
{loading ? (
<ActivityIndicator style={styles.loader} />
) : (
<FlatList
data={visible}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <TodoRow item={item} onToggle={handleToggle} />}
ItemSeparatorComponent={() => <View style={styles.separator} />}
contentContainerStyle={styles.list}
/>
)}
<Pressable onPress={handleRefresh} style={styles.refresh}>
<Text style={styles.refreshText}>Reset list</Text>
</Pressable>
</View>
);
}
const styles = StyleSheet.create({
screen: { flex: 1, backgroundColor: "#f8fafc" },
header: { padding: 16, gap: 12 },
heading: { fontSize: 22, fontWeight: "700", color: "#0f172a" },
filters: { flexDirection: "row", gap: 8 },
chip: { paddingHorizontal: 12, paddingVertical: 6, borderRadius: 999, backgroundColor: "#e2e8f0" },
chipActive: { backgroundColor: "#2563eb" },
chipText: { fontSize: 14, fontWeight: "600", color: "#334155" },
chipTextActive: { color: "#fff" },
list: { paddingHorizontal: 16, paddingBottom: 24 },
row: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
padding: 14,
borderRadius: 10,
gap: 8,
},
rowDone: { opacity: 0.7 },
title: { flex: 1, fontSize: 16, color: "#0f172a" },
titleDone: { textDecorationLine: "line-through", color: "#64748b" },
badge: { fontSize: 12, fontWeight: "600", color: "#2563eb" },
separator: { height: 8 },
loader: { marginTop: 32 },
refresh: { margin: 16, padding: 14, borderRadius: 10, backgroundColor: "#0f172a" },
refreshText: { color: "#fff", textAlign: "center", fontWeight: "600" },
});What this demonstrates:
item, onToggle) flow into a memoized row while state (todos, filter) lives in the screensetState updates avoid stale closures when toggling itemsuseMemo derives filtered data and open count without extra re-renders from recomputation aloneuseCallback keeps handleToggle stable so memo on TodoRow can skip unchanged rowsFlatList re-renders only visible rows - parent state still triggers reconciliation for those rowsuseState and useReducer schedule a re-render when the updater runs; the return value is the current render snapshot.setState calls in event handlers, promises, and timeouts into one render.User tap (UI thread)
→ event sent to JS thread
→ onPress / handler runs
→ setState schedules render
→ component function re-runs (JS)
→ React diffs virtual tree (JS)
→ Fabric commits changed shadow nodes
→ native layout + paint (UI thread)
| Pattern | API | Mobile note |
|---|---|---|
| Local UI state | useState | Form fields, toggles, sheet open state |
| Derived data | useMemo | Filtered lists - cheaper than storing duplicate state |
| Stable handlers | useCallback | Required for memo/FlatList row optimization |
| Mutable refs | useRef | Timers, animation handles - no re-render on .current change |
| Shared screen state | Context / store | Prefer colocated state until multiple tabs need it |
| Server data | TanStack Query / use | Keep fetching out of render; show skeletons while pending |
| Situation | Use props | Use state |
|---|---|---|
| Value owned by parent | ✓ | |
| Value changes only inside component | ✓ | |
| Sibling components need same value | Lift to parent ✓ | |
| Value from navigation params | Route prop ✓ | Copy to state if editable |
| Remote API payload | Pass down after fetch | Store in query cache |
import type { Dispatch, SetStateAction } from "react";
interface ScreenProps {
userId: string;
onSignOut: () => void;
}
// Explicit setter type for callbacks that forward setState
type SetTodos = Dispatch<SetStateAction<Todo[]>>;
// Discriminated unions for async UI - avoids impossible loading+error states
type LoadState =
| { status: "idle" }
| { status: "loading" }
| { status: "error"; message: string }
| { status: "success"; data: Todo[] };interface or type; export row props when using memo.SetStateAction<T> covers both value and updater function forms.Unstable inline callbacks in lists - onPress={() => toggle(item.id)} inside renderItem breaks memo every render. Fix: Pass id to a memoized row that calls a stable useCallback handler.
Storing derived data in state - Keeping openTodos in state alongside todos doubles updates and risks drift. Fix: Derive with useMemo from the source of truth.
Mutating state objects - todo.done = true then setTodos(todos) may skip re-render because the reference is unchanged. Fix: Return new objects/arrays from updaters.
Assuming immediate native paint after setState - JS finishes the render pass before native commits; rapid bursts still queue work. Fix: Batch related updates; avoid synchronous layout reads after every tick.
Context value recreated each render - value={{ user, theme }} re-renders all consumers. Fix: Memoize the context value object or split contexts.
Over-memoizing everything - useMemo/useCallback have their own cost and noise. Fix: Profile first; optimize list rows and heavy pure computations.
Reading state right after setState - setCount(1); console.log(count) still logs the old snapshot. Fix: Use the updater form or useEffect keyed on the value.
| Alternative | Use When | Don't Use When |
|---|---|---|
Local useState | Screen-scoped UI that does not cross routes | Many distant cousins need the same data |
| React Context | Theme, auth session, feature flags read widely | High-frequency updates (scroll position, keystrokes) |
| Zustand / Jotai | Medium app state with minimal boilerplate | One parent-child callback is enough |
| TanStack Query | Server lists, caching, background refresh | Purely local toggle with no network |
useRef for mutable values | Animation frames, intervals, imperative handles | Values that must appear on screen |
| Uncontrolled inputs + ref | Simple forms with few fields | Complex validation across steps |
memo).setTimeout, fetch resolutions, and native events batch by default.setState calls in the same tick produce one re-render.flushSync only when you intentionally need a synchronous paint (rare on mobile).renderItem unless memoized; new function identity can defeat row memoization.memo on the row, stable useCallback handlers, and extraData only when needed.setState / useReducer.style={{ flex: 1 }} creates a new object every time.useCallback and hoist styles to StyleSheet.create.useRef stores mutable values that should not trigger re-renders when updated.useState.FlatList is a PureComponent - it may skip renderItem if data reference is unchanged.extraData={selectedId} when row appearance depends on state outside data.// Wrong - each call uses the same snapshot
setCount(count + 1);
setCount(count + 1);
// Right - functional updater chains on latest value
setCount((c) => c + 1);
setCount((c) => c + 1);count variable in the closure is stale within the same handler.memo bails out with same props.children prop identity changes if the parent inline-defines JSX that captures new closures.expo-router and React Navigation pass route params as props or hook values (useLocalSearchParams).useEffect, not in the render body.setTodos((prev) => ...) so you do not close over todos.id arguments rather than capturing loop variables when possible.useMemo only caches a computed value between renders of the same component.memo on children when passing memoized objects or arrays down.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