Props and State for Data Flow and Interactive Screens

Capítulo 3

Estimated reading time: 13 minutes

+ Exercise

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, onToggle so 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.

Continue in our app.
  • Listen to the audio with the screen off.
  • Earn a certificate upon completion.
  • Over 5000 courses for you to explore!
Or continue reading below...
Download App

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 query state in the parent screen.
  • Step 2: Pass query and setQuery (or an onChangeQuery callback) to the search component.
  • Step 3: Pass query to 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 selectedId in state.
  • Step 2: Parent renders list items and passes isSelected and onSelect props.
  • Step 3: Child calls onSelect when 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 setTasks because the next tasks array depends on the previous one.
  • We clear the input by setting draft back 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 tasks and draft state.
  • The input receives draft as a value and updates it through setDraft.
  • Each row receives title and done as props.
  • Each row emits events upward through onToggle and onRemove, 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.

Now answer the exercise about the content:

In a screen where both a search box and a list need to use the same query value, what approach best keeps the UI consistent and avoids duplicated state?

You are right! Congratulations, now go to the next page

You missed! Try again.

Shared data should live in the closest common parent as a single source of truth. The parent passes query down via props and provides a callback (like setQuery) so the search box can trigger updates without mutating props.

Next chapter

Rendering Lists and Handling User Input with Core Components

Arrow Right Icon
Free Ebook cover React Native Basics: Components, Styling, and Navigation Concepts
25%

React Native Basics: Components, Styling, and Navigation Concepts

New course

12 pages

Download the app to earn free Certification and listen to the courses in the background, even with the screen off.