Updating State Correctly

Use the updater function when the next value depends on the last, and replace objects and arrays rather than editing them.

SyntaxsetState(nextValue) / setState((previous) => nextValue)

Two rules cover nearly every state bug. Use the updater form when the next value depends on the previous one. Replace objects and arrays instead of changing them in place.

The updater function

A setter accepts either a value or a function. setCount(count + 1) uses the count captured by the render that is running. setCount((c) => c + 1) asks React for the latest pending value instead. Three value calls in one handler add one, because all three read the same snapshot. Three updater calls add three, because React queues the functions and applies them in order. The parameter name is yours; prev and c are both fine.

Replace, do not mutate

items.push(next) and form.email = text change the object React already has. The reference is unchanged, React compares old and new state with Object.is, sees no difference, and may render nothing. Build a new value: [...items, next], which is the same as items.concat(next), and { ...form, email: text }, which is the same as Object.assign({}, form, { email: text }). Spread copies one level only, so a nested object needs its own copy.

Batching

React does not re-render once per setter call. It collects the calls and renders once after the handlers have finished. Since React 18 this covers setters called inside promises, setTimeout and native callbacks too; React Native 0.87 ships React 19, so you can rely on it. The consequence is the one that surprises people: the state variable never changes part-way through a handler.

Example

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

type Settings = { name: string; notify: boolean; volume: number };

// On screen: a name field, a notifications switch, and a Louder button showing
// the current volume. Each control changes one field and leaves the rest alone.
export default function SettingsForm() {
  const [settings, setSettings] = useState<Settings>({
    name: "",
    notify: true,
    volume: 3,
  });

  // Updater form, because the next volume depends on the last one.
  // { ...prev, volume: ... } is the same as
  // Object.assign({}, prev, { volume: ... }).
  function louder() {
    setSettings((prev) => ({ ...prev, volume: Math.min(prev.volume + 1, 10) }));
  }

  return (
    <View style={styles.screen}>
      <TextInput
        style={styles.input}
        value={settings.name}
        // Never settings.name = text. Build a new object instead.
        onChangeText={(text) => setSettings((prev) => ({ ...prev, name: text }))}
        placeholder="Display name"
      />
      <View style={styles.row}>
        <Switch
          value={settings.notify}
          onValueChange={(next) => setSettings((prev) => ({ ...prev, notify: next }))}
        />
        <Text>Notifications</Text>
      </View>
      <Pressable style={styles.button} onPress={louder}>
        <Text>Louder ({settings.volume})</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 12 },
  input: { borderWidth: 1, borderColor: "#cccccc", borderRadius: 6, padding: 10 },
  row: { flexDirection: "row", alignItems: "center", gap: 8 },
  button: { backgroundColor: "#eeeeee", borderRadius: 6, padding: 12, alignItems: "center" },
});

When to use it

  • Increment a counter correctly from a handler that fires several times
  • Change one field of a form object without clearing the others
  • Add, remove or toggle an item in a list held in state
  • Update state from inside a setTimeout or a promise callback

More examples

Snapshot versus updater, in one component

The clearest demonstration of both batching and the snapshot rule. On screen: a number and two buttons; the first raises it by one however many setters it calls, the second raises it by three. Either way the screen redraws once, not three times.

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

export default function Stepper() {
  const [count, setCount] = useState(0);

  function addThreeWrong() {
    // All three read the same snapshot of count. Net effect: +1.
    setCount(count + 1);
    setCount(count + 1);
    setCount(count + 1);
  }

  function addThreeRight() {
    // React queues the functions and applies them in order. Net effect: +3.
    setCount((c) => c + 1);
    setCount((c) => c + 1);
    setCount((c) => c + 1);
  }

  return (
    <View style={styles.screen}>
      <Text style={styles.count}>{count}</Text>
      <Pressable style={styles.button} onPress={addThreeWrong}>
        <Text>Add 3 the wrong way</Text>
      </Pressable>
      <Pressable style={styles.button} onPress={addThreeRight}>
        <Text>Add 3 the right way</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 12, alignItems: "center" },
  count: { fontSize: 40 },
  button: { backgroundColor: "#eeeeee", borderRadius: 6, padding: 12 },
});

A pure helper for list updates

Pulling the array work out of the component makes it testable without a screen. The last line of output matters for lists: map reuses the objects it did not change, so a memoised list row can skip re-rendering. Mutating in place would break that signal entirely.

Example · tsx
type Todo = { id: string; title: string; done: boolean };

// Pure: takes the old array, returns a new one, changes nothing.
// Easy to unit test, and it keeps the setter call short.
export function toggle(todos: Todo[], id: string): Todo[] {
  return todos.map((todo) =>
    todo.id === id ? { ...todo, done: !todo.done } : todo
  );
}

export function add(todos: Todo[], todo: Todo): Todo[] {
  return [...todos, todo]; // same as todos.concat(todo)
}

export function remove(todos: Todo[], id: string): Todo[] {
  return todos.filter((todo) => todo.id !== id);
}

const before: Todo[] = [
  { id: "a", title: "Milk", done: false },
  { id: "b", title: "Bread", done: true },
];
const after = toggle(before, "a");

console.log(after[0].done, after[1].done);
// Output: true true
console.log(before[0].done);
// Output: false
console.log(before[1] === after[1]);
// Output: true

// In the component:  setTodos((prev) => toggle(prev, id));

Nested state needs a copy at every level

Spread is shallow, which is the detail that bites once state has shape. Every level you change needs its own copy; levels you do not touch can be shared. When this gets deep, that is the signal to flatten the state or move it into a reducer.

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

type Profile = {
  name: string;
  address: { city: string; country: string };
};

export default function ProfileEditor() {
  const [profile, setProfile] = useState<Profile>({
    name: "Ada",
    address: { city: "London", country: "UK" },
  });

  function moveToParis() {
    setProfile((prev) => ({
      ...prev,                               // copy the outer object
      address: { ...prev.address, city: "Paris" }, // and the nested one
    }));
    // Wrong: { ...prev, address: prev.address } reuses the SAME inner object,
    // so prev.address.city = "Paris" would be a mutation of live state.
  }

  return (
    <View style={styles.screen}>
      <Text>
        {profile.name} lives in {profile.address.city}, {profile.address.country}
      </Text>
      <Pressable style={styles.button} onPress={moveToParis}>
        <Text>Move to Paris</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 12 },
  button: { backgroundColor: "#eeeeee", borderRadius: 6, padding: 12, alignItems: "center" },
});

Discussion

  • Be the first to comment on this lesson.