skip to content

Why does a React Native quick-reply chip inside a ScrollView need two taps while the keyboard is open, and how does keyboardShouldPersistTaps fix it?

level: juniorimportance: should knowfreq 45%

answer

  1. first tap goes to the scroll view
  2. default is 'never'
  3. 'handled' lets children win
  4. every ancestor scroll view
  5. strings only since 0.87

basics

~20 s

With the default keyboardShouldPersistTaps 'never', a ScrollView claims the first tap while the keyboard is up, dismisses the keyboard, and the child never sees it. Setting 'handled' on every ancestor ScrollView lets tapped children respond at once.

solid answer

~40 s

`ScrollView`'s `keyboardShouldPersistTaps` defaults to `'never'`. While the keyboard is up, a tap on anything other than the focused input is captured by the scroll view before its children, which blurs the input and dismisses the keyboard; the chip only works on the second tap. `'handled'` lets a child that handles the tap receive it while the keyboard stays up, and taps on empty space still dismiss. `'always'` never dismisses automatically. Because capture runs from the outermost ancestor inward, every scroll view around the control needs the setting, including lists built on `ScrollView`. React Native 0.87 accepts only the three strings, as the boolean form was removed. Pair it with a way to close the keyboard: `Keyboard.dismiss()` or `keyboardDismissMode` (`'on-drag'`, or `'interactive'` on iOS).

code

tsx · 20 lines
tsx
import {Pressable, ScrollView, Text} from 'react-native';

const REPLIES = ['On my way', 'Sounds good', 'Call you later'];

type QuickRepliesProps = {onSend: (text: string) => void};

export function QuickReplies({onSend}: QuickRepliesProps) {
  return (
    <ScrollView
      horizontal
      keyboardShouldPersistTaps="handled"
      contentContainerStyle={{gap: 8, paddingHorizontal: 12}}>
      {REPLIES.map(reply => (
        <Pressable key={reply} onPress={() => onSend(reply)}>
          <Text>{reply}</Text>
        </Pressable>
      ))}
    </ScrollView>
  );
}

go deeper

for a junior

Recall that the default 'never' makes the first tap dismiss the keyboard, and that 'handled' lets buttons inside the ScrollView work on the first tap.

for a middle

Explain the capture-phase mechanism, why nested scroll views all need the prop, the difference between 'handled' and 'always', and the 0.87 removal of booleans.

for a senior

Design the whole keyboard interaction for input-heavy screens: taps that persist, an explicit dismissal path, and platform-specific keyboardDismissMode, tested on nested layouts.

for a principal

Set app-wide defaults in shared scroll and list wrappers so every screen gets consistent keyboard behaviour instead of per-screen fixes.

## The symptom In a chat screen the user is typing, the keyboard is up, and they tap a **quick-reply chip** ("On my way", "Sounds good") shown in a horizontal `ScrollView` above the composer. Nothing seems to happen: the keyboard closes. They tap again, and only then is the reply sent. The same happens with a Send button that lives inside a scrolling form. ## Why it happens React Native's `ScrollView` (from `react-native`) has an opinion about taps while the keyboard is open, controlled by **`keyboardShouldPersistTaps`**. With the default, **`'never'`**: 1. The keyboard is up and a text input is focused. 2. A touch starts on something inside the scroll view that is not that input. 3. In the **capture phase**, before any child can respond, the scroll view claims the touch. 4. It blurs the focused input, which dismisses the keyboard, and the child **never receives the tap**. 5. The second tap happens with the keyboard down, so it reaches the chip. This is deliberate: "tap outside to dismiss" is a sensible default for forms. It is the wrong default for controls meant to be used while typing. ## The three values | Value | Tap on a child while the keyboard is up | |---|---| | `'never'` (default) | the scroll view takes the tap and dismisses the keyboard; the child gets nothing | | `'handled'` | if a child handles the tap, it gets it and the keyboard stays; a tap on empty space still dismisses | | `'always'` | the keyboard never dismisses automatically, and the scroll view itself does not catch taps; children still can | **`'handled'`** is the right choice for a chat: chips and buttons work on the first tap, and tapping empty space still closes the keyboard. React Native 0.87 removed the old boolean form. Pass one of the three strings; `true` and `false` are no longer accepted. ## Nested scroll views The capture phase runs from the outermost ancestor inward. If a horizontal chip row sits inside a vertical screen `ScrollView`, the **outer** one sees the touch first, and with its default `'never'` it swallows the tap before the inner one's setting matters. Set `keyboardShouldPersistTaps` on **every** scroll view on the path to the control. List components built on `ScrollView` accept the same prop. ## Dismissing on purpose Once taps persist, the keyboard needs a clear way to go away: - **`Keyboard.dismiss()`** hides the keyboard and removes focus from the input. Call it after sending if the design wants the keyboard closed, or from a tap on empty space. - **`keyboardDismissMode`** on a `ScrollView` dismisses on scroll: `'on-drag'` closes the keyboard when a drag begins, and on iOS `'interactive'` lets the keyboard follow the finger down, with dragging back up cancelling. On Android `'interactive'` behaves like `'none'`, the default. A chat usually combines `keyboardShouldPersistTaps="handled"` on the list and chip row with `keyboardDismissMode="interactive"` on iOS or `'on-drag'` on Android for the message list. ## Why not use 'always' everywhere `'always'` looks like the simplest fix, but it removes the "tap outside to dismiss" behaviour entirely: the scroll view never dismisses the keyboard for you, even on a tap on empty space. On a chat screen that leaves users with no obvious way to close the keyboard except dragging, if you configured that, or tapping a control that calls `Keyboard.dismiss()`. `'handled'` keeps the expected tap-outside behaviour and only lets through taps that a child actually handles, which is almost always the intended behaviour. Reserve `'always'` for surfaces where the keyboard must stay up whatever the user taps, such as a custom number pad next to an input. ## Checklist - Is the control inside a `ScrollView` or a list built on one? Then the prop applies. - Is every ancestor scroll view set to `'handled'`? - Does the user still have a way to dismiss the keyboard? - After sending, should focus stay in the composer? Keep it there, and do not call `Keyboard.dismiss()`.

  • The chip row uses keyboardShouldPersistTaps='handled' but still needs two taps. What is the likely cause?
    An outer `ScrollView`, such as the screen's own scroll container, still has the default `'never'`. The capture phase reaches the outermost scroll view first, so it takes the tap and dismisses the keyboard before the inner setting applies. Set `'handled'` on every ancestor scroll view.
  • How do you let users drag the React Native message list to dismiss the keyboard?
    Set `keyboardDismissMode` on the scroll view: `'on-drag'` dismisses when a drag starts, and on iOS `'interactive'` makes the keyboard follow the finger, where dragging back up cancels. Android treats `'interactive'` as `'none'`, so choose the value per platform.

A receptionist who clears the desk before letting anyone speak: with 'never' your first knock only gets the desk cleared (keyboard dismissed); with 'handled' the receptionist lets you through if you already have an appointment (a child that handles the tap).

saying these in an interview costs you the question

  • keyboardShouldPersistTaps defaults to 'handled', so taps on buttons always work.
  • Setting the prop on the inner ScrollView is enough, whatever wraps it.
  • keyboardShouldPersistTaps={true} is the current way to keep taps.
  • 'always' means tapping empty space still dismisses the keyboard.
  • keyboardDismissMode='interactive' behaves the same on Android and iOS.