Simple One Time Password OTP Component for React Native

No library needed, just a ~120-line component.

Posted on October 3, 2022

Open Source Library vs. Do it Yourself

Often we like to just reach for an open source library to accomplish a 'simple enough functionality'. This is an enticing pattern in software engineering at first glance, but beneath the hood of something like the rapidly changing and evolving React Native ecosystem, there are often many problems with compatibility and versioning that render open-source components or libraries broken after just a few short months of each release. One example case is that of an input for a one-time password (OTP) component. There are a handful of libraries that seem to be floating around, but in our experience almost all of them are not heavily maintained and they seem to be riddled with bugs and issues. We ultimately came to the conclusion to create our own version of this component in-house. Ultimately it was not too hard to accomplish through built-in components that ship with React Native itself.

Show Me the Code!

I'm going to keep this post short and sweet. Here's the code:

import {useIsFocused} from '@react-navigation/native';
import * as React from 'react';
import {useRef} from 'react';
import {TextInput, View} from 'react-native';
import {isIOS} from '../../helpers/utils';
import {useTimeout} from '../../hooks/useTimeout';

export interface IOTPInputProps {
  otpCodeChanged: (otpCode: string) => void;
}

const NUMBER_OF_INPUTS = 6;

export function OTPInput(props: IOTPInputProps) {
  const {otpCodeChanged} = props;
  const isFocused = useIsFocused();
  const [values, setValues] = React.useState<string[]>([
    '',
    '',
    '',
    '',
    '',
    '',
  ]);
  const itemsRef = useRef<Array<TextInput | null>>([]);

  const applyOTPCodeToInputs = (code: string) => {
    // split up code and apply it to all inputs
    const codeArray = code.split('');
    codeArray.forEach((char, index) => {
      const input = itemsRef.current[index];
      if (input) {
        input.setNativeProps({
          text: char,
        });
      }
    });
    // focus on last input as a cherry on top
    const lastInput = itemsRef.current[itemsRef.current.length - 1];
    if (lastInput) {
      lastInput.focus();
      otpCodeChanged(code);
    }
  };

  useTimeout(
    () => {
      // focus on the first input
      const firstInput = itemsRef.current[0];
      if (firstInput) {
        firstInput.focus();
      }
    },
    isFocused ? 1000 : null,
  );

  return (
    <View
      style={{flex: 1, flexDirection: 'row', justifyContent: 'space-around'}}>
      {Array.from({length: NUMBER_OF_INPUTS}, (_, index) => (
        <TextInput
          style={{
            paddingLeft: isIOS() ? 0 : 0,
            paddingRight: isIOS() ? 10 : 0,
          }}
          ref={(el) => (itemsRef.current[index] = el)}
          key={index}
          keyboardType={'numeric'}
          placeholder={'X'}
          value={values[index]}
          defaultValue=""
          // first input can have a length of 6 because they paste their code into it
          maxLength={index === 0 ? 6 : 1}
          onChange={(event) => {
            const {text} = event.nativeEvent;

            // only continue one if we see a text of length 1 or 6
            if (text.length === 0 || text.length === 1 || text.length === 6) {
              if (text.length === 6) {
                applyOTPCodeToInputs(text);
                return;
              }
              // going forward, only if text is not empty
              if (text.length === 1 && index !== NUMBER_OF_INPUTS - 1) {
                const nextInput = itemsRef.current[index + 1];
                if (nextInput) {
                  nextInput.focus();
                }
              }
            }
            // determine new value
            const newValues = [...values];
            newValues[index] = text;

            // update state
            setValues(newValues);
            // also call callback as a flat string
            otpCodeChanged(newValues.join(''));
          }}
          onKeyPress={(event) => {
            if (event.nativeEvent.key === 'Backspace') {
              // going backward:
              if (index !== 0) {
                const previousInput = itemsRef.current[index - 1];
                if (previousInput) {
                  previousInput.focus();
                  return;
                }
              }
            }
          }}
          textContentType="oneTimeCode"
        />
      ))}
    </View>
  );
}

Caveats

Note this code makes use of a variety of imports from third parties and other custom code. The first is useIsFocused by @react-navigation/native — this simply switches to true when the screen is focused, false otherwise. This hook is used to focus on the first input whenever the screen using theOTPInput component is focused. Next is a small utility function isIOS() that is just a wrapper around a call on the Platform object imported from react-native (it's more idiomatic and a bit smaller for our team):

export const isIOS = () => {
    return Platform.OS === 'ios';
};

Then finally we have a useTimeout hook that triggers the keyboard opening in a react-safe way:

// Taken from https://github.com/antonioru/beautiful-react-hooks/blob/master/src/useTimeout.ts
import {useCallback, useEffect, useRef, useState} from 'react';
export type UseTimeoutOptions = {
  cancelOnUnmount?: boolean;
};
const defaultOptions = {
  cancelOnUnmount: true,
};
/**
 * An async-utility hook that accepts a callback function and a delay time (in milliseconds), then delays the
 * execution of the given function by the defined time.
 */
export const useTimeout = <T extends (...args: any[]) => any>(
  fn: T,
  milliseconds: number | null,
  options: UseTimeoutOptions = defaultOptions,
): [boolean, () => void] => {
  const opts = {...defaultOptions, ...(options || {})};
  const timeout = useRef<NodeJS.Timeout>();
  const callback = useRef<T>(fn);
  const [isCleared, setIsCleared] = useState<boolean>(false);
// the clear method
  const clear = useCallback(() => {
    if (timeout.current) {
      clearTimeout(timeout.current);
      setIsCleared(true);
    }
  }, []);
// if the provided function changes, change its reference
  useEffect(() => {
    if (typeof fn === 'function') {
      callback.current = fn;
    }
  }, [fn]);
// when the milliseconds change, reset the timeout (if not null)
  // if milliseconds are null, clear the timeout
  useEffect(() => {
    if (milliseconds !== null) {
      timeout.current = setTimeout(() => {
        callback.current();
      }, milliseconds);
    } else {
      clear();
    }
    return clear;
  }, [milliseconds]);
// when component unmount clear the timeout
  useEffect(
    () => () => {
      if (opts.cancelOnUnmount) {
        clear();
      }
    },
    [],
  );
return [isCleared, clear];
};

I found that using such a timeout was a bit smoother when opening the keyboard after navigating to our OTP screen.

Thanks!

If this post helped you or your team out, give it a clap or two, and follow if you want more software posts!

Also note this post was very rushed so I may add it or modify it later. :)

Cheers🍺

-Chris

Next / Previous Post:

Find more posts by tag:

-~{/* */}~-