useEffect

Run code after a render to keep something outside React in sync, and clean it up when the dependencies change or the screen goes away.

SyntaxuseEffect(() => { /* setup */ return () => { /* cleanup */ }; }, [deps])

useEffect runs code after React has rendered and committed the screen. It is for work that is not rendering: starting a timer, subscribing to a system event, measuring something. Anything you can compute during render belongs in the component body, and anything caused by a tap belongs in the handler.

Signature

useEffect(setup, dependencies?). The setup function runs after the commit and may return a cleanup function. React compares each dependency with Object.is. [a, b] re-runs the effect whenever a or b changed, [] runs it once after the first render, and omitting the array entirely re-runs it after every commit.

Cleanup

Cleanup must undo setup exactly. Started an interval, clear it; added a listener, remove it. React calls cleanup before each re-run and once when the component is removed. With Strict Mode on in development, React deliberately runs setup, cleanup, then setup again on mount, which exposes cleanup you forgot. That does not happen in production builds.

The two mistakes

A missing dependency. The effect reads userId but the array says [], so it keeps using the first value forever and the screen quietly shows stale data. List everything the effect reads.

An effect that should have been an event handler. If the work happens because the user did something, put it in onPress. Effects are for synchronisation, not for reacting to taps. A related trap: a setter inside an effect with no dependency array renders, re-runs the effect, and loops forever.

Example

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

// On screen: a number counting down from 10, one step a second, with the word
// "Done" appearing underneath when it reaches zero.
export default function Countdown() {
  const [secondsLeft, setSecondsLeft] = useState(10);

  useEffect(() => {
    if (secondsLeft === 0) {
      return; // nothing scheduled, so nothing to clean up
    }
    const id = setTimeout(() => setSecondsLeft(secondsLeft - 1), 1000);
    // Cleanup runs before the next run of this effect, and on unmount.
    // Without it, leaving the screen leaves a timer that sets state on a
    // component that no longer exists.
    return () => clearTimeout(id);
  }, [secondsLeft]); // the effect reads secondsLeft, so it is listed

  return (
    <View style={styles.screen}>
      <Text style={styles.number}>{secondsLeft}</Text>
      {secondsLeft === 0 ? <Text style={styles.done}>Done</Text> : null}
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { flex: 1, alignItems: "center", justifyContent: "center", gap: 8 },
  number: { fontSize: 56, fontWeight: "600" },
  done: { fontSize: 18, color: "#555555" },
});

When to use it

  • Run a countdown or a polling timer and stop it when the screen closes
  • Subscribe to app state, keyboard or dimension changes and unsubscribe on unmount
  • Delay a search by 300ms after the last keystroke
  • Set up and tear down a connection when a screen is shown and hidden

More examples

A subscription, set up and torn down

The textbook shape of an effect: subscribe in setup, unsubscribe in cleanup, empty dependency array because nothing in the component is read. AppStateStatus is "active", "background" or, on iOS only, "inactive". On screen: one line that changes when you send the app to the home screen and come back. Reach for this when something must pause while the app is backgrounded.

Example · tsx
import { useEffect, useState } from "react";
import {
  AppState,
  StyleSheet,
  Text,
  View,
  type AppStateStatus,
} from "react-native";

export default function ForegroundWatcher() {
  const [status, setStatus] = useState<AppStateStatus>(AppState.currentState);

  useEffect(() => {
    // addEventListener(type, listener) returns a subscription object.
    const subscription = AppState.addEventListener("change", (next) => {
      setStatus(next);
    });
    // remove() is the cleanup. Forgetting it leaks a listener per mount.
    return () => subscription.remove();
  }, []); // reads nothing from the component, so the list is empty

  return (
    <View style={styles.screen}>
      <Text>App state: {status}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16 },
});

Debounce with an effect, derive without one

Two decisions in one file. The debounce earns an effect because it involves a timer and needs cancelling. The match count does not, because it can be calculated from existing state during render. On screen: a field and a count that settles 300ms after you stop typing.

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

const FRUITS = ["apple", "apricot", "banana", "cherry"];

export default function DebouncedFilter() {
  const [term, setTerm] = useState("");
  const [debounced, setDebounced] = useState("");

  // A fair use of an effect: it keeps a second value in step with the first,
  // and the cleanup cancels the pending timer each time term changes again.
  useEffect(() => {
    const id = setTimeout(() => setDebounced(term), 300);
    return () => clearTimeout(id);
  }, [term]);

  // No effect and no third state variable for this. It is computed during
  // render from state that is already here. An effect would only add a render.
  const matches = FRUITS.filter((fruit) => fruit.startsWith(debounced));

  return (
    <View style={styles.screen}>
      <TextInput
        style={styles.input}
        value={term}
        onChangeText={setTerm}
        placeholder="Filter"
        autoCapitalize="none"
      />
      <Text>{matches.length} matches for {debounced.length === 0 ? "anything" : debounced}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 12 },
  input: { borderWidth: 1, borderColor: "#cccccc", borderRadius: 6, padding: 10 },
});

Work caused by a tap is not an effect

The test to apply: did this happen because the user did something, or because some value changed? Analytics, saving a form and showing an alert are all the first kind and belong in the handler. On screen: a button that reveals a line of text; the log line is written once, at the moment of the press.

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

function logOpened(source: string) {
  console.log("panel opened from", source);
}

export default function Panel() {
  const [open, setOpen] = useState(false);

  // WRONG, left here to recognise it:
  // useEffect(() => {
  //   if (open) {
  //     logOpened("effect");
  //   }
  // }, [open]);
  // It fires one render late, fires again for any other reason open flips,
  // and in development Strict Mode runs it twice.

  function handleOpen() {
    setOpen(true);
    logOpened("button"); // RIGHT: the cause was the press, so it goes here
  }

  return (
    <View style={styles.screen}>
      <Pressable style={styles.button} onPress={handleOpen}>
        <Text>Open</Text>
      </Pressable>
      {open ? <Text>Panel contents</Text> : null}
    </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.