Skip to main content

One-Time Password Generator

The Project

A freeCodeCamp Lab that turned out to be more of a conscious review than a discovery: useState, useEffect, conditional rendering. All already seen. But the freedom on the CSS and a question to the Code Tutor about cryptographic security made the exercise more interesting than I expected.

Source Code

/* DESIGN
------
* This file contains the React logic for the OTP Generator application
* The architecture focuses on the "Side Effect" pattern and secure randomness:
*
* The Generation Logic (Cryptographic Randomness):
* - I used `window.crypto.getRandomValues()` instead of `Math.random()` because
* Math.random is pseudo-random (predictable), while the Web Crypto API pulls
* entropy from the operating system, making the OTP genuinely unpredictable
* - The modulo trick (`% 1000000`) extracts exactly 6 digits from a 32-bit integer,
* and `padStart(6, "0")` ensures codes like "000042" aren't truncated to "42"
*
* The Timer System (useEffect + setInterval):
* - I implemented a countdown using `useEffect` that watches two dependencies:
* `isActive` (whether the timer is running) and `timeLeft` (remaining seconds)
* - The effect contains a Guard Clause pattern: if the timer isn't active or has
* reached zero, it exits early and resets `isActive` to re-enable the button
* - When active, a `setInterval` decrements `timeLeft` every second
* - The cleanup function (`return () => clearInterval(...)`) is critical because
* this effect re-runs every time `timeLeft` changes (it's in the dependency array:
* `[isActive, timeLeft]`)
* Without cleanup:
* - Second 1: interval #1 created → decrements timeLeft to 4
* - Second 2: effect re-runs → interval #1 still active + interval #2 created
* - Second 3: intervals #1, #2, #3 all active → timeLeft decrements by 3 at once
* - Result: the countdown accelerates exponentially until the timer completes
* The cleanup ensures the old interval is destroyed before creating a new one,
* maintaining exactly one active interval at any time
*
* The UI State Machine:
* - The display cycles through three visual states driven by two boolean conditions:
* 1. No OTP yet → placeholder message (styled with `.empty` class)
* 2. OTP active → large 6-digit code with live countdown
* 3. OTP expired → same code visible, but timer shows expiration message
* - The button's `disabled` prop is bound directly to `isActive`, creating a
* self-regulating loop: generate → disable → countdown → enable.
*/

/* freeCodeCamp instructions:
* 1. You should use the useEffect hook to manage the countdown timer. ✔️
* 2. Your OTPGenerator component should return a div element with the class name container. ✔️
* 3. The div having the class container should include the following elements:
* - An h1 element with the ID otp-title and text "OTP Generator". ✔️
* - An h2 element with the ID otp-display that either displays the message "Click 'Generate OTP' to get a code"
* or shows the generated OTP if one is available. ✔️
* - A p element with the ID otp-timer and aria-live attribute set to a valid value that:
* - Starts off empty. ✔️
* - Displays "Expires in: X seconds" after the button is clicked, where X represents the remaining time
* before the OTP expires. ✔️
* - Shows the message "OTP expired. Click the button to generate a new OTP." once the countdown reaches 0. ✔️
* - A button element with the ID generate-otp-button labeled "Generate OTP". When clicked, it should generate a
* new OTP and start a 5-second countdown. ✔️
* - The "Generate OTP" button must be disabled while the countdown is active. ✔️
* 4. You should ensure the countdown timer stops automatically once it reaches 0 seconds to prevent unnecessary updates. ✔️
* 5. The generated OTP should be 6 digits long. ✔️
*/

const { useState, useEffect, useRef } = React;

export const OTPGenerator = () => {
const [otp, setOtp] = useState("");
const [timeLeft, setTimeLeft] = useState(0);
const [isActive, setIsActive] = useState(false);

const generateOTP = () => {
/*
* I chose the Web Crypto API over Math.random() for security reasons and to gain
* hands-on experience with the API
* `Uint32Array(1)` creates a typed array that holds one 32-bit unsigned integer,
* and `getRandomValues` fills it with cryptographically strong random data
* from the OS entropy pool (e.g. 2,567,523,558)
* The modulo operation (`% 1000000`) extracts the last 6 digits (e.g. 523,558),
* and `padStart` ensures we always get exactly 6 characters, even if the result
* starts with zeros (e.g. 4,821 becomes "004821")
*/
const array = new Uint32Array(1);
window.crypto.getRandomValues(array);
const sixDigits = array[0] % 1000000;
const secureOtp = sixDigits.toString().padStart(6, "0");

setOtp(secureOtp);
setTimeLeft(5);
setIsActive(true);
};

useEffect(() => {
/*
* Guard Clause pattern: I check the "stop conditions" first and exit early
* If the timer isn't active OR has reached zero, there's nothing to do
* Important detail: when `timeLeft` hits 0, I must also set `isActive` to false,
* otherwise the button would stay disabled forever since nothing would reset it
*/
if (!isActive || timeLeft === 0) {
if (timeLeft === 0) setIsActive(false);
return;
}

/*
* The interval engine: if we reach this point, the timer is active and has
* seconds remaining. I set up a `setInterval` that decrements `timeLeft` by 1
* every 1000ms
*
* The cleanup function (the returned arrow function) is critical here:
* because `useEffect` re-runs every time `timeLeft` changes, without cleanup
* each re-render would stack a NEW interval on top of the old one, causing
* the countdown to accelerate exponentially. The cleanup kills the previous
* interval before the next one starts
*/
const intervalId = setInterval(() => {
setTimeLeft(timeLeft - 1);
}, 1000);

return () => clearInterval(intervalId);

}, [isActive, timeLeft]); // Re-run when timer state or countdown value changes

return (
<div className="container">
<h1 id="otp-title" className="title">OTP Generator</h1>

{/*
* Conditional rendering with ternary:
* If `otp` has a value, I display the 6-digit code; otherwise, I show
* the placeholder message. The `.empty` class is toggled dynamically
* to switch between the large monospace OTP style and the smaller
* instructional text style
*/}
<h2 id="otp-display" className={`display ${!otp ? "empty" : ""}`}>
{otp ? otp : "Click 'Generate OTP' to get a code"}
</h2>

{/*
* The timer display uses a nested ternary to handle three states:
* 1. No OTP generated yet (`!otp`) → empty string (aria-live region stays silent)
* 2. OTP active (`isActive`) → "Expires in: X seconds" (live countdown)
* 3. OTP expired (`!isActive`) → expiration message prompting regeneration
*
* I set `aria-live="polite"` so screen readers announce countdown changes
* without interrupting the user's current focus
*/}
<p id="otp-timer" className="timer-text" aria-live="polite">
{otp ? (isActive ? `Expires in: ${timeLeft} seconds` : "OTP expired. Click the button to generate a new OTP.") : ""}
</p>

{/*
* The button's `disabled` prop is bound directly to `isActive`, creating
* a self-regulating cycle: clicking generates an OTP and disables the button,
* the countdown runs, and when it expires `isActive` becomes false,
* automatically re-enabling the button
*/}
<button
id="generate-otp-button"
className="generate-btn"
onClick={generateOTP}
disabled={isActive}
>
Generate OTP
</button>
</div>
);
};

crypto.getRandomValues(): The Right Question at the Right Time

The very first thing I thought before starting was: can I use crypto.randomUUID() that I had explored in depth in the Profile Card?
The problem is that randomUUID() generates a string like c90d075d-53a1-..., and to extract 6 digits from it I would have had to remove the dashes, filter out the letters, extract the numbers, hope there were enough of them and, otherwise, regenerate. A hack. I asked the Code Tutor, who explained that there's a more direct tool, crypto.getRandomValues(), built exactly for this.
I didn't want to use Math.random(): I had learned that it's pseudo-random and therefore predictable. Even though it was over-engineering for a didactic exercise focused on applying useState, useEffect and conditional rendering, the way this API works fascinated me so much that I couldn't wait to apply it.

Design: Obsidian + Roman Numeral Converter

I didn't focus on the design and I didn't prototype in Figma, but having a blank canvas on the CSS I decided to keep it simple. While looking for AppFlowy alternatives this morning, I had stumbled upon the Obsidian website: dark, minimal, no superfluous decorative elements. I decided to start from that style and combine it with a detail I had used in the Roman Numeral Converter: the Material Design-inspired button that in active state transitions from square corners to pill-shaped via border-radius animation, giving pleasant tactile feedback.

Landscape: The Favorite Edge Case

I dedicated more attention than usual to landscape, after realizing how even major websites treat it as a secondary case. I picked up my favorite edge case, the iPhone 12 mini, and optimized every spacing for that extremely narrow vertical viewport. I also added another tab in Safari to simulate the maximum possible constraint.
Here's the result:

OTP Generator in landscape mode on iPhone 12 mini with two Safari tabs open, showing the initial empty state with the yellow generate button
OTP Generator in landscape mode showing the generated code 795635 with the countdown timer 'Expires in: 2 seconds' below it

This attention didn't come about randomly: three days before I had added a media query to this site's custom.css to handle landscape on iPhone. The problem was the colored side bars that the browser adds by default to avoid the notch. They're functional, but horrible to look at. Looking at how the best handle it, I noticed two opposite approaches:

  • Apple and Google: the navbar extends to the physical edges of the screen ("Full Bleed"), while the content stays aligned within the safe margins, as is very evident on Apple.com.
  • Wikipedia: the navbar stays compressed within the margins, creating a "boxed in" effect that breaks the visual continuity, exactly the effect my site had.

I chose the Apple.com approach. The pattern I used is calc(env(safe-area-inset-left) + 12px): this always guarantees at least 12px of padding, adding the notch size on top of that for devices that have one. On devices without a notch, env(safe-area-inset-left) is zero, so the result is simply 12px, also solving the "content glued to the edge" problem on foldables, the second edge case I'm lucky enough to be able to test on.

Anti-CLS

A problem I hadn't considered from the start, but that turned out to be interesting to solve: CLS (Cumulative Layout Shift), those visual "jumps" that occur when an element changes size and drags everything else up or down with it.

The OTP display changes content dynamically: first it shows a placeholder with a small font, then a 6-digit code with a font almost three times larger. Same for the timer: it starts empty, then shows the countdown, then an expiration message that takes up two lines. Every time the text changes, the browser recalculates how much space that element takes up, and everything below it shifts accordingly.
The solution was to reserve the necessary space in advance with a fixed height. I chose height and not min-height because min-height still allows the element to grow. The box has to be rigid. The content centers inside it with flexbox and the layout doesn't move, whatever is inside it.

.display {
height: var(--display-min-height); /* Rigid box: doesn't grow, doesn't shrink */
display: flex;
align-items: center;
justify-content: center;
}

Another thing I discovered while working on letter-spacing: CSS adds space after every character, including the last one, visually shifting the entire block to the left. I compensated with a margin-right: calc(var(--otp-letter-spacing) * -1) that brings everything back exactly on axis.

What I Learned

Guard Clause in useEffect:
Checking exit conditions at the beginning of the effect, before any logic, keeps the code flat and readable. Without the setIsActive(false) when timeLeft reaches zero, the button would remain disabled forever: nothing resets the state, the cycle gets stuck.

Interval Stacking:
Without the cleanup function (return () => clearInterval(intervalId)), every re-render caused by the timeLeft change would start a new setInterval without stopping the previous one. The countdown would accelerate exponentially. The cleanup kills the previous interval before a new one starts.

aria-live="polite":
Adding aria-live to the timer means a screen reader announces every countdown update without interrupting the user's current focus. "Polite" waits for the user to finish what they're doing before speaking. It's a minimal detail in the code, but it makes a difference for those who can't see the screen.

hover: hover:
Wrapping hover styles inside @media (hover: hover) prevents the so-called "sticky hover" on touch screens: on iOS, after a tap, the :hover state remains visually active until something else is touched. By limiting hover to devices that support a pointer, this artifact is avoided without giving up feedback for mouse users.


Next:
Consolidate everything with the React State and Hooks Review and the React State and Hooks Quiz, then work through the Working with Forms in React theory on how forms work in React and the new useActionState hook, which I'll apply in the Superhero Application Form (Workshop).