Build a one-time-code input as a single React component: six separate single-character boxes, the kind an app shows after texting you a verification code. The interesting part isn't the boxes — it's the focus choreography. Typing a digit fills a box and jumps to the next; backspace walks back; pasting the whole code fans it out across all six. Managing that focus with refs (not DOM queries) is the skill under test.
Implement the App component in App.tsx. It receives no props and renders the provided six-box one-time-code field. Keep the digits in React state and the input elements in a useRef array.
4 — box 0 shows 4 and focus jumps to box 1. Type 2 — box 1 shows 2, focus moves to box 2.12a34-56 into any box — non-digits are removed, all boxes are replaced from box 0, box 5 takes focus, and the line reads Code: 123456.0–9 only.document.querySelector. That is the whole point.digits.join(''): show the code only at length six. Preserve the labelled group, per-box position labels, numeric hints, one-time-code autocomplete hint, and polite completion announcement already in the starter.styles.css; focus on the logic.One array of six characters is the source of truth; a ref array of the six inputs lets you move focus between boxes as the user types, deletes, and pastes.
An OTP field is six boxes that behave like one. The data is trivial — six characters — but each keystroke also has to move the caret: a digit advances to the next box, a backspace retreats, a paste scatters across all of them. State holds the characters; refs hold the DOM nodes you need to call .focus() on.
Separate the two jobs. digits (state) is what is shown; refs.current (an array of input elements) is where the caret goes next. On every interaction you update the array, then imperatively focus the right sibling through the ref. Typing pushes focus forward one box; backspace on an empty box pulls it back one box.
A common first try skips refs and reaches into the DOM to move focus:
function handleChange(i, e) {
const next = [...digits];
next[i] = e.target.value;
setDigits(next);
document.querySelectorAll('.box')[i + 1]?.focus(); // find the next box by query
}
It sort of works, but it is fragile: the component is now coupled to a global selector, a second OTP field on the page would match the wrong boxes, and it accepts letters because nothing filters the input. Focus is a component concern — keep a handle to your inputs with a ref instead of querying the whole document.
import { useRef, useState } from 'react';
import './styles.css';
const LENGTH = 6;
export default function App() {
const [digits, setDigits] = useState<string[]>(Array(LENGTH).fill(''));
const refs = useRef<(HTMLInputElement | null)[]>([]);
function setDigit(i: number, value: string) {
setDigits((prev) => {
const next = [...prev];
next[i] = value;
return next;
});
}
function handleChange(i: number, e: React.ChangeEvent<HTMLInputElement>) {
const char = e.target.value.slice(-1); // last typed character
if (char && !/\d/.test(char)) return; // ignore non-digits
setDigit(i, char);
if (char && i < LENGTH - 1) refs.current[i + 1]?.focus(); // auto-advance
}
function handleKeyDown(i: number, e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== 'Backspace') return;
if (digits[i]) {
setDigit(i, ''); // clear the current box
} else if (i > 0) {
refs.current[i - 1]?.focus(); // retreat to the previous box
setDigit(i - 1, '');
}
}
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
e.preventDefault();
const chars = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, LENGTH);
if (!chars) return;
const next = Array(LENGTH).fill('');
for (let j = 0; j < chars.length; j++) next[j] = chars[j];
setDigits(next);
refs.current[Math.min(chars.length, LENGTH) - 1]?.focus(); // focus the last filled box
}
const code = digits.join('');
return (
<main className="container">
<h1 id="otp-heading">OTP Input</h1>
<div className="boxes" role="group" aria-labelledby="otp-heading">
{digits.map((digit, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el;
}}
className="box"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
autoComplete={i === 0 ? 'one-time-code' : 'off'}
aria-label={`Digit ${i + 1} of ${LENGTH}`}
value={digit}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={handlePaste}
/>
))}
</div>
<p className="code" aria-live="polite">Code: {code.length === LENGTH ? code : '—'}</p>
</main>
);
}
digits is the only state; refs.current is just a way to reach the inputs. slice(-1) keeps the newest character even if a box briefly holds two; the regex filters non-digits; auto-advance and retreat are one .focus() call each. The paste handler rebuilds the whole array and jumps focus to the last box the code reached.
4: handleChange(0) sees char = "4", writes digits[0] = "4", and calls refs.current[1].focus() — box 1 is now active.123456 into box 1: handlePaste strips to "123456", sets the whole array, and focuses box 5. The line reads Code: 123456.digits[5] is empty, so focus moves to box 4 and box 4 is cleared.document.querySelector('.box') couples the component to the page and breaks with a second field. Keep a ref array to your inputs.ref={(el) => { refs.current[i] = el; }} with a block body; an arrow that returns the assignment is treated by React as a cleanup function./\d/ check the boxes accept letters. Reject the character before writing it to state.aria-labels, numeric and autocomplete hints, and polite status even though refs and state provide the interaction.length prop instead of hard-coding six, and build the array and refs from it.onComplete(code) prop so the form submits itself.ArrowLeft and ArrowRight in onKeyDown to move focus without changing the digits.A reducer distinguishes one position edit from a paste replacement. Refs remain responsible only for the required focus choreography.
import { useReducer, useRef } from 'react';
import './styles.css';
const LENGTH = 6;
type Action = { type: 'set'; index: number; value: string } | { type: 'replace'; digits: string[] };
function reducer(digits: string[], action: Action): string[] {
if (action.type === 'replace') return action.digits;
return digits.map((digit, index) => index === action.index ? action.value : digit);
}
export default function App() {
const [digits, dispatch] = useReducer(reducer, Array(LENGTH).fill(''));
const refs = useRef<(HTMLInputElement | null)[]>([]);
const focus = (index: number) => refs.current[index]?.focus();
function input(index: number, event: React.ChangeEvent<HTMLInputElement>) {
const digit = event.currentTarget.value.slice(-1);
if (digit && !/\d/.test(digit)) { event.currentTarget.value = digits[index]; return; }
dispatch({ type: 'set', index, value: digit });
if (digit && index < LENGTH - 1) focus(index + 1);
}
function keydown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== 'Backspace') return;
if (digits[index]) dispatch({ type: 'set', index, value: '' });
else if (index > 0) { dispatch({ type: 'set', index: index - 1, value: '' }); focus(index - 1); }
}
function paste(event: React.ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
const value = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, LENGTH);
if (!value) return;
dispatch({ type: 'replace', digits: Array.from({ length: LENGTH }, (_, index) => value[index] ?? '') });
focus(Math.min(value.length, LENGTH) - 1);
}
const code = digits.join('');
return <main className="container">
<h1 id="otp-heading">OTP Input</h1>
<div className="boxes" role="group" aria-labelledby="otp-heading">
{digits.map((digit, index) => <input key={index} ref={(element) => { refs.current[index] = element; }} className="box" inputMode="numeric" pattern="[0-9]*" maxLength={1} autoComplete={index === 0 ? 'one-time-code' : 'off'} aria-label={`Digit ${index + 1} of ${LENGTH}`} value={digit} onChange={(event) => input(index, event)} onKeyDown={(event) => keydown(index, event)} onPaste={paste} />)}
</div>
<p className="code" aria-live="polite">Code: {code.length === LENGTH ? code : '—'}</p>
</main>;
}A custom hook packages the six cell model, refs, sanitization, and navigation while preserving the same rendered component contract.
import { useRef, useState } from 'react';
import './styles.css';
const LENGTH = 6;
function useOtp() {
const [digits, setDigits] = useState<string[]>(() => Array(LENGTH).fill(''));
const refs = useRef<(HTMLInputElement | null)[]>([]);
const write = (index: number, value: string) => setDigits((current) => current.map((digit, position) => position === index ? value : digit));
const edit = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.value.slice(-1);
if (value && !/^\d$/.test(value)) { event.currentTarget.value = digits[index]; return; }
write(index, value);
if (value && index + 1 < LENGTH) refs.current[index + 1]?.focus();
};
const erase = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Backspace') return;
if (digits[index]) write(index, '');
else if (index > 0) { write(index - 1, ''); refs.current[index - 1]?.focus(); }
};
const distribute = (event: React.ClipboardEvent<HTMLInputElement>) => {
event.preventDefault();
const clean = event.clipboardData.getData('text').match(/\d/g)?.join('').slice(0, LENGTH) ?? '';
if (!clean) return;
setDigits(Array.from({ length: LENGTH }, (_, index) => clean[index] ?? ''));
refs.current[clean.length - 1]?.focus();
};
return { digits, refs, edit, erase, distribute };
}
export default function App() {
const otp = useOtp();
const joined = otp.digits.join('');
return <main className="container">
<h1 id="otp-heading">OTP Input</h1>
<div className="boxes" role="group" aria-labelledby="otp-heading">
{otp.digits.map((digit, index) => <input key={index} ref={(element) => { otp.refs.current[index] = element; }} className="box" inputMode="numeric" pattern="[0-9]*" maxLength={1} autoComplete={index === 0 ? 'one-time-code' : 'off'} aria-label={`Digit ${index + 1} of ${LENGTH}`} value={digit} onChange={(event) => otp.edit(index, event)} onKeyDown={(event) => otp.erase(index, event)} onPaste={otp.distribute} />)}
</div>
<p className="code" aria-live="polite">Code: {joined.length === LENGTH ? joined : '—'}</p>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
Build a one-time-code input as a single React component: six separate single-character boxes, the kind an app shows after texting you a verification code. The interesting part isn't the boxes — it's the focus choreography. Typing a digit fills a box and jumps to the next; backspace walks back; pasting the whole code fans it out across all six. Managing that focus with refs (not DOM queries) is the skill under test.
Implement the App component in App.tsx. It receives no props and renders the provided six-box one-time-code field. Keep the digits in React state and the input elements in a useRef array.
4 — box 0 shows 4 and focus jumps to box 1. Type 2 — box 1 shows 2, focus moves to box 2.12a34-56 into any box — non-digits are removed, all boxes are replaced from box 0, box 5 takes focus, and the line reads Code: 123456.0–9 only.document.querySelector. That is the whole point.digits.join(''): show the code only at length six. Preserve the labelled group, per-box position labels, numeric hints, one-time-code autocomplete hint, and polite completion announcement already in the starter.styles.css; focus on the logic.One array of six characters is the source of truth; a ref array of the six inputs lets you move focus between boxes as the user types, deletes, and pastes.
An OTP field is six boxes that behave like one. The data is trivial — six characters — but each keystroke also has to move the caret: a digit advances to the next box, a backspace retreats, a paste scatters across all of them. State holds the characters; refs hold the DOM nodes you need to call .focus() on.
Separate the two jobs. digits (state) is what is shown; refs.current (an array of input elements) is where the caret goes next. On every interaction you update the array, then imperatively focus the right sibling through the ref. Typing pushes focus forward one box; backspace on an empty box pulls it back one box.
A common first try skips refs and reaches into the DOM to move focus:
function handleChange(i, e) {
const next = [...digits];
next[i] = e.target.value;
setDigits(next);
document.querySelectorAll('.box')[i + 1]?.focus(); // find the next box by query
}
It sort of works, but it is fragile: the component is now coupled to a global selector, a second OTP field on the page would match the wrong boxes, and it accepts letters because nothing filters the input. Focus is a component concern — keep a handle to your inputs with a ref instead of querying the whole document.
import { useRef, useState } from 'react';
import './styles.css';
const LENGTH = 6;
export default function App() {
const [digits, setDigits] = useState<string[]>(Array(LENGTH).fill(''));
const refs = useRef<(HTMLInputElement | null)[]>([]);
function setDigit(i: number, value: string) {
setDigits((prev) => {
const next = [...prev];
next[i] = value;
return next;
});
}
function handleChange(i: number, e: React.ChangeEvent<HTMLInputElement>) {
const char = e.target.value.slice(-1); // last typed character
if (char && !/\d/.test(char)) return; // ignore non-digits
setDigit(i, char);
if (char && i < LENGTH - 1) refs.current[i + 1]?.focus(); // auto-advance
}
function handleKeyDown(i: number, e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key !== 'Backspace') return;
if (digits[i]) {
setDigit(i, ''); // clear the current box
} else if (i > 0) {
refs.current[i - 1]?.focus(); // retreat to the previous box
setDigit(i - 1, '');
}
}
function handlePaste(e: React.ClipboardEvent<HTMLInputElement>) {
e.preventDefault();
const chars = e.clipboardData.getData('text').replace(/\D/g, '').slice(0, LENGTH);
if (!chars) return;
const next = Array(LENGTH).fill('');
for (let j = 0; j < chars.length; j++) next[j] = chars[j];
setDigits(next);
refs.current[Math.min(chars.length, LENGTH) - 1]?.focus(); // focus the last filled box
}
const code = digits.join('');
return (
<main className="container">
<h1 id="otp-heading">OTP Input</h1>
<div className="boxes" role="group" aria-labelledby="otp-heading">
{digits.map((digit, i) => (
<input
key={i}
ref={(el) => {
refs.current[i] = el;
}}
className="box"
inputMode="numeric"
pattern="[0-9]*"
maxLength={1}
autoComplete={i === 0 ? 'one-time-code' : 'off'}
aria-label={`Digit ${i + 1} of ${LENGTH}`}
value={digit}
onChange={(e) => handleChange(i, e)}
onKeyDown={(e) => handleKeyDown(i, e)}
onPaste={handlePaste}
/>
))}
</div>
<p className="code" aria-live="polite">Code: {code.length === LENGTH ? code : '—'}</p>
</main>
);
}
digits is the only state; refs.current is just a way to reach the inputs. slice(-1) keeps the newest character even if a box briefly holds two; the regex filters non-digits; auto-advance and retreat are one .focus() call each. The paste handler rebuilds the whole array and jumps focus to the last box the code reached.
4: handleChange(0) sees char = "4", writes digits[0] = "4", and calls refs.current[1].focus() — box 1 is now active.123456 into box 1: handlePaste strips to "123456", sets the whole array, and focuses box 5. The line reads Code: 123456.digits[5] is empty, so focus moves to box 4 and box 4 is cleared.document.querySelector('.box') couples the component to the page and breaks with a second field. Keep a ref array to your inputs.ref={(el) => { refs.current[i] = el; }} with a block body; an arrow that returns the assignment is treated by React as a cleanup function./\d/ check the boxes accept letters. Reject the character before writing it to state.aria-labels, numeric and autocomplete hints, and polite status even though refs and state provide the interaction.length prop instead of hard-coding six, and build the array and refs from it.onComplete(code) prop so the form submits itself.ArrowLeft and ArrowRight in onKeyDown to move focus without changing the digits.A reducer distinguishes one position edit from a paste replacement. Refs remain responsible only for the required focus choreography.
import { useReducer, useRef } from 'react';
import './styles.css';
const LENGTH = 6;
type Action = { type: 'set'; index: number; value: string } | { type: 'replace'; digits: string[] };
function reducer(digits: string[], action: Action): string[] {
if (action.type === 'replace') return action.digits;
return digits.map((digit, index) => index === action.index ? action.value : digit);
}
export default function App() {
const [digits, dispatch] = useReducer(reducer, Array(LENGTH).fill(''));
const refs = useRef<(HTMLInputElement | null)[]>([]);
const focus = (index: number) => refs.current[index]?.focus();
function input(index: number, event: React.ChangeEvent<HTMLInputElement>) {
const digit = event.currentTarget.value.slice(-1);
if (digit && !/\d/.test(digit)) { event.currentTarget.value = digits[index]; return; }
dispatch({ type: 'set', index, value: digit });
if (digit && index < LENGTH - 1) focus(index + 1);
}
function keydown(index: number, event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key !== 'Backspace') return;
if (digits[index]) dispatch({ type: 'set', index, value: '' });
else if (index > 0) { dispatch({ type: 'set', index: index - 1, value: '' }); focus(index - 1); }
}
function paste(event: React.ClipboardEvent<HTMLInputElement>) {
event.preventDefault();
const value = event.clipboardData.getData('text').replace(/\D/g, '').slice(0, LENGTH);
if (!value) return;
dispatch({ type: 'replace', digits: Array.from({ length: LENGTH }, (_, index) => value[index] ?? '') });
focus(Math.min(value.length, LENGTH) - 1);
}
const code = digits.join('');
return <main className="container">
<h1 id="otp-heading">OTP Input</h1>
<div className="boxes" role="group" aria-labelledby="otp-heading">
{digits.map((digit, index) => <input key={index} ref={(element) => { refs.current[index] = element; }} className="box" inputMode="numeric" pattern="[0-9]*" maxLength={1} autoComplete={index === 0 ? 'one-time-code' : 'off'} aria-label={`Digit ${index + 1} of ${LENGTH}`} value={digit} onChange={(event) => input(index, event)} onKeyDown={(event) => keydown(index, event)} onPaste={paste} />)}
</div>
<p className="code" aria-live="polite">Code: {code.length === LENGTH ? code : '—'}</p>
</main>;
}A custom hook packages the six cell model, refs, sanitization, and navigation while preserving the same rendered component contract.
import { useRef, useState } from 'react';
import './styles.css';
const LENGTH = 6;
function useOtp() {
const [digits, setDigits] = useState<string[]>(() => Array(LENGTH).fill(''));
const refs = useRef<(HTMLInputElement | null)[]>([]);
const write = (index: number, value: string) => setDigits((current) => current.map((digit, position) => position === index ? value : digit));
const edit = (index: number, event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.value.slice(-1);
if (value && !/^\d$/.test(value)) { event.currentTarget.value = digits[index]; return; }
write(index, value);
if (value && index + 1 < LENGTH) refs.current[index + 1]?.focus();
};
const erase = (index: number, event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key !== 'Backspace') return;
if (digits[index]) write(index, '');
else if (index > 0) { write(index - 1, ''); refs.current[index - 1]?.focus(); }
};
const distribute = (event: React.ClipboardEvent<HTMLInputElement>) => {
event.preventDefault();
const clean = event.clipboardData.getData('text').match(/\d/g)?.join('').slice(0, LENGTH) ?? '';
if (!clean) return;
setDigits(Array.from({ length: LENGTH }, (_, index) => clean[index] ?? ''));
refs.current[clean.length - 1]?.focus();
};
return { digits, refs, edit, erase, distribute };
}
export default function App() {
const otp = useOtp();
const joined = otp.digits.join('');
return <main className="container">
<h1 id="otp-heading">OTP Input</h1>
<div className="boxes" role="group" aria-labelledby="otp-heading">
{otp.digits.map((digit, index) => <input key={index} ref={(element) => { otp.refs.current[index] = element; }} className="box" inputMode="numeric" pattern="[0-9]*" maxLength={1} autoComplete={index === 0 ? 'one-time-code' : 'off'} aria-label={`Digit ${index + 1} of ${LENGTH}`} value={digit} onChange={(event) => otp.edit(index, event)} onKeyDown={(event) => otp.erase(index, event)} onPaste={otp.distribute} />)}
</div>
<p className="code" aria-live="polite">Code: {joined.length === LENGTH ? joined : '—'}</p>
</main>;
}Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.