Back to Blog
reactreact-hook-formantdime

Prevent Invalid Characters in OTP Input with React Hook Form + Ant Design

3 min read

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:

  • keydown
  • keypress
  • preventDefault()

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.