All writing
6 min read

What Building an OTP Input Taught Me About Controlled Components

Auto-advance, paste, and backspace look trivial until you build them — here are the controlled-input lessons that survive every form I ship.

ReactFrontendDX

An OTP input is just six little boxes. How hard could it be? Hard enough that I shipped a library for it. The bugs you hit building one are the same ones that quietly haunt every serious form: focus management, paste handling, and keeping state and DOM honest. Here's what stuck.

Lesson 1: one source of truth, derived boxes

The naive approach gives each box its own useState. That's six states to keep in sync and a nightmare for paste. Hold the whole code as one string and derive each box from it:

tsx
const [code, setCode] = useState("");
const digits = Array.from({ length: 6 }, (_, i) => code[i] ?? "");

Now there is exactly one thing to update, validate, and submit. The boxes are a view of the string, not six independent truths. This is the controlled-component mindset that scales to any complex field.

Lesson 2: paste is the real feature, not an edge case

Users paste the code from their SMS far more than they type it. If you only handle onChange per box, paste dumps all six digits into box one. Intercept it:

tsx
function onPaste(e: React.ClipboardEvent) {
  e.preventDefault();
  const pasted = e.clipboardData.getData("text").replace(/\D/g, "").slice(0, 6);
  setCode(pasted);
  // move focus to the last filled box
  inputs.current[Math.min(pasted.length, 5)]?.focus();
}

Strip non-digits, clamp the length, set state once, then move focus. Treating paste as a first-class path is what makes the component feel native instead of fussy.

Lesson 3: backspace needs to read the DOM, not just state

Auto-advance is easy. The subtle bug is backspace: when a box is already empty, the user expects backspace to clear the previous box and jump there. You can't know that from React state alone — you need the key event:

tsx
function onKeyDown(e: React.KeyboardEvent, i: number) {
  if (e.key === "Backspace" && !digits[i] && i > 0) {
    e.preventDefault();
    setCode((c) => c.slice(0, i - 1));
    inputs.current[i - 1]?.focus();
  }
}

Imperative focus with a ref array is the right tool here. Controlled state owns the *value*; refs own the *focus*. Mixing those responsibilities is where most custom inputs go wrong.

Lesson 4: fire onComplete from an effect, not a handler

It's tempting to call onComplete inside the change handler when length hits six. But paste and programmatic resets bypass that path. Derive it instead:

tsx
useEffect(() => {
  if (code.length === 6) onComplete?.(code);
}, [code, onComplete]);

React the value, not the event. Every code path that produces a full code now triggers completion — exactly once — without you wiring each one by hand.

The takeaway

Keep one source of truth and derive the UI from it. Let controlled state own values and refs own focus. React to derived conditions with effects instead of trying to cover every event. Those four rules turned a fiddly OTP widget into something reliable — and they make every checkout and login form I build behave the same way.


Written by Fady Ehab Amer

Get in touch →

Keep reading