JavaScript Traffic Light: Building State-Driven UIs with React
Building interactive components is one of the best ways to solidify your understanding of React. A traffic light might seem simple, but it beautifully demonstrates the core concepts that power modern React applications: state management, side effects, and custom hooks. In this post, we’ll build an interactive traffic light from scratch and explore the patterns that make it work.
What We’re Building
A traffic light that cycles automatically through green → yellow → red, with configurable timing for each phase. The inactive lights appear dimmed (gray), while the active light displays in full color. Let’s unpack how it works.
Why a Traffic Light?
Traffic lights are a perfect teaching tool because they:
- Model real-world state — Only one light is active at a time
- Require timed transitions — Each phase has a specific duration
- Loop indefinitely — Red leads back to green, creating a continuous cycle
- Stay simple — Minimal UI, maximal learning
Core Concepts
1. State with useState
React components need to “remember” which light is currently active. We use useState to hold that information:
const [activeLight, setActiveLight] = useState(lightConfigs[0]);
The activeLight object tells us the current color and its duration. When we call setActiveLight, React re-renders the component with the new value, and the UI updates to reflect the change.
2. Side Effects with useEffect
The cycling logic doesn’t belong in the render — it’s a side effect that runs after the component mounts. We use useEffect to start the light cycle when the component first appears:
useEffect(() => {
start();
}, []);
The empty dependency array [] means this effect runs once when the component mounts. Inside, we call start(), which kicks off the async cycle.
3. Custom Hooks for Reusable Logic
The cycling logic — tracking the active light and advancing through phases — is extracted into a custom hook called useLight:
const useLight = (lightConfigs: { color: string; duration: number }[]) => {
const [activeLight, setActiveLight] = useState(lightConfigs[0]);
const start = async () => {
for (let i = 0; i < lightConfigs.length; i++) {
setActiveLight(lightConfigs[i]);
await delay(lightConfigs[i].duration);
if (i === lightConfigs.length - 1) {
start(); // Loop back to start
}
}
};
return { activeLight, start };
};
Custom hooks let you encapsulate state and logic in a reusable way. If you needed multiple traffic lights on a page, you could use useLight in each one without duplicating code.
How the Cycle Works
The traffic light configuration defines each phase:
const lightConfigs = [
{ color: "green", duration: 3000 },
{ color: "yellow", duration: 2000 },
{ color: "red", duration: 4000 },
];
The start function iterates through this array:
- Set the active light to the current config
- Wait for the configured duration using
await delay(...) - When the last light (red) finishes, call
start()again to loop
The delay helper wraps setTimeout in a Promise so we can use async/await:
const delay = (ms: number) =>
new Promise<void>((resolve) => setTimeout(resolve, ms));
Conditional Styling
Each light bulb is a <div> whose background color depends on whether it matches the active light:
className={cn(
"w-20 h-20 rounded-full border-4 border-gray-800 transition-colors duration-300",
activeLight.color === "red" ? "bg-red-500" : "bg-gray-500"
)}
The cn utility (from clsx + tailwind-merge) merges classes. When the light is active, we use the bright color; otherwise, we use gray (bg-gray-500). The transition-colors duration-300 adds a smooth fade between states.
Key Takeaways
| Concept | Role in the Traffic Light |
|---|---|
useState | Stores which light is active |
useEffect | Starts the cycle on mount |
| Custom hook | Encapsulates cycling logic |
| Async/await | Waits for each phase duration |
| Conditional classes | Switches between active and inactive styles |
Extending the Component
Once you understand the basics, you can extend the traffic light in many ways:
Add Manual Controls
Wrap the component in state that can pause or reset the cycle:
const [isPaused, setIsPaused] = useState(false);
// Only call start() when !isPaused
Adjust Timing
Edit lightConfigs to change how long each light stays on. Real traffic lights often use longer red and green phases and shorter yellow.
Add a “Walk” Signal
Introduce a fourth state — a pedestrian walk indicator — that appears after green and before yellow. Update the config array and the component will cycle through it automatically.
Use setInterval Instead
For a simpler (but less flexible) approach, you could use setInterval with an index that increments and wraps. The async/await approach we used makes it easier to have different durations per phase.
Try It Yourself
Use the playground above to experiment. Change the durations, add new lights, or refactor the useLight hook to accept a pause callback. The best way to internalize these concepts is to break things and fix them.
Further Reading
- React docs: State — Deep dive into state management
- React docs: useEffect — When and how to use effects
- Building Custom Hooks — Extract and reuse component logic