• בלוג
  • עדכון סטייט בתוך עדכון סטייט

עדכון סטייט בתוך עדכון סטייט

15/08/2025

בוובינר היום ביקשנו מקלוד לעדכן קומפוננטת ריאקט שמציגה קרוסלת תמונות ולהוסיף לה שעון עצר שמראה כמה זמן עד החלפת התמונה הבאה. בהתחלה הוא יצר שני אפקטים וכשהתעקשנו ליצור אפקט אחד קיבלנו את הקוד הבא:

useEffect(() => {
  if (images.length <= 1) return

  // Reset countdown when component mounts or when interval changes
  setCountdown(interval / 1000)

  const timer = setInterval(() => {
    setCountdown((prevCountdown) => {
      if (prevCountdown <= 1) {
        // Time to switch image
        setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length)
        return interval / 1000 // Reset countdown
      }
      return prevCountdown - 1
    })
  }, 1000) // Update every second

  return () => clearInterval(timer)
}, [images.length, interval])

שימו לב לקריאה ל setCurrentIndex בתוך פונקציית העדכון שעוברת ל setCountdown. יצא לי לכתוב קוד בסגנון הזה וכל פעם שזה קרה זה העיד על איזושהי בעיה בתכנון הקומפוננטה ובסופו של דבר יצא משליטה וגרם לבאגים מוזרים במקומות אחרים. ובכל זאת הקוד של קלוד עובד.

אחרי הוובינר ניסיתי להבין מה הפריע לי בפיתרון ואני חושב שהבעיה היא כפילות בסטייט - בעצם יש פה שני משתני סטייט קשורים ואנחנו צריכים את העדכון בתוך עדכון כדי לבטא את הקשר הזה. אפשר לחשוב על זה שרצוי שמשתני סטייט של קומפוננטה יהיו אורתוגונליים וכשיש קשר ביניהם זה אומר שמשהו לא מדויק בארכיטקטורה.

אם יש משתני סטייט קשורים אולי זה אומר שאחד מהם יכול להיות ערך מחושב? כיוון אחד יכול להיות לספור רק את הזמן שעבר מאז שהקומפוננטה נכנסה למסך, ואז אפשר לחשב את אינדקס התמונה להצגה עם חילוק ושארית. הבעיה היא מה עושים עם הלחיצות. אם ממשיכים באותו כיוון אפשר להגדיר שהלחיצות "יקדמו" את השעון, ואז יש לנו את הקוד הבא:

import { useState, useEffect } from 'react'
import './ImageCarousel.css'

interface ImageCarouselProps {
  images: string[]
  interval?: number
}

const ImageCarousel: React.FC<ImageCarouselProps> = ({ images, interval = 3000 }) => {
  const [elapsedTime, setElapsedTime] = useState(0)

  // Derive currentIndex and countdown from elapsedTime
  const currentCycle = Math.floor(elapsedTime / interval)
  const currentIndex = currentCycle % images.length
  const timeInCurrentCycle = elapsedTime % interval
  const countdown = Math.ceil((interval - timeInCurrentCycle) / 1000)

  useEffect(() => {
    if (images.length <= 1) return

    const timer = setInterval(() => {
      setElapsedTime((prevTime) => prevTime + 1000)
    }, 1000) // Update every second

    return () => clearInterval(timer)
  }, [images.length, interval])

  const goToSlide = (index: number) => {
    // Calculate the elapsed time that would result in the desired index
    const targetCycle = Math.floor(elapsedTime / interval) + (index - currentIndex)
    const adjustedCycle = targetCycle >= 0 ? targetCycle : targetCycle + Math.ceil(Math.abs(targetCycle) / images.length) * images.length
    setElapsedTime(adjustedCycle * interval)
  }

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

  const goToNext = () => {
    const nextIndex = (currentIndex + 1) % images.length
    goToSlide(nextIndex)
  }

  if (images.length === 0) {
    return <div className="carousel-empty">No images to display</div>
  }

  return (
    <div className="carousel-container">
      <div className="carousel-wrapper">
        <button 
          className="carousel-btn carousel-btn-prev" 
          onClick={goToPrevious}
          aria-label="Previous image"
        >
          &#8249;
        </button>

        <div className="carousel-image-container">
          <img 
            src={images[currentIndex]} 
            alt={`Slide ${currentIndex + 1}`}
            className="carousel-image"
          />
        </div>

        <button 
          className="carousel-btn carousel-btn-next" 
          onClick={goToNext}
          aria-label="Next image"
        >
          &#8250;
        </button>
      </div>

      <div className="carousel-indicators">
        {images.map((_, index) => (
          <button
            key={index}
            className={`carousel-indicator ${index === currentIndex ? 'active' : ''}`}
            onClick={() => goToSlide(index)}
            aria-label={`Go to slide ${index + 1}`}
          />
        ))}
      </div>

      {images.length > 1 && (
        <div className="carousel-countdown">
          Next image in: {countdown}s
        </div>
      )}
    </div>
  )
}

export default ImageCarousel

גם הוא עובד הפעם עם משתנה סטייט יחיד.

כיוון אחר לשבור את הקשר הוא לפרק את הקומפוננטה לשתי קומפוננטות - קומפוננטה אחת של קרוסלת תמונות וקומפוננטה שניה היא השעון עצר. זה הקוד עם הפרדת קומפוננטות:

import { useState, useEffect } from 'react'
import './ImageCarousel.css'

interface CountdownTimerProps {
  interval: number
  onTimeout: () => void
}

const CountdownTimer: React.FC<CountdownTimerProps> = ({ interval, onTimeout }) => {
  const [countdown, setCountdown] = useState(interval / 1000)

  useEffect(() => {
    const timer = setInterval(() => {
      setCountdown((prevCountdown) => {
        if (prevCountdown <= 1) {
          onTimeout()
          return interval / 1000 // Reset countdown
        }
        return prevCountdown - 1
      })
    }, 1000) // Update every second

    return () => clearInterval(timer)
  }, [interval, onTimeout])

  return (
    <div className="carousel-countdown">
      Next image in: {countdown}s
    </div>
  )
}

interface ImageCarouselProps {
  images: string[]
  interval?: number
}

const ImageCarousel: React.FC<ImageCarouselProps> = ({ images, interval = 3000 }) => {
  const [currentIndex, setCurrentIndex] = useState(0)

  const goToSlide = (index: number) => {
    setCurrentIndex(index)
  }

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

  const goToNext = () => {
    setCurrentIndex((prevIndex) => (prevIndex + 1) % images.length)
  }

  if (images.length === 0) {
    return <div className="carousel-empty">No images to display</div>
  }

  return (
    <div className="carousel-container">
      <div className="carousel-wrapper">
        <button 
          className="carousel-btn carousel-btn-prev" 
          onClick={goToPrevious}
          aria-label="Previous image"
        >
          &#8249;
        </button>

        <div className="carousel-image-container">
          <img 
            src={images[currentIndex]} 
            alt={`Slide ${currentIndex + 1}`}
            className="carousel-image"
          />
        </div>

        <button 
          className="carousel-btn carousel-btn-next" 
          onClick={goToNext}
          aria-label="Next image"
        >
          &#8250;
        </button>
      </div>

      <div className="carousel-indicators">
        {images.map((_, index) => (
          <button
            key={index}
            className={`carousel-indicator ${index === currentIndex ? 'active' : ''}`}
            onClick={() => goToSlide(index)}
            aria-label={`Go to slide ${index + 1}`}
          />
        ))}
      </div>

      {images.length > 1 && (
        <CountdownTimer 
          key={currentIndex}
          interval={interval}
          onTimeout={goToNext}
        />
      )}
    </div>
  )
}

export default ImageCarousel

כאן שוב יש לנו את ה set בתוך set, אבל הפעם הזזתי את האפקט רק לקומפוננטה של השעון שהיא הרבה יותר קטנה וכל משתנה סטייט שמור בקומפוננטה שלו.

מה דעתכם? איזה גירסה אתם הכי אוהבים ולמה?