Prevent Invalid Characters in OTP Input with React Hook Form + Ant Design
When building an OTP input field, many developers try to prevent invalid characters directly from keyboard events.
Example:
onKeyDown={(e) => {
if (!/[0-9]/.test(e.key)) {
e.preventDefault();
}
}}
This works fine for English keyboards.
But it becomes unreliable when users use:
- Vietnamese Telex
- Vietnamese VNI
- Japanese IME
- Chinese Pinyin
- Korean Hangul
Especially on macOS.
The Problem
Vietnamese keyboards use an IME (Input Method Editor).
Users do not type the final character immediately.
Example:
a + s => á
a + f => à
During typing, the browser enters a composition state.
That means:
keydownkeypresspreventDefault()
cannot fully stop the final generated character.
Even worse:
Your OTP field may accidentally allow invalid characters.
Incorrect Approach
This is a common implementation:
<Input
maxLength={6}
onKeyDown={(e) => {
if (!/[0-9]/.test(e.key)) {
e.preventDefault();
}
}}
/>
Looks correct, but fails with IME keyboards.
Correct Approach
Instead of preventing keys:
✅ allow typing
✅ sanitize value afterward
✅ update form state safely
React Hook Form + Ant Design Example
import { Form, Input } from 'antd';
import { Controller, useForm } from 'react-hook-form';
type FormValues = {
otp: string;
};
export default function OTPForm() {
const { control, handleSubmit } = useForm<FormValues>({
defaultValues: {
otp: '',
},
});
const onSubmit = (values: FormValues) => {
console.log(values);
};
return (
<Form onFinish={handleSubmit(onSubmit)}>
<Controller
name="otp"
control={control}
rules={{
required: true,
minLength: 6,
maxLength: 6,
}}
render={({ field }) => (
<Input
{...field}
inputMode="numeric"
autoComplete="one-time-code"
maxLength={6}
placeholder="Enter OTP"
onChange={(e) => {
const sanitized = e.target.value.replace(/\D/g, '');
field.onChange(sanitized);
}}
/>
)}
/>
<button type="submit">Submit</button>
</Form>
);
}
Why This Works Better
Instead of blocking keyboard behavior:
preventDefault();
we sanitize the value after input:
value.replace(/\D/g, '');
This works consistently across:
- macOS
- Windows
- iOS
- Android
- Vietnamese keyboards
- Japanese IME
- Chinese IME
Optional Improvement: Handle Composition Events
For even better IME support:
const [isComposing, setIsComposing] = useState(false);
<Input
onCompositionStart={() => setIsComposing(true)}
onCompositionEnd={(e) => {
setIsComposing(false);
const sanitized = e.currentTarget.value.replace(/\D/g, '');
field.onChange(sanitized);
}}
onChange={(e) => {
if (isComposing) {
return;
}
const sanitized = e.target.value.replace(/\D/g, '');
field.onChange(sanitized);
}}
/>;
This prevents issues while the IME is still composing text.
Recommendation
For OTP fields:
✅ use inputMode="numeric"
✅ sanitize with regex
✅ support composition events
✅ avoid relying only on keydown
Avoid:
onKeyDown + preventDefault;
for international users.
Final Thoughts
Keyboard input is more complicated than it looks.
IME-based languages can bypass traditional key filtering logic, especially on macOS.
For OTP fields, sanitizing values after input is the safest and most reliable solution.