A promise I made to myself 7 years ago.

A promise I made to myself 7 years ago.

OTP Render Prop Component in React

When I first started with React, I didn't know much about it. I learned the basics in just one week and landed an unpaid internship during college, though I can't recall where exactly.

In that internship, my first task was to use an OTP Component. But here's the twist – I didn't create it from scratch; I had to import a library. The tricky part? I can't remember which library I downloaded back in 2017, but I do remember it was a headache. I could barely customize how it looked, or where it went on the screen.

So, I promised myself that one day, I'd create my own OTP component. Now, seven years later, I've done it. I don't want others to get as frustrated as I did.

To all my friends out there, feel free to grab this code and use it however you want. I hope it helps you in your career much sooner than it helped me. Let's get started!

Here's the final version of what I am building.

The full code is available at the end with the code sandbox link.

Part 1: Component Design

I'm using a clever method called "render prop" in React. This means you can easily fit in your design style for the OTP component. The magic happens with the renderInputProps part. It lets you slot in your preferred look for the input fields. So, you're in control of how the OTP component looks, making it match your app's style perfectly. It's like giving you the keys to the design kingdom, making things super flexible and easy to use.

      <OTP
        length={4}
        onCompleted={(otp) => console.log("OTP Entered is", otp)}
        renderInputProps={(props) => (
          <input className="input" {...props} />
        )}
      />
  • length: Specifies the length of the OTP (One-Time Password) to be entered.

  • onCompleted: A function triggered when the user successfully enters the complete OTP. In the provided example, logs the entered OTP to the console.

  • renderInputProps: This is where the magic happens. It's a function that takes two arguments – props and index. It allows users to inject their input field design into the OTP component.

You will need to pass props in <input/> component as it gives you these things:

{
    key: ..., // A unique identifier for React elements.
    onClick: ..., // Function triggered when the input is clicked.
    onPasteCapture: ..., // Captures and handles paste events in the input.
    onChange: ..., // Handles changes in the input value.
    onKeyDown: ..., // Manages keydown events, particularly handling the 'Backspace' key.
    value: ..., // Represents the value of the input from the inputArr.
    ref: ..., // Allows the component to keep track of input references for focus and move cursor.
  }

Will explain this but first let's give you a tutorial.

Part 2: Basic version

So what do we need? we need a component that can render the amount of input boxes mentioned in the length props. Assume we have 4 input boxes, i.e., length=4 Let's do that first.

Let's Create a state of an array for input values.

const [inputArr, setInputArr] = useState(Array(length).fill(""));
// length=4 -> inputArr=["","","",""]

As we are using render props pattern, we need to render renderInputProps which is return an <input/> in a function.

    <div>
      {Array(length)
        .fill()
        .map((_, index) =>
          renderInputProps({
            key: index,
            onChange: (e) => onChange(e, index), // passing index for accesing inputArr[index]
            value: inputArr[index], // will discuss this shortly
            ref: null,
          })
        )}
    </div>

The { key: index } is props object for <input/> we will fill up later, fnName(e, index) are the handlers I am attaching to these event handlers.

This will render input like this: (remember this as your final mental model)

<input className="input" onChange={props.onChange} value={props.value} key={props.key} ref={props.ref} />

Now let's define our onChange handler:

  const onChange = (e, index) => {
    // regexp for number
    if (!e.target.value.match(/[0-9]/)) {
      return;
    }
    // take last character from array
    const lastChar = e.target.value.substring(e.target.value.length - 1);
    setInputArr((prev) => {
      const newArr = [...prev];
      newArr[index] = lastChar;
      return newArr;
    });
  };

//...
  useEffect(() => {
    // check if all inputs are filled
    if (inputArr.join("").length === length) {
      onCompleted(inputArr.join(""));
    }
  }, [inputArr]);
Algorithm
We are matching the input's value if it's a number, then whatever the input is, we will take the last character as a value for a specific input box, update them according to which onClick handler is invoked using index as an identifier. UseEffect for calling our onCompleted prop method if OTP is fully entered

Let's see the Output of this Code:

Part 2: Adding automatic cursor movements

But something looks odd, it would have been better if the cursor moved to the next input box automatically after filling. This can be solved by saving each input box ref and call ref.current.focus(). But as input boxes are multiple let's save them in an array

const inputRefArr = useRef([]);

We also need to set individual ref to its specific input box.

    <div>
      {Array(length)
        .fill()
        .map((_, index) =>
          renderInputProps({
            key: index,
            onChange: (e) => onChange(e, index),
            value: inputArr[index],
            ref: (ref) => (inputRefArr.current[index] = ref),
          })
        )}
    </div>

The mental model of this is like this:

<input 
    className="input"
    onChange={props.onChange} 
    value={props.value} 
    key={props.key} 
    ref={(ref)=>props.ref=ref} 
/>

Now we can console.log inputRefArr and is giving us the array of input boxes.

We need to focus on the next input box if the current one is filled, that is:

  const onChange = (e, index) => {
    // regexp for number
    if (!e.target.value.match(/[0-9]/)) {
      return;
    }
    // take last character from array
    const lastChar = e.target.value.substring(e.target.value.length - 1);

    setInputArr((prev) => {
      const newArr = [...prev];
      newArr[index] = lastChar;

      // check if all inputs are filled
      if (newArr.join("").length === length) {
        onCompleted(newArr.join(""));
      }
      return newArr;
    });
    // focus on next input
    if (index + 1 < length && inputRefArr.current[index + 1]) {
      inputRefArr.current[index + 1].focus();
    }
  };

Also, I want to focus on the first element when the component rendered

  useEffect(() => {
    if (inputRefArr.current[0]) {
      inputRefArr.current[0].focus();
    }
  }, []);

Now Let's see our output

Now on entering backspace key it's not doing anything, let's move the cursor to the previous input box after deleting the current input's value. this will be done using onkeyDown handler in renderInputProps's props

  const onKeyDown = (e, index) => {
    if (e.key === "Backspace") {
      // deleting current input's value
      setInputArr((prev) => {
        const newArr = [...prev];
        newArr[index] = "";
        return newArr;
      });
      // focus on previous input if present
      if (index > 0 && inputRefArr.current[index - 1]) {
        inputRefArr.current[index - 1].focus();
      }
    }
  };

will have to plug this handler in the renderInputProps like this

renderInputProps({
  key: index,
  onChange: (e) => onChange(e, index),
  onKeyDown: (e) => onKeyDown(e, index),
  value: inputArr[index],
  ref: (ref) => (inputRefArr.current[index] = ref),
})

Let's the output:

Part 3: Edge case

I think there is an edge case we need to consider, see this:

Edge case
After entering the OTP, if i click on the input and set the cursor before 3, i.e. |3 , and enter any number let's say 5 , the event.target.value is being set to 53 , and onChange is setting the last character, i.e. 3 , to solve this we can restrict the cursor to always be set after the last char.

Let's do this in onClick handler:

  const onClick = (e, index) => {
    if (inputRefArr.current[index]) {
      inputRefArr.current[index].focus();
      inputRefArr.current[index].setSelectionRange(1, 1);
    }
  };

basically setSelectionRange is used for selecting the text from the start to end position, passing start and end both as end position, and it sets the cursor to the end of the input. (Don't forget to plug in onClick in your renderInputProps function).

Part 4: OTP copy-pasting feature

We want to paste OTP from the clipboard, how can we do that?

Using onPaste handler:

  const onPasteCapture = (e, index) => {
    e.preventDefault();
    const otp = e.clipboardData.getData("Text");
    setInputArr((prev) => {
      const newArr = [...prev];
      for (let i = 0; i < length; i++) {
        if (otp[i]) {
          newArr[i] = otp[i];
        }
      }
      return newArr;
    });
    if (inputRefArr.current[length - 1]) {
      inputRefArr.current[length - 1].focus();
    }
  };

Remember to plugin it in renderInputProps function.

Conclusion

So here is the full code for OTP.jsx component

const OTP = ({ length, onCompleted, renderInputProps }) => {
  const inputRefArr = useRef([]);
  const [inputArr, setInputArr] = useState(Array(length).fill(""));

  const onChange = (e, index) => {
    // regexp for number
    if (!e.target.value.match(/[0-9]/)) {
      return;
    }
    // take last character from array
    const lastChar = e.target.value.substring(e.target.value.length - 1);
    setInputArr((prev) => {
      const newArr = [...prev];
      newArr[index] = lastChar;
      return newArr;
    });
    // focus on next input
    if (index + 1 < length && inputRefArr.current[index + 1]) {
      inputRefArr.current[index + 1].focus();
    }
  };

  const onKeyDown = (e, index) => {
    if (e.key === "Backspace") {
      setInputArr((prev) => {
        const newArr = [...prev];
        newArr[index] = "";
        return newArr;
      });

      // focus on previous input
      if (index > 0 && inputRefArr.current[index - 1]) {
        inputRefArr.current[index - 1].focus();
      }
    }
  };

  const onPaste = (e, index) => {
    e.preventDefault();
    const otp = e.clipboardData.getData("Text");
    setInputArr((prev) => {
      const newArr = [...prev];
      for (let i = 0; i < length; i++) {
        if (otp[i]) {
          newArr[i] = otp[i];
        }
      }
      return newArr;
    });
    if (inputRefArr.current[length - 1]) {
      inputRefArr.current[length - 1].focus();
    }
  };

  const onClick = (e, index) => {
    if (inputRefArr.current[index]) {
      inputRefArr.current[index].focus();
      inputRefArr.current[index].setSelectionRange(1, 1);
    }
  };

  const propFn = (index) => ({
    key: index,
    onClick: (e) => onClick(e, index),
    onPaste: (e) => onPaste(e, index),
    onChange: (e) => onChange(e, index),
    onKeyDown: (e) => onKeyDown(e, index),
    value: inputArr[index],
    ref: (ref) => (inputRefArr.current[index] = ref),
  });

  useEffect(() => {
    if (inputRefArr.current[0]) {
      inputRefArr.current[0].focus();
    }
  }, []);

  useEffect(() => {
    // check if all inputs are filled
    if (inputArr.join("").length === length) {
      onCompleted(inputArr.join(""));
    }
  }, [inputArr]);

  return (
    <div>
      {Array(length)
        .fill()
        .map((_, index) => renderInputProps(propFn(index)))}
    </div>
  );
};

export default OTP;

Here's the code link:

Hope it will help you! Thanks :)

Did you find this article valuable?

Support Kshitiz by becoming a sponsor. Any amount is appreciated!