Blogs JavaScript Traffic Light: Building State-Driven UIs with React

JavaScript Traffic Light: Building State-Driven UIs with React

8 min read

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:

  1. Set the active light to the current config
  2. Wait for the configured duration using await delay(...)
  3. 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

ConceptRole in the Traffic Light
useStateStores which light is active
useEffectStarts the cycle on mount
Custom hookEncapsulates cycling logic
Async/awaitWaits for each phase duration
Conditional classesSwitches 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

© 2026 Kamalesh Biswas