Skip to content
Sunstone Apps
← Back to blog
React Native

A hidden TextInput can't capture a hardware keyboard on React Native's New Architecture

The keyboard was supposed to stay hidden. It didn't. dumpsys said the soft keyboard was on screen even though we asked it not to be, and that single line of adb output changed the whole approach.

Published: 8 min read
OpenGraph preview image for this article. A hidden TextInput can't capture a hardware keyboard on React Native's New Architecture

Daily Sudoku: Offline Puzzle already supported a physical keyboard on the web build. You select a cell, press 1 through 9, hit Backspace to erase, and use h, a, n for hint, auto notes, and toggle notes. On the web it is a few lines: a document.addEventListener("keydown", ...) handler that maps keys to the same actions the on-screen buttons call.

Then we wanted the same thing on Android, because people test on emulators with the host keyboard and some play on a phone with a USB keyboard attached. On the web that document listener does the job. On native there is no document, so nothing was wired at all. The Android version had simply never supported the feature.

This post is about the wrong turn we took first, the adb command that killed the hypothesis, and the config-plugin fix that holds under React Native’s New Architecture.

The obvious first attempt

If you search for “capture hardware keyboard React Native,” you land on the hidden TextInput trick almost immediately. The idea is simple, and it is genuinely how barcode-scanner apps often work: render an invisible, always-focused TextInput, tell it not to show the soft keyboard, and read the characters as they arrive.

The version we wrote looked reasonable:

<TextInput
  ref={inputRef}
  value=""
  onChangeText={handleChangeText}
  onKeyPress={handleKeyPress}
  onBlur={() => inputRef.current?.focus()}
  autoFocus
  caretHidden
  showSoftInputOnFocus={false}
  style={{ position: "absolute", left: -1000, top: -1000, opacity: 0 }}
/>

onChangeText gives you the typed character (numbers and letters), onKeyPress gives you Backspace, and showSoftInputOnFocus={false} is the documented way to keep the on-screen keyboard from popping up while the field is focused. On a phone with a hardware keyboard you want the field focused to receive keys, but you never want the touch keyboard covering half the board.

It typechecked, it linted, it looked done. It was not done.

The uncomfortable check: ask the device, not the code

The honest way to verify “the soft keyboard is not showing” is not to glance at the emulator. It is to ask Android directly. The input method service says what it thinks it is doing:

adb -s emulator-5554 shell dumpsys input_method | grep -iE "mInputShown|mServedView"

The answer was blunt:

mInputShown=true
mServedView=com.facebook.react.views.textinput.ReactEditText{... -2625,-2625 ...}

Two facts in one line. First, mInputShown=true: the soft keyboard was on screen despite showSoftInputOnFocus={false}. Second, the view holding input focus was a ReactEditText positioned at -2625, -2625. That was our hidden field. left: -1000 in density-independent pixels, on a 420-dpi emulator with a scale factor of 2.625, lands at exactly -2625 real pixels. The served view was ours, it was focused, and the keyboard it summoned was showing.

The prop we relied on was being ignored. Not “sometimes flaky.” Ignored, on the build in front of us.

Why the prop is ignored: the New Architecture

The project runs on React Native’s New Architecture, with newArchEnabled=true in gradle.properties. showSoftInputOnFocus={false} maps to Android’s setShowSoftInputOnFocus(false) on the underlying EditText, and that path has a long, well-documented history of not being respected on Android, especially across lifecycle and focus changes. Under Fabric it is worse: the common answers turn into a workaround where you call Keyboard.dismiss() inside onFocus and then refocus the field, which flickers the keyboard on and off and sometimes drops focus entirely. That is not a fix, it is a fight with the platform that the player watches happen.

There is a deeper point here that is easy to skip past. The hidden-TextInput approach tries to make a text field forward keys while suppressing its own keyboard. On a stack where the suppression prop is unreliable, the whole trick is built on the one part that does not work. No amount of prop-tweaking changes that the strategy depends on a broken guarantee.

The other quiet trap: the argument for the hidden input was “it needs no native rebuild, it is pure JS.” But there was no Metro dev server running; the app under test was a release-style APK. Any change, JS or native, required a rebuild to try. So the approach’s only real advantage had already evaporated, and what remained was a defect.

The fix: capture keys at the Activity, not in a text field

A hardware keyboard on Android delivers key events to the focused Activity through onKeyDown. If you handle them there, you never need a focused text field, which means you never summon an IME, which means the soft keyboard simply cannot appear. That is the whole idea behind react-native-keyevent: it exposes the Activity’s onKeyDown/onKeyUp to JavaScript over the event emitter.

The JavaScript side is intentionally small:

import KeyEvent from "react-native-keyevent";

React.useEffect(() => {
  if (Platform.OS !== "android") return;

  KeyEvent.onKeyDownListener((event) => {
    if (event.keyCode === 67 || event.keyCode === 112) {
      onErase(); // Backspace / Forward Delete
      return;
    }
    const key = (event.pressedKey ?? "").toLowerCase();
    if (key >= "1" && key <= "9") onDigit(Number(key));
    else if (key === "0") onErase();
    else if (key === "h") onHint();
    else if (key === "a") onAutoNotes();
    else if (key === "n") onToggleNotes();
  });

  return () => KeyEvent.removeKeyDownListener();
}, []);

return null;

Note the component renders null. There is no view, no input, nothing focusable. We use keyCode for the delete keys (backspace does not always arrive as a printable character) and pressedKey for everything else. Web still uses the document listener; iOS is intentionally left as a no-op because we do not install the AppDelegate hook there.

The native half has to be a config plugin

react-native-keyevent needs the Activity to forward its key events:

override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
  if (event != null) {
    KeyEventModule.getInstance().onKeyDownEvent(keyCode, event)
  }
  return super.onKeyDown(keyCode, event)
}

The instinct is to paste that into MainActivity.kt. Do not. In an Expo project the entire android/ folder is generated by expo prebuild, so a hand edit to MainActivity.kt is erased the next time anyone regenerates the project. The durable place for native changes is a config plugin.

Our plugin uses withMainActivity, inserts the two imports right after the package line, and appends the overrides before the class’s closing brace. It also guards on the language, because a Kotlin MainActivity and a Java one need different code:

const { createRunOncePlugin, withMainActivity } = require("expo/config-plugins");

const withKeyEvent = (config) =>
  withMainActivity(config, (mod) => {
    if (mod.modResults.language !== "kt") {
      throw new Error("[withKeyEvent] Expected a Kotlin MainActivity.");
    }
    let contents = mod.modResults.contents;
    // ...insert imports after the package line, append overrides before the last brace...
    mod.modResults.contents = contents;
    return mod;
  });

module.exports = createRunOncePlugin(withKeyEvent, "with-key-event", "1.0.0");

Register it in app.json next to the other plugins, and it runs on every prebuild. Because the change is now part of prebuild, the build command matters: a fast incremental Gradle build skips prebuild and will not apply the plugin or link the new module. You need the full path that runs expo prebuild first. In our repo that is pnpm sudoku:apk:prd:full; the fast pnpm sudoku:apk:prd variant deliberately does not run prebuild.

Honest caveats

react-native-keyevent is a legacy module built on the old bridge. On the New Architecture it runs through the interop layer, which is fine for a simple event emitter like this. Still, it is better to say that plainly than pretend it became a first-class Fabric module. If the listener ever goes silent after a RN upgrade, the interop layer is the first suspect.

We also kept the scope small. Digits, erase, and three letter shortcuts cover essentially all of the value of playing Sudoku with a keyboard. Arrow-key navigation stays web-only, not because it is impossible with react-native-keyevent, but because it was not worth the extra surface for how the game is actually played. Shipping the useful path beats polishing a shortcut almost no one asked for.

The takeaway

Two habits did the real work here. The first: when a UI claim is testable, test it against the device instead of your eyes. mInputShown=true ended an argument that “it looks hidden to me” could have dragged on for an hour. The second: match the tool to the platform’s actual guarantees. A hidden TextInput leans on showSoftInputOnFocus, and once you know that prop is unreliable under Fabric, you stop trying to make a broken foundation carry the feature and move the capture to where the platform is dependable, the Activity itself.

The soft keyboard stays down now, the number keys land in the right cells, and we can prove it with one line of adb instead of squinting at the screen.

Contact Sunstone Apps