Lifting State Up

When two components must agree on a value, move the state to their closest common parent and pass it down with a way to change it.

Syntax<Child value={value} onChange={setValue} />

Props flow downwards and are read-only. A child cannot change a value its parent owns. So when two components must agree about the same value — a set of filter chips and the list they filter, a toggle and the thing it toggles — the state cannot live in either of them. Move it to the closest component they share and pass it down. That is lifting state up.

How it works

The parent holds the useState pair. It passes the value down as one prop and a way to change it as another, usually named value and onChange, or something more specific. The children become controlled: they render what they are given and report what the user did. There is only one copy of the value, so the two cannot drift apart.

You can pass the setter itself: onSelect={setSize} is the same thing as onSelect={(next) => setSize(next)}, just shorter. Pass a named function instead when the parent has to do more than store the value.

What trips people up

Copying the prop into local state. const [local, setLocal] = useState(size) inside the child gives you two sources of truth, and the copy ignores every later value, because useState reads its argument only on the first render.

Lifting too far. Stop at the nearest common parent. Once a value is threaded through four or five components that do not use it, that is the point to reach for context or a store instead.

Example

Example · tsx
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";

type Size = "S" | "M" | "L";

type SizeChoiceProps = {
  sizes: readonly Size[];
  selected: Size;
  onSelect: (size: Size) => void;
};

// Child one: shows the options and reports a press. It owns no state.
function SizeChoice({ sizes, selected, onSelect }: SizeChoiceProps) {
  return (
    <View style={styles.row}>
      {sizes.map((size) => (
        <Pressable key={size} onPress={() => onSelect(size)} style={styles.chip}>
          <Text style={size === selected ? styles.on : styles.off}>{size}</Text>
        </Pressable>
      ))}
    </View>
  );
}

// Child two: only reads the value.
function Summary({ selected }: { selected: Size }) {
  return <Text style={styles.summary}>You picked size {selected}</Text>;
}

// The parent is the closest component both children share, so the state lives
// here. On screen: a row of three chips and a line below naming the choice;
// pressing a chip updates both at once.
export default function SizeScreen() {
  const [size, setSize] = useState<Size>("M");

  return (
    <View style={styles.screen}>
      {/* onSelect={setSize} is the same as onSelect={(next) => setSize(next)} */}
      <SizeChoice sizes={["S", "M", "L"]} selected={size} onSelect={setSize} />
      <Summary selected={size} />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 12 },
  row: { flexDirection: "row", gap: 8 },
  chip: { borderWidth: 1, borderColor: "#999999", borderRadius: 16, paddingVertical: 6, paddingHorizontal: 14 },
  on: { fontWeight: "700" },
  off: { color: "#555555" },
  summary: { fontSize: 16 },
});

When to use it

  • Keep a filter bar and the list it filters in agreement
  • Let a parent form validate and submit the values typed into its fields
  • Share a selected date between a picker and a summary line
  • Build a reusable input that holds no state of its own

More examples

A reusable controlled field

The same stateless child used twice with different state behind it. Because the parent owns both values, it can also validate them, disable a submit button, or clear the whole form in one setter call. On screen: two labelled fields and a greeting line that updates as you type in either.

Example · tsx
import { useState } from "react";
import { StyleSheet, Text, TextInput, View } from "react-native";

type FieldProps = {
  label: string;
  value: string;
  onChangeText: (text: string) => void;
};

// No useState in here. The field is told what to show and reports changes.
function Field({ label, value, onChangeText }: FieldProps) {
  return (
    <View style={styles.field}>
      <Text style={styles.label}>{label}</Text>
      <TextInput style={styles.input} value={value} onChangeText={onChangeText} />
    </View>
  );
}

export default function NameForm() {
  const [first, setFirst] = useState("");
  const [last, setLast] = useState("");

  return (
    <View style={styles.screen}>
      <Field label="First name" value={first} onChangeText={setFirst} />
      <Field label="Last name" value={last} onChangeText={setLast} />
      <Text style={styles.greeting}>Hello {first} {last}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 16 },
  field: { gap: 4 },
  label: { color: "#555555" },
  input: { borderWidth: 1, borderColor: "#cccccc", borderRadius: 6, padding: 10 },
  greeting: { fontSize: 18 },
});

Two siblings that have to agree

The filter bar and the list are siblings, so neither can own the filter. The parent holds it, passes the value to the bar and derives the visible rows during render. Nothing is duplicated, so the highlighted chip and the rows on screen can never disagree. On screen: two filter words above a list that shortens when you choose "open".

Example · tsx
import { useState } from "react";
import { FlatList, Pressable, StyleSheet, Text, View } from "react-native";

type Task = { id: string; title: string; done: boolean };

const TASKS: Task[] = [
  { id: "1", title: "Buy milk", done: true },
  { id: "2", title: "Call the plumber", done: false },
  { id: "3", title: "Renew passport", done: false },
];

type Filter = "all" | "open";

function FilterBar({ value, onChange }: { value: Filter; onChange: (next: Filter) => void }) {
  return (
    <View style={styles.row}>
      {(["all", "open"] as const).map((option) => (
        <Pressable key={option} onPress={() => onChange(option)}>
          <Text style={option === value ? styles.on : styles.off}>{option}</Text>
        </Pressable>
      ))}
    </View>
  );
}

export default function TaskScreen() {
  const [filter, setFilter] = useState<Filter>("all");
  const visible = filter === "all" ? TASKS : TASKS.filter((task) => !task.done);

  return (
    <View style={styles.screen}>
      <FilterBar value={filter} onChange={setFilter} />
      {/* FlatList, not ScrollView: a list of unknown length. */}
      <FlatList
        data={visible}
        keyExtractor={(item) => item.id}
        renderItem={({ item }) => <Text style={styles.item}>{item.title}</Text>}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, padding: 16, gap: 12 },
  row: { flexDirection: "row", gap: 16 },
  on: { fontWeight: "700" },
  off: { color: "#555555" },
  item: { paddingVertical: 10 },
});

The copied prop, and the fix

Reset is the button that exposes the bug. With a copied prop, pressing it would change the total but leave the stepper showing the old number. Reading the prop directly keeps one owner, so both update together. On screen: a minus, a number and a plus, a total beneath, and a Reset that puts everything back to one.

Example · tsx
import { useState } from "react";
import { Pressable, StyleSheet, Text, View } from "react-native";

type RowProps = { quantity: number; onChange: (next: number) => void };

function QuantityRow({ quantity, onChange }: RowProps) {
  // WRONG, and a common bug:
  // const [local, setLocal] = useState(quantity);
  // useState reads its argument on the first render only, so later values of
  // the quantity prop are ignored and the row drifts out of step.

  // RIGHT: read the prop, report the change upwards.
  return (
    <View style={styles.row}>
      <Pressable onPress={() => onChange(Math.max(1, quantity - 1))}>
        <Text style={styles.step}>-</Text>
      </Pressable>
      <Text style={styles.value}>{quantity}</Text>
      <Pressable onPress={() => onChange(quantity + 1)}>
        <Text style={styles.step}>+</Text>
      </Pressable>
    </View>
  );
}

export default function Cart() {
  const [quantity, setQuantity] = useState(1);

  return (
    <View style={styles.screen}>
      <QuantityRow quantity={quantity} onChange={setQuantity} />
      <Text>Total: {quantity * 4} GBP</Text>
      <Pressable style={styles.button} onPress={() => setQuantity(1)}>
        <Text>Reset</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 12 },
  row: { flexDirection: "row", alignItems: "center", gap: 16 },
  step: { fontSize: 24, width: 24, textAlign: "center" },
  value: { fontSize: 20, minWidth: 32, textAlign: "center" },
  button: { backgroundColor: "#eeeeee", borderRadius: 6, padding: 12, alignItems: "center" },
});

Discussion

  • Be the first to comment on this lesson.