Code To Learn logo

Code To Learn

M3: Effects & Data

L8: Image Carousel Component

Build an interactive image carousel with auto-play functionality using useEffect

In this lesson, we'll build an interactive image carousel that auto-plays through images using useEffect. This brings together everything you've learned about state, effects, and cleanup functions!

What You'll Learn

  • Build a reusable carousel component with state
  • Implement auto-play using useEffect and timers
  • Handle cleanup for intervals to prevent memory leaks
  • Add user interactions (pause on hover, manual navigation)
  • Use proper accessibility patterns for carousels

Why Carousels Need useEffect

Image carousels are a perfect example of when you need side effects:

  • Auto-play requires timers - You need setInterval to change slides automatically
  • Timers need cleanup - Intervals must be cleared to prevent memory leaks
  • User interactions affect timers - Hovering should pause, leaving should resume
  • Component lifecycle matters - Timer should stop when component unmounts

All of these are side effects that happen outside the normal React render flow!

Let's start with a simple carousel component that displays images and tracks the current slide.

Create a new file for our carousel:

Terminal
touch src/components/ImageCarousel.jsx

Build the Basic Structure

src/components/ImageCarousel.jsx
import { useState } from 'react';

export default function ImageCarousel({ images = [] }) {
  // Track which image is currently shown
  const [currentIndex, setCurrentIndex] = useState(0);

  // Handle case with no images
  if (images.length === 0) {
    return (
      <div className="w-full h-64 bg-gray-200 flex items-center justify-center rounded-lg">
        <p className="text-gray-500">No images available</p>
      </div>
    );
  }

  return (
    <div className="relative w-full h-64 overflow-hidden rounded-lg bg-gray-900">
      {/* Display current image */}
      <img
        src={images[currentIndex]}
        alt={`Slide ${currentIndex + 1}`}
        className="w-full h-full object-cover"
      />
      
      {/* We'll add controls here */}
    </div>
  );
}

What's happening:

  • currentIndex tracks which image is shown (starts at 0)
  • Graceful fallback for empty image arrays
  • Basic image display with proper styling

Step 2: Add Navigation Controls

Now let's add buttons to navigate between images manually.

Create Navigation Functions

src/components/ImageCarousel.jsx
import { useState } from 'react';

export default function ImageCarousel({ images = [] }) {
  const [currentIndex, setCurrentIndex] = useState(0);

  // Navigate to previous image
  const goToPrevious = () => {
    setCurrentIndex((prevIndex) => {
      // Wrap around to last image if at first
      return prevIndex === 0 ? images.length - 1 : prevIndex - 1;
    });
  };

  // Navigate to next image
  const goToNext = () => {
    setCurrentIndex((prevIndex) => {
      // Wrap around to first image if at last
      return prevIndex === images.length - 1 ? 0 : prevIndex + 1;
    });
  };

  if (images.length === 0) {
    return (
      <div className="w-full h-64 bg-gray-200 flex items-center justify-center rounded-lg">
        <p className="text-gray-500">No images available</p>
      </div>
    );
  }

  return (
    <div className="relative w-full h-64 overflow-hidden rounded-lg bg-gray-900">
      {/* Previous Button */}
      <button
        onClick={goToPrevious}
        className="absolute left-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white rounded-full p-2 transition-colors"
        aria-label="Previous image"
      >
        <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
        </svg>
      </button>

      <img
        src={images[currentIndex]}
        alt={`Slide ${currentIndex + 1} of ${images.length}`}
        className="w-full h-full object-cover"
      />

      {/* Next Button */}
      <button
        onClick={goToNext}
        className="absolute right-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white rounded-full p-2 transition-colors"
        aria-label="Next image"
      >
        <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
        </svg>
      </button>
    </div>
  );
}

Key points:

  • Circular navigation: Wraps around at both ends
  • Functional updates: Using prevIndex prevents stale closure issues
  • Accessibility: Buttons have aria-label for screen readers
  • Positioning: Absolute positioning centers buttons on each side

Step 3: Implement Auto-Play with useEffect

Now for the exciting part! Let's make the carousel auto-play using useEffect and setInterval.

Add Auto-Play Logic

src/components/ImageCarousel.jsx
import { useState, useEffect } from 'react';

export default function ImageCarousel({ images = [], autoPlayInterval = 3000 }) {
  const [currentIndex, setCurrentIndex] = useState(0);

  // Auto-play effect
  useEffect(() => {
    // Don't auto-play if there's only one image
    if (images.length <= 1) return;

    // Set up interval to advance to next slide
    const intervalId = setInterval(() => {
      goToNext();
    }, autoPlayInterval);

    // Cleanup: Clear interval when component unmounts or deps change
    return () => {
      clearInterval(intervalId);
    };
  }, [images.length, autoPlayInterval]); // Re-run if these change

  const goToPrevious = () => {
    setCurrentIndex((prevIndex) => 
      prevIndex === 0 ? images.length - 1 : prevIndex - 1
    );
  };

  const goToNext = () => {
    setCurrentIndex((prevIndex) => 
      prevIndex === images.length - 1 ? 0 : prevIndex + 1
    );
  };

  // ... rest of component
}

Understanding the Auto-Play Code

Step 4: Add Pause on Hover

Users should be able to pause auto-play by hovering over the carousel. Let's add that functionality!

Track Hover State

src/components/ImageCarousel.jsx
import { useState, useEffect } from 'react';

export default function ImageCarousel({ images = [], autoPlayInterval = 3000 }) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [isHovered, setIsHovered] = useState(false);

  useEffect(() => {
    // Don't auto-play if only one image OR if user is hovering
    if (images.length <= 1 || isHovered) return;

    const intervalId = setInterval(() => {
      goToNext();
    }, autoPlayInterval);

    return () => {
      clearInterval(intervalId);
    };
  }, [images.length, autoPlayInterval, isHovered]); // Add isHovered to deps

  // ... navigation functions ...

  return (
    <div 
      className="relative w-full h-64 overflow-hidden rounded-lg bg-gray-900"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      {/* ... rest of carousel ... */}
    </div>
  );
}

What's happening:

  • isHovered state tracks whether mouse is over carousel
  • onMouseEnter/onMouseLeave update the state
  • useEffect checks isHovered - if true, it returns early (no interval)
  • When user hovers, interval is cleared and effect re-runs
  • When user stops hovering, effect creates a new interval

Step 5: Add Slide Indicators

Visual indicators help users see how many images there are and which one is active.

Add Indicator Dots

src/components/ImageCarousel.jsx
// ... imports and component logic ...

  return (
    <div 
      className="relative w-full h-64 overflow-hidden rounded-lg bg-gray-900"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      {/* Previous Button */}
      {/* ... button code ... */}

      <img
        src={images[currentIndex]}
        alt={`Slide ${currentIndex + 1} of ${images.length}`}
        className="w-full h-full object-cover"
      />

      {/* Next Button */}
      {/* ... button code ... */}

      {/* Slide Indicators */}
      <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2">
        {images.map((_, index) => (
          <button
            key={index}
            onClick={() => setCurrentIndex(index)}
            className={`w-3 h-3 rounded-full transition-colors ${
              index === currentIndex 
                ? 'bg-white' 
                : 'bg-white/50 hover:bg-white/75'
            }`}
            aria-label={`Go to slide ${index + 1}`}
          />
        ))}
      </div>
    </div>
  );

Features:

  • One dot per image: Map over images array
  • Visual feedback: Active dot is fully white, others are translucent
  • Clickable: Each dot jumps to its slide
  • Centered: Positioned at bottom center with Tailwind
  • Accessible: Each button has descriptive label

Step 6: Integrate with PropertyCard

Now let's use our carousel in the PropertyCard component to display listing images!

Update PropertyCard

src/components/PropertyCard.jsx
//import ImageCarousel from './ImageCarousel';

export default function PropertyCard({ listing }) {
  const { title, location, price, rating, reviews, images } = listing;

  return (
    <div className="bg-white rounded-lg shadow-md overflow-hidden hover:shadow-lg transition-shadow">
      {/* Replace static image with carousel */}
      {/*<img */}
      {/*  src={images[0]} */}
      {/*  alt={title} */}
      {/*  className="w-full h-48 object-cover" */}
      {/*/> */}
      
      <ImageCarousel images={images} autoPlayInterval={3500} />

      <div className="p-4">
        <h3 className="text-lg font-semibold text-gray-800 mb-1">{title}</h3>
        
        <p className="text-sm text-gray-600 mb-2">{location}</p>
        
        <div className="flex items-center justify-between">
          <div className="flex items-center gap-1">
            <span className="text-yellow-500">ā˜…</span>
            <span className="font-medium">{rating}</span>
            <span className="text-gray-500 text-sm">({reviews} reviews)</span>
          </div>
          
          <div className="text-right">
            <span className="text-lg font-bold text-gray-900">${price}</span>
            <span className="text-gray-600 text-sm"> / night</span>
          </div>
        </div>
      </div>
    </div>
  );
}

Test It Out!

Save your files and check the browser. You should now see:

  • āœ… Image carousel with navigation buttons
  • āœ… Auto-playing every 3.5 seconds
  • āœ… Pauses when you hover
  • āœ… Clickable indicator dots
  • āœ… Smooth transitions between images

Here's the full, polished carousel component:

src/components/ImageCarousel.jsx
import { useState, useEffect } from 'react';

export default function ImageCarousel({ 
  images = [], 
  autoPlayInterval = 3000 
}) {
  const [currentIndex, setCurrentIndex] = useState(0);
  const [isHovered, setIsHovered] = useState(false);

  // Auto-play effect with cleanup
  useEffect(() => {
    // Don't auto-play if only one image or user is hovering
    if (images.length <= 1 || isHovered) return;

    const intervalId = setInterval(() => {
      goToNext();
    }, autoPlayInterval);

    // Cleanup interval on unmount or when dependencies change
    return () => {
      clearInterval(intervalId);
    };
  }, [images.length, autoPlayInterval, isHovered]);

  // Navigate to previous image (circular)
  const goToPrevious = () => {
    setCurrentIndex((prevIndex) => 
      prevIndex === 0 ? images.length - 1 : prevIndex - 1
    );
  };

  // Navigate to next image (circular)
  const goToNext = () => {
    setCurrentIndex((prevIndex) => 
      prevIndex === images.length - 1 ? 0 : prevIndex + 1
    );
  };

  // Handle case with no images
  if (images.length === 0) {
    return (
      <div className="w-full h-64 bg-gray-200 flex items-center justify-center rounded-lg">
        <p className="text-gray-500">No images available</p>
      </div>
    );
  }

  return (
    <div 
      className="relative w-full h-64 overflow-hidden rounded-lg bg-gray-900 group"
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
    >
      {/* Previous Button - shows on hover */}
      <button
        onClick={goToPrevious}
        className="absolute left-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white 
                   rounded-full p-2 transition-all opacity-0 group-hover:opacity-100 z-10"
        aria-label="Previous image"
      >
        <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
        </svg>
      </button>

      {/* Current Image */}
      <img
        src={images[currentIndex]}
        alt={`Slide ${currentIndex + 1} of ${images.length}`}
        className="w-full h-full object-cover transition-opacity duration-500"
      />

      {/* Next Button - shows on hover */}
      <button
        onClick={goToNext}
        className="absolute right-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white 
                   rounded-full p-2 transition-all opacity-0 group-hover:opacity-100 z-10"
        aria-label="Next image"
      >
        <svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
          <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
        </svg>
      </button>

      {/* Slide Indicators */}
      {images.length > 1 && (
        <div className="absolute bottom-4 left-1/2 -translate-x-1/2 flex gap-2 z-10">
          {images.map((_, index) => (
            <button
              key={index}
              onClick={() => setCurrentIndex(index)}
              className={`w-3 h-3 rounded-full transition-colors ${
                index === currentIndex 
                  ? 'bg-white' 
                  : 'bg-white/50 hover:bg-white/75'
              }`}
              aria-label={`Go to slide ${index + 1}`}
              aria-current={index === currentIndex ? 'true' : 'false'}
            />
          ))}
        </div>
      )}
    </div>
  );
}

Best Practices & Accessibility

Manual Testing Checklist

Test these scenarios:

  1. Auto-play: Images should advance every 3.5 seconds
  2. Hover pause: Auto-play should stop when hovering
  3. Navigation buttons: Previous/next buttons should work
  4. Circular navigation: Should wrap around at both ends
  5. Indicators: Dots should highlight current slide and be clickable
  6. Single image: Should not auto-play or show indicators
  7. No images: Should show "No images available" message

Check for Memory Leaks

Open React DevTools and watch for:

  1. Hover over carousel, then navigate away
  2. Interval should be cleared (check with console.log in cleanup)
  3. No errors in console
  4. Component properly unmounts
// Add logging to verify cleanup
useEffect(() => {
  console.log('Interval started');
  
  const intervalId = setInterval(() => {
    goToNext();
  }, autoPlayInterval);

  return () => {
    console.log('Interval cleaned up'); // Should see this on unmount
    clearInterval(intervalId);
  };
}, [images.length, autoPlayInterval, isHovered]);

Key Takeaways

  • Timers are side effects - setInterval requires useEffect and cleanup
  • Always cleanup intervals - Use clearInterval in the cleanup function
  • Functional state updates - Use prevState to avoid stale closures
  • Pause on interaction - Respect user control over auto-play
  • Accessibility matters - Add ARIA labels, keyboard support, and respect motion preferences
  • Test cleanup thoroughly - Verify intervals are cleared on unmount

What's Next?

You've mastered useEffect by building a real-world component! In the next lesson, we'll review everything you've learned in Module 3:

  • useEffect patterns and best practices
  • Common pitfalls and how to avoid them
  • When to use (and not use) useEffect
  • Self-assessment quiz

Then you'll tackle a comprehensive module challenge to solidify your skills!