Problem Understanding
This question has a deliberate trap built into it: the variable duration requirement. Red lasts 4 seconds. Green lasts 3 seconds. Yellow lasts 1 second. If you reach for setInterval — which fires on a fixed schedule — you cannot satisfy this requirement without a lot of awkward extra logic. The correct tool is setTimeout, scheduled fresh on each state transition with the exact duration for that specific light.
Before touching code, clarify with the interviewer:
- What is the correct cycle order? (Usually Red → Green → Yellow → Red — mimicking real traffic lights)
- Should it start running automatically on mount?
- Is a pause/resume button required?
Component Architecture
The key design decision is making the lights data-driven. Don't hard-code the light logic — define a config array:
const LIGHTS = [
{ name: 'Red', color: '#ef4444', duration: 4000 },
{ name: 'Green', color: '#22c55e', duration: 3000 },
{ name: 'Yellow', color: '#eab308', duration: 1000 },
];
Now all your cycle logic works generically from this array. Adding a fourth light, or changing a duration, requires zero changes to logic — just update the config. This is the kind of architecture that impresses interviewers because it shows you're thinking in terms of maintainability, not just "make it work."
💡 Interview Tip: Presenting a config-driven design instead of a hard-coded switch statement is a strong signal of senior-level thinking. Say explicitly: "I'm making this data-driven so that adding a new light or changing durations is a one-line change in the config, not a code change in the logic."
State Design
Two pieces of state. That's all.
const [activeIndex, setActiveIndex] = useState(0); // 0=Red, 1=Green, 2=Yellow
const [isRunning, setIsRunning] = useState(true);
Everything else is derived:
LIGHTS[activeIndex].color — what color is the active light?
LIGHTS[activeIndex].duration — how long does it stay on?
LIGHTS[activeIndex].name — what label to show?
⚠️ Common Mistake: Some candidates store the current light's name or color as separate state variables. This is an anti-pattern — you now have multiple sources of truth that must be kept in sync. If activeIndex is 0, the color is always LIGHTS[0].color. No separate state needed.
Why setTimeout, Not setInterval
This is the most important thing to explain in the interview. Let's compare:
// ❌ WRONG — setInterval fires at a fixed interval
// You cannot give Red 4s and Yellow 1s with a single setInterval
useEffect(() => {
const timer = setInterval(next, 1000); // all lights get 1s — wrong!
return () => clearInterval(timer);
}, []);
// ✅ CORRECT — setTimeout with the current light's specific duration
useEffect(() => {
if (!isRunning) return;
const timer = setTimeout(() => {
setActiveIndex(i => (i + 1) % LIGHTS.length);
}, LIGHTS[activeIndex].duration); // reads the right duration each time
return () => clearTimeout(timer);
}, [activeIndex, isRunning]);
The setTimeout approach is elegant because of how it integrates with React's effect system. Every time activeIndex changes, the effect re-runs. It reads the duration for the new active light and schedules the next transition accordingly. No external counter, no switch statement — the state machine drives itself.
💡 Interview Tip: Say this out loud: "I'm using setTimeout rather than setInterval because each light has a different duration. Each time activeIndex changes, the effect re-runs and schedules the next transition with the correct duration for that specific light. The staggered durations come naturally from the config array."
Step-by-Step Implementation
The Effect
useEffect(() => {
if (!isRunning) return; // guard: do nothing when paused
const timer = setTimeout(() => {
setActiveIndex(i => (i + 1) % LIGHTS.length); // advance to next light
}, LIGHTS[activeIndex].duration); // use THIS light's duration
return () => clearTimeout(timer); // cancel if re-rendered before timer fires
}, [activeIndex, isRunning]); // re-run when either of these changes
Let's trace through this step by step:
- Component mounts.
activeIndex=0 (Red), isRunning=true. Effect runs. Sets a 4000ms timer.
- After 4 seconds, the timer fires.
setActiveIndex(i => (0+1)%3 = 1). State updates to Green.
- State change triggers a re-render. useEffect cleanup runs —
clearTimeout on the old timer (already fired, so harmless). Effect runs again with activeIndex=1. Sets a 3000ms timer.
- After 3 seconds, transitions to Yellow (index 2). Sets a 1000ms timer.
- After 1 second, wraps back to Red (index 0). Cycle repeats.
The Cleanup Function — Why It Matters
Imagine the user clicks Stop 2 seconds into the Red phase. What happens?
- User clicks Stop →
setIsRunning(false).
- Re-render triggers. useEffect cleanup runs →
clearTimeout(timer). The 4s timer is cancelled. Red will not transition to Green.
- Effect runs again.
isRunning is false → early return. No new timer is set.
Without the cleanup function, the 4s timer would still fire after Stop is pressed, causing an unwanted state transition. The user pressed Stop but the light keeps changing — a bug that's easy to miss in testing but breaks the user experience.
⚠️ Common Mistake: Not returning a cleanup function from the effect. The timer fires even after Stop is pressed, even after the component unmounts. This causes the dreaded "Can't perform a React state update on an unmounted component" warning and can create subtle bugs in navigation-heavy apps.
Full Component
const LIGHTS = [
{ name: 'Red', color: '#ef4444', duration: 4000 },
{ name: 'Green', color: '#22c55e', duration: 3000 },
{ name: 'Yellow', color: '#eab308', duration: 1000 },
];
export default function TrafficLight() {
const [activeIndex, setActiveIndex] = useState(0);
const [isRunning, setIsRunning] = useState(true);
useEffect(() => {
if (!isRunning) return;
const timer = setTimeout(() => {
setActiveIndex(i => (i + 1) % LIGHTS.length);
}, LIGHTS[activeIndex].duration);
return () => clearTimeout(timer);
}, [activeIndex, isRunning]);
return (
<div className="tl-page">
<div className="tl-housing">
{LIGHTS.map((light, i) => (
<div
key={light.name}
className="tl-bulb"
style={{
background: i === activeIndex ? light.color : '#1e293b',
boxShadow: i === activeIndex
? \`0 0 24px 6px \${light.color}60\`
: 'none',
}}
/>
))}
</div>
<p className="tl-label">{LIGHTS[activeIndex].name}</p>
<button onClick={() => setIsRunning(r => !r)}>
{isRunning ? 'Stop' : 'Start'}
</button>
</div>
);
}
Edge Cases
- Component unmounts mid-cycle: The cleanup function cancels the pending timer. No state update fires on a dead component.
- Rapid Stop/Start: Each click of Stop/Start toggles
isRunning. The effect cleans up the old timer and either sets a new one (if running) or returns early (if stopped). No race condition.
- activeIndex out of range: Using
% LIGHTS.length wraps correctly. Index can never exceed the array bounds.
Styling Notes
The visual key for this component is the glow effect on the active bulb. Using box-shadow with a semi-transparent version of the bulb color creates a realistic lamp glow:
// Active bulb — lit up with glow
boxShadow: \`0 0 24px 6px \${light.color}60\` // 60 = 38% opacity in hex
// Inactive bulb — dark background
background: '#1e293b'
💡 Interview Tip: Adding a transition: background 0.3s, box-shadow 0.3s on the bulb div creates a smooth fade between states — the light appears to turn on and off gradually rather than snapping. This level of polish, delivered in 28 minutes, tells the interviewer you have strong front-end instincts.
Common Pitfalls Summary
- Using setInterval: Cannot give different durations per light without complex extra logic. Always use setTimeout for variable-duration state machines.
- Missing cleanup function: Timers fire after the component unmounts or after Stop is pressed — causes bugs and React warnings.
- Hard-coding the cycle in a switch statement: Not extensible. Config-driven is always better for repeating patterns.
- Not using the functional form of setState: Inside a setTimeout callback,
setActiveIndex(activeIndex + 1) captures a stale closure value. Always use setActiveIndex(i => (i + 1) % LIGHTS.length) to get the latest state.
- Wrong cycle order: Real traffic lights go Red → Green → Yellow → Red (not Red → Yellow → Green). This is a small detail but shows attention to real-world accuracy.
How to Communicate During the Interview
- "I noticed each light has a different duration — that means setInterval won't work here. I'm using setTimeout instead, which I re-schedule on each state change with the correct duration for that specific light."
- "My lights are defined as a config array, not hard-coded in the JSX. This makes the component completely generic — you could change the durations or add a fourth light by just editing the config."
- "The cleanup function in my useEffect is critical. When isRunning changes to false, the cleanup cancels the pending timer so the light doesn't keep advancing even after the user presses Stop."
- "I'm using the functional form of setActiveIndex to avoid stale closure issues inside the setTimeout callback."