Handling Events

Pass a function to props like onPress and onChangeText, and use an arrow function when a handler needs an argument.

Syntax<Pressable onPress={handlePress} /> / <TextInput onChangeText={(text) => setQuery(text)} />

React Native has no DOM events. There is no onClick, no bubbling up through parent elements, no preventDefault. Components accept handler props instead, and you give the prop a function to call.

The two you need first

Pressable takes onPress, called after the touch is released. It receives one argument shaped { nativeEvent: PressEvent }; in TypeScript that argument's type is GestureResponderEvent, exported from react-native. Most handlers ignore it. onPressIn, onPressOut and onLongPress have the same shape.

TextInput takes onChangeText, and this is where web habits cause real confusion: the new text arrives as a plain string, not an event. There is no e.target.value. A separate onChange prop does give you { nativeEvent: { text, eventCount, target } }, but onChangeText is the one to use.

Pass the handler, do not call it

onPress={save} hands React the function. onPress={save()} runs save while the component renders and gives the prop whatever it returned, usually undefined, so the button looks dead and your code fired at the wrong moment.

When a handler needs an argument, wrap it: onPress={() => remove(item.id)}. That arrow is shorthand for onPress={function () { remove(item.id); }} — the same thing, written shorter. When the argument is exactly what the prop already supplies, drop the wrapper: onChangeText={setQuery} and onChangeText={(text) => setQuery(text)} are identical.

Example

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

// On screen: a text field with a Clear button beside it, and a status line
// below. Typing updates the line as you type; Clear empties the field.
export default function SearchBar() {
  const [query, setQuery] = useState("");

  function handleClear() {
    setQuery("");
  }

  return (
    <View style={styles.screen}>
      <View style={styles.row}>
        <TextInput
          style={styles.input}
          value={query}
          // onChangeText passes the new text as a plain string.
          // Shorthand for (text) => setQuery(text) - the same thing.
          onChangeText={setQuery}
          placeholder="Search"
          autoCapitalize="none"
        />
        {/* The function itself, not handleClear(). */}
        <Pressable style={styles.button} onPress={handleClear}>
          <Text style={styles.buttonText}>Clear</Text>
        </Pressable>
      </View>
      <Text style={styles.status}>
        {query.length === 0 ? "Nothing typed yet" : `Searching for ${query}`}
      </Text>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16, gap: 12 },
  row: { flexDirection: "row", alignItems: "center", gap: 8 },
  input: { flex: 1, borderWidth: 1, borderColor: "#cccccc", borderRadius: 6, padding: 10 },
  button: { backgroundColor: "#eeeeee", borderRadius: 6, paddingVertical: 10, paddingHorizontal: 14 },
  buttonText: { fontSize: 15 },
  status: { color: "#555555" },
});

When to use it

  • Submit a form when the user presses a button
  • Store what a user types in a field as they type it
  • Delete the right row when each row has its own delete button
  • Darken a card while it is held down to show the press registered

More examples

Keeping an argument with an arrow function

The pattern for any row of generated buttons: one shared handler, and the arrow supplies which item was pressed. On screen: three chips; pressing one makes its label read "M selected". Without the arrow, all three handlers run while the row renders, and because each one sets state during render React does not quietly keep the last value: it throws "Too many re-renders. React limits the number of renders to prevent an infinite loop."

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

const SIZES = ["S", "M", "L"] as const;

export default function SizePicker() {
  const [chosen, setChosen] = useState<string | null>(null);

  function choose(size: string) {
    setChosen(size);
  }

  return (
    <View style={styles.row}>
      {SIZES.map((size) => (
        // The arrow carries the argument. onPress={choose(size)} would run
        // choose for all three buttons while the row renders.
        <Pressable key={size} onPress={() => choose(size)} style={styles.chip}>
          <Text>{size === chosen ? `${size} selected` : size}</Text>
        </Pressable>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  row: { flexDirection: "row", gap: 8, padding: 16 },
  chip: { borderWidth: 1, borderColor: "#999999", borderRadius: 16, paddingVertical: 6, paddingHorizontal: 14 },
});

Press states and the event object

Reach for the function form of style when a control needs to look pressed, and for hitSlop when a small icon is hard to hit. onLongPress fires after 500ms by default, adjustable with delayLongPress. On screen: a grey box that darkens while held; the console shows where inside it you pressed.

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

export default function TapTarget() {
  function handlePress(event: GestureResponderEvent) {
    // The handler receives { nativeEvent: PressEvent }. locationX and
    // locationY are measured from the top left of the pressed element.
    const { locationX, locationY } = event.nativeEvent;
    console.log(`pressed at ${Math.round(locationX)},${Math.round(locationY)}`);
  }

  return (
    <View style={styles.screen}>
      <Pressable
        onPress={handlePress}
        onLongPress={() => console.log("held for 500ms")}
        hitSlop={8} // widens the touch area without changing the layout
        // style may be a function of the press state. There is no :active
        // selector, because there is no CSS here.
        style={({ pressed }) => [styles.box, pressed && styles.boxPressed]}
      >
        <Text>Press or hold</Text>
      </Pressable>
    </View>
  );
}

const styles = StyleSheet.create({
  screen: { padding: 16 },
  box: { backgroundColor: "#f2f2f2", padding: 20, borderRadius: 10, alignItems: "center" },
  boxPressed: { backgroundColor: "#dddddd" },
});

Keyboard-related TextInput handlers

Three different handler shapes on one component, which is why reading the prop's documented argument matters. Use onBlur to delay an error message until the user has left the field, so it does not appear while they are still typing. On screen: one field; a red line appears under it after you leave it with an address missing an @.

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

export default function EmailField() {
  const [email, setEmail] = useState("");
  const [touched, setTouched] = useState(false);

  return (
    <View style={styles.screen}>
      <TextInput
        style={styles.input}
        value={email}
        onChangeText={setEmail}
        // onSubmitEditing receives { nativeEvent: { text, eventCount, target } },
        // not a bare string - unlike onChangeText.
        onSubmitEditing={(event) => console.log("submitted", event.nativeEvent.text)}
        onBlur={() => setTouched(true)}
        placeholder="[email protected]"
        keyboardType="email-address"
        autoCapitalize="none"
        returnKeyType="done"
      />
      {touched && !email.includes("@") ? (
        <Text style={styles.error}>That does not look like an email address</Text>
      ) : null}
    </View>
  );
}

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

Discussion

  • Be the first to comment on this lesson.