I recently had an opportunity to create a one-time-password like component in a React app. The goal was to have a readonly separated, 6 digit/character code that can be displayed on one device and a separated input to enter that code in a web app.
The solution I arrived at is a 6-input component that advances the focus as the user types. This can also handle the “Backspace” key via the useKeyPressEvent hook from react-use.
This is the solution that worked for our case and I would love to hear others have solved this challenge as well.
What We’re Building
Parent Component
The component that is orchestrating the SeparatedInput
component will need to have a state for the value and a handler to update that state with the value that SeparatedInput
will pass back up. We can set it up like this:
Rendering The Inputs
We’re going to start with a new component and name it something like SeparatedInput.tsx
. This component will house our inputs and the handlers for them. We’ll be using styled-components to make the styling a bit easier.
We’ll need to pass our value to this component so let’s add that as a prop.
Now we need to separate this value into an array that we can map into our inputs.
We can spread our value into an array to create an array of characters. This is great if our value
is 6 characters long but doesn’t quite work if it’s less than that. To fix this, we can fill out the rest with empty strings using Array(n).fill("")
. We can then spread this into our array as well.
The line resolvedValue.length = MAX_DIGITS;
trims any elements over our length of MAX_DIGITS
(6).
From here, we can map over our array and render our inputs using those strings.
Handling Input Value Changes
We can use one function to pass to the inputs since each input will send an event on change. With this, we’ll be passing our onChange
back up via a prop.
Great! This handler will take each unique event and add that to the value. We still have an issue with typing into the inputs since they don’t advance to the next input. This will cause the value to keep growing out of sync with the focussed input.
For the focus we can leverage useRef
. We’ll get the ref of the InputContainer
so we can control the focus of its children. We’ll also need some state to handle the focus index. We can then update the focus via useEffect
on the update of focus index.
To set the focus index we can take the previous state and check to see if it’s less than the last input index and, if so, increment by one.
Cool, this seems to work pretty well for adding characters to the inputs. The next thing we’ll need to do is handle removing characters using “Backspace.” To achieve this, we’ll use the useKeyPressEvent hook. This hook takes the keyPress key as the first argument and a callback function to do something when that key is pressed.
Let’s set up the callback function first. For our specific case we want the “Backspace” key remove the last character in our value. We can use slice on the value string to handle this. After removing the item, we’ll set the focus to the previous input.
Once our function is in place we can pass it to useKeyPressEvent
.
Now we can move back and forth by typing characters and “Backspace.”
We can still click into any input and the focus will shift to that one. However, if we type it will add the character to the next input in our sequence. There are few ways to improve this experience and I’ll share the one that is currently meeting our needs, though we may update it in the future.
The input sequence we’ll use is just forward and back, without the user clicking or tabbing/shift+tabbing into other inputs. Since it’s only six characters it shouldn’t be too bad to hit “Backspace” a few times rather than update a previous input via mouse click. If it were longer I would definitely opt for a solution where you can correct any input in the chain.
We can use the disabled
attribute on the inputs to prevent the input from receiving focus. We’ll create a check to make sure that the inputs before and after our current focus index are disabled. We’ll also prevent it from disabling the last input if all values are filled.
Display the Code
This component will also be used to display the generated code to the user in the other device. In order to use this as a display component we’ll want to make sure the user can’t update the inputs. We’ll use a combination of disabled
and readonly
attributes.
The parent can pass the value
and the readOnly
props to the SeparatedInput
component.
The SeparatedInput
will then use this to check for in the handlers and inputs to make sure it will only show the value, not change it. The final component code will then be as follows.