Why Props and State Matter for Data Flow
In React Native, screens are built from components that render UI based on data. Two core mechanisms control how data moves and how the UI updates: props and state. Understanding the difference is essential for building interactive screens where user actions (typing, tapping, toggling) change what the user sees.
Think of a component as a function of data: when the data changes, the rendered output changes. Props and state are the main “inputs” that drive rendering. The key difference is ownership: props are owned by the parent and passed down, while state is owned by the component and managed internally (or by a state container, but the component still receives it as props).
Props: Read-Only Inputs Passed from Parent to Child
Props (short for “properties”) are values you pass into a component when you render it. A component should treat props as read-only. If a child needs to influence something, it typically does so by calling a function passed via props (an event callback), allowing the parent to decide what to change.
What Props Are Used For
- Configuration: labels, colors, sizes, and flags that control how a component looks or behaves.
- Data display: passing a user object, a list of items, or a selected value.
- Event callbacks: passing functions like
onPress,onChangeText,onToggleso the child can notify the parent.
Example: Passing Data and a Callback
The parent owns the data and passes it down. The child displays it and calls back when the user interacts.
import React, { useState } from 'react';
import { View, Text, Pressable } from 'react-native';
function CounterDisplay({ count, onIncrement }) {
return (
<View>
<Text>Count: {count}</Text>
<Pressable onPress={onIncrement}>
<Text>Increment</Text>
</Pressable>
</View>
);
}
export default function Screen() {
const [count, setCount] = useState(0);
return (
<CounterDisplay
count={count}
onIncrement={() => setCount((c) => c + 1)}
/>
);
}Notice the direction of data flow: count flows down into CounterDisplay as a prop. The user action flows up through onIncrement, which the parent uses to update its state. This pattern is the backbone of predictable UI updates.
- Listen to the audio with the screen off.
- Earn a certificate upon completion.
- Over 5000 courses for you to explore!
Download the app
Props Are Immutable (From the Child’s Perspective)
A common beginner mistake is trying to “change a prop” inside a child. Instead, the child should request a change by calling a callback prop. This keeps components easier to reason about: the parent is the source of truth for the data it owns.
State: Data That Changes Over Time Inside a Component
State is data that can change while the app runs. When state changes, React re-renders the component (and its children) so the UI stays in sync. In React Native function components, state is typically managed with the useState hook.
What State Is Used For
- Form inputs: text fields, selected options, toggles.
- UI modes: loading indicators, expanded/collapsed sections, selected tabs.
- Temporary screen data: filters, sort order, local draft values.
Example: Text Input State
import React, { useState } from 'react';
import { View, Text, TextInput } from 'react-native';
export default function ProfileEditor() {
const [name, setName] = useState('');
return (
<View>
<Text>Your name:</Text>
<TextInput
value={name}
onChangeText={setName}
placeholder="Type here"
/>
<Text>Preview: {name}</Text>
</View>
);
}This is a classic “controlled input”: the TextInput displays value={name}, and user typing triggers onChangeText, which updates state via setName. The preview updates automatically because the component re-renders with the new state.
Single Source of Truth and Where State Should Live
When multiple components need the same piece of data, you should store that data in the closest common parent and pass it down via props. This is often described as “lifting state up.” The goal is a single source of truth: one place where the authoritative value lives.
Step-by-Step: Lifting State Up
Imagine a screen with a search box and a list. The search box needs to update the query, and the list needs to filter based on that query. If each component had its own separate query state, they could get out of sync. Instead:
- Step 1: Put
querystate in the parent screen. - Step 2: Pass
queryandsetQuery(or anonChangeQuerycallback) to the search component. - Step 3: Pass
queryto the list component so it can filter.
import React, { useMemo, useState } from 'react';
import { View, Text, TextInput, FlatList } from 'react-native';
function SearchBox({ query, onChangeQuery }) {
return (
<TextInput
value={query}
onChangeText={onChangeQuery}
placeholder="Search items"
/>
);
}
function ItemList({ items }) {
return (
<FlatList
data={items}
keyExtractor={(item) => item.id}
renderItem={({ item }) => <Text>{item.label}</Text>}
/>
);
}
export default function SearchableListScreen() {
const [query, setQuery] = useState('');
const allItems = [
{ id: '1', label: 'Apple' },
{ id: '2', label: 'Banana' },
{ id: '3', label: 'Orange' },
];
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return allItems;
return allItems.filter((x) => x.label.toLowerCase().includes(q));
}, [query]);
return (
<View>
<SearchBox query={query} onChangeQuery={setQuery} />
<ItemList items={filtered} />
</View>
);
}Here the parent screen owns query. The search box is a controlled component driven by props. The list is also driven by props, receiving the already-filtered array. This keeps responsibilities clear and avoids duplicated state.
Derived State vs Stored State
A useful rule: don’t store what you can derive. If a value can be computed from props or other state, compute it during render (or memoize it) rather than storing it as separate state. This reduces bugs where one value updates but the derived value doesn’t.
In the previous example, filtered is derived from query and allItems. It does not need its own useState. If you stored filtered in state, you would need to remember to update it every time query changes, which is error-prone.
Updating State Correctly (and Why Functional Updates Help)
State updates may be batched, and reading a state variable immediately after calling its setter does not guarantee you have the latest value. When the next state depends on the previous state, use a functional update.
Example: Safe Increment
const [count, setCount] = useState(0);
// Good when next value depends on previous
setCount((prev) => prev + 1);This avoids issues if multiple updates happen quickly (for example, multiple taps or multiple updates in the same event loop tick).
Passing Functions as Props: Event-Driven Data Flow
Interactive screens are built around events: pressing a button, selecting an item, submitting a form. In React Native, you typically pass a function down as a prop so the child can notify the parent of an event. The parent then updates state, and the updated state flows back down as props.
Step-by-Step: Selecting an Item in a List
- Step 1: Parent stores
selectedIdin state. - Step 2: Parent renders list items and passes
isSelectedandonSelectprops. - Step 3: Child calls
onSelectwhen pressed. - Step 4: Parent updates
selectedId, causing a re-render.
import React, { useState } from 'react';
import { View, Text, Pressable, FlatList } from 'react-native';
function SelectableRow({ label, isSelected, onSelect }) {
return (
<Pressable onPress={onSelect}>
<Text>{isSelected ? '✓ ' : ''}{label}</Text>
</Pressable>
);
}
export default function PickerScreen() {
const [selectedId, setSelectedId] = useState(null);
const options = [
{ id: 'basic', label: 'Basic' },
{ id: 'pro', label: 'Pro' },
{ id: 'team', label: 'Team' },
];
return (
<View>
<Text>Choose a plan:</Text>
<FlatList
data={options}
keyExtractor={(item) => item.id}
renderItem={({ item }) => (
<SelectableRow
label={item.label}
isSelected={item.id === selectedId}
onSelect={() => setSelectedId(item.id)}
/>
)}
/>
<Text>Selected: {selectedId ?? 'none'}</Text>
</View>
);
}This pattern scales well: the parent owns the selection, and each row is a simple presentational component driven by props.
State for UI Status: Loading, Error, and Empty
Many screens need to represent different UI states: loading data, showing an error, or showing an empty result. Even without introducing networking details, you can model these states with a few state variables and render different UI accordingly.
Example: Modeling Screen Status
import React, { useState } from 'react';
import { View, Text, Pressable } from 'react-native';
export default function StatusDemo() {
const [status, setStatus] = useState('idle'); // 'idle' | 'loading' | 'error' | 'success'
return (
<View>
<Pressable onPress={() => setStatus('loading')}>
<Text>Simulate Load</Text>
</Pressable>
<Pressable onPress={() => setStatus('success')}>
<Text>Simulate Success</Text>
</Pressable>
<Pressable onPress={() => setStatus('error')}>
<Text>Simulate Error</Text>
</Pressable>
{status === 'idle' && <Text>Ready.</Text>}
{status === 'loading' && <Text>Loading...</Text>}
{status === 'error' && <Text>Something went wrong.</Text>}
{status === 'success' && <Text>Done!</Text>}
</View>
);
}Even in simple demos, it’s worth practicing explicit UI states. It makes your screens more predictable and easier to extend later.
Props and State Across Screens: Route Params as Props
When you navigate between screens, you often need to pass data to the next screen (for example, an item ID). In most navigation libraries, the destination screen receives route parameters, which conceptually behave like props: they are inputs provided by the navigator/parent context.
A practical approach is: pass minimal identifiers (like an id) rather than large objects, and let the destination screen derive what it needs. This reduces stale data and keeps navigation payloads small.
Example: Passing an ID and Using It as Input
// Pseudocode shape (navigation setup not shown)
function ProductsScreen({ navigation }) {
const products = [
{ id: 'p1', name: 'Headphones' },
{ id: 'p2', name: 'Keyboard' },
];
return (
<FlatList
data={products}
keyExtractor={(p) => p.id}
renderItem={({ item }) => (
<Pressable onPress={() => navigation.navigate('ProductDetails', { productId: item.id })}>
<Text>{item.name}</Text>
</Pressable>
)}
/>
);
}
function ProductDetailsScreen({ route }) {
const { productId } = route.params;
return (
<View>
<Text>Details for: {productId}</Text>
</View>
);
}The details screen treats productId as an input. If the user navigates to a different product, the input changes and the screen can update accordingly.
Common Patterns for Interactive Screens
1) Controlled vs Uncontrolled Inputs
In React Native, you typically build controlled inputs for predictable behavior: the input’s displayed value comes from state, and every change updates state. This makes validation, formatting, and submission straightforward.
An uncontrolled input would let the native component keep its own internal value, and you would read it only when needed. Controlled inputs are usually preferred for forms because they keep UI and data in sync.
2) Local State vs Shared State
Use local state for UI concerns that don’t matter outside the component (for example, whether a dropdown is open). Use shared state (lifted to a parent) when multiple components need to read or update the same value (for example, a selected filter that affects both a toolbar and a list).
3) Presentational vs Container Components
A practical way to organize code is to separate components that mainly render UI from components that manage state. A presentational component receives props and renders; a container component owns state and passes props down. You don’t have to enforce this strictly, but it helps keep complex screens maintainable.
Step-by-Step: Building a Small Interactive Screen with Props and State
This walkthrough combines the most common pieces: a form input, a list, and item actions. The screen will let the user add tasks and toggle them done. The parent owns the list state; each row receives props and emits events via callbacks.
Step 1: Define the Parent State
The screen needs a draft text value and an array of tasks. Each task has an id, title, and done flag.
import React, { useMemo, useState } from 'react';
import { View, Text, TextInput, Pressable, FlatList } from 'react-native';
function TaskRow({ title, done, onToggle, onRemove }) {
return (
<View>
<Pressable onPress={onToggle}>
<Text>{done ? '☑ ' : '☐ '}{title}</Text>
</Pressable>
<Pressable onPress={onRemove}>
<Text>Remove</Text>
</Pressable>
</View>
);
}
export default function TasksScreen() {
const [draft, setDraft] = useState('');
const [tasks, setTasks] = useState([
{ id: 't1', title: 'Learn props', done: true },
{ id: 't2', title: 'Practice state updates', done: false },
]);
const remainingCount = useMemo(
() => tasks.filter((t) => !t.done).length,
[tasks]
);
return (
<View>
<Text>Remaining: {remainingCount}</Text>
</View>
);
}So far, we’ve created state and a derived value (remainingCount). Next, we’ll add the input and the “add” action.
Step 2: Add a Controlled Input and an Add Button
We’ll keep the input controlled with draft state. When the user presses “Add,” we validate the text, create a new task, update the tasks array, and clear the draft.
// Inside TasksScreen return
<TextInput
value={draft}
onChangeText={setDraft}
placeholder="New task"
/>
<Pressable
onPress={() => {
const title = draft.trim();
if (!title) return;
const newTask = {
id: String(Date.now()),
title,
done: false,
};
setTasks((prev) => [newTask, ...prev]);
setDraft('');
}}
>
<Text>Add</Text>
</Pressable>Key details:
- We use
trim()to avoid adding empty tasks. - We use a functional update for
setTasksbecause the next tasks array depends on the previous one. - We clear the input by setting
draftback to an empty string.
Step 3: Render the List and Wire Row Callbacks
Now we render tasks with FlatList. Each row receives props for display (title, done) and callbacks for interactions (onToggle, onRemove).
// Inside TasksScreen return
<FlatList
data={tasks}
keyExtractor={(t) => t.id}
renderItem={({ item }) => (
<TaskRow
title={item.title}
done={item.done}
onToggle={() => {
setTasks((prev) =>
prev.map((t) =>
t.id === item.id ? { ...t, done: !t.done } : t
)
);
}}
onRemove={() => {
setTasks((prev) => prev.filter((t) => t.id !== item.id));
}}
/>
)}
/>This demonstrates a core React principle: treat state as immutable. We don’t modify an existing task object; we create a new object with { ...t, done: !t.done }. We don’t remove by mutating the array; we create a new array with filter. Immutable updates help React detect changes and keep your UI consistent.
Step 4: Understand the Data Flow
- The parent screen owns
tasksanddraftstate. - The input receives
draftas a value and updates it throughsetDraft. - Each row receives
titleanddoneas props. - Each row emits events upward through
onToggleandonRemove, which update parent state. - After state updates, the parent re-renders and passes updated props down.
Common Mistakes and How to Avoid Them
Mutating State Directly
Avoid doing things like tasks.push(...) or task.done = true on state values. Instead, create new arrays/objects. Use map, filter, and object spread to produce new values.
Duplicating the Same State in Multiple Places
If two components need to stay in sync, don’t give each its own copy of the same state. Lift it to the closest common parent and pass it down via props.
Storing Derived Values as State
If a value can be computed from existing state/props, compute it during render or memoize it. This prevents inconsistencies and reduces the number of updates you need to manage.
Overusing State for Constants
If a value never changes, it doesn’t belong in state. Keep constants as regular variables. State should represent data that changes over time and affects rendering.