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