Back to engineering

Engineering

The focus bug hiding in a disabled input

The Bookatu engineering team4 min read

A search box that kicked the cursor out after every couple of keystrokes. The cause was one prop doing exactly what it was told, and a browser rule most of us half-remember. A short story about a sharp little bug.

The short version

  • A customer-search input lost focus after about two characters, every time
  • It had disabled set to the search's loading flag, so each keystroke that fired the lookup disabled the input for a beat
  • A disabled element loses focus. The browser blurs it. That's the spec, not a quirk
  • The fix was to never disable an input the user is actively typing in, and show a non-blocking hint instead

The report was precise, which is always a gift: type two characters into the customer search and the cursor jumps out; click back in, type one more, it jumps out again. Precise reports point at a threshold, and thresholds point at logic. Something happened at the second character that did not happen at the first.

The threshold was the search firing

The search only queried the server once you had typed at least two characters, to avoid hammering the database on a single letter. So the second keystroke was the first one that kicked off a lookup. The lookup ran inside a React transition, which exposes a pending flag while it's in flight. And that flag was wired, reasonably enough at a glance, straight into the input.

tsx
<Input
  value={query}
  onChange={(e) => search(e.target.value)}
  // The bug. isSearching goes true the moment the
  // lookup fires, which is every keystroke past two.
  disabled={isSearching}
/>

Read it out loud and it's obvious. Every keystroke past the second one starts a search, the search sets the pending flag, the flag disables the input, and then, a fraction of a second later, the search returns, the flag clears, and the input is enabled again. The user experiences this as the cursor being flung out of the box mid-word, over and over.

The rule most of us half-remember

The piece that makes this bite is a browser behaviour that's easy to forget because you rarely disable a focused element on purpose: when an element becomes disabled, it loses focus. The browser blurs it. This is defined behaviour, not a rendering accident, and it's the same reason a disabled button can't be the active element. Disable the very input someone is typing into and you have, by the spec, taken the cursor away from them.

Disabling an element blurs it. If that element is the one the user is typing in, you've just thrown their cursor across the room.

The fix, and the rule it leaves behind

The fix is to stop disabling the input at all. The search runs perfectly well in the background while the person keeps typing; there was never a reason to lock the field. Where we wanted to signal that something was happening, a small non-blocking hint next to the field does the job without touching focus. We reproduced the original bug in a browser test that types six characters and asserts the value is intact and the field still holds focus, so a future refactor can't quietly reintroduce it.

tsx
// The input is never disabled while typing. A hint shows
// state without stealing focus.
<Input value={query} onChange={(e) => search(e.target.value)} />
{isSearching && query.length >= 2 && hits.length === 0 && (
  <p className="searching-hint">Searching\u2026</p>
)}

The general rule is worth writing on the wall: never disable an interactive element while the user is mid-interaction with it. A loading state is feedback, and feedback should never remove the control the person is using. If you need to prevent a double submit, disable on the way out, not on the way in, and never on the element that currently has the cursor.

reactdomaccessibilitydebugging

Building on Bookatu?

Bookatu has a public REST API and webhooks. Have a look at the developer docs.

Developer docs