Search code examples
cssreactjsreact-hooksflickerscroll-snap

React scroll-snap flicker when using useState


I am experiencing a very weird flickering (glitch) when using the combination of

  • css scroll-snap
  • useState
  • Sub-Components

But ONLY in the combination of these three!

flickering

Here is the minimal reproducable code:

carousel.js

import styles from './carousel.module.scss'
import { useEffect, useRef, useState } from 'react';

export default function Carousel() {
  const [currentScollPos, setCurrentScrollPos] = useState(0)
  const carouselRef = useRef()

  useEffect(() => {
    const carouselScrollUpdate = (e) => {
      setCurrentScrollPos(e.target.scrollLeft)
    }
    carouselRef?.current?.addEventListener('scroll', carouselScrollUpdate, { passive: true })
    return () => {
      carouselRef?.current?.removeEventListener('scroll', carouselScrollUpdate, { passive: true })
    }
  }, [carouselRef])

  const Slide = () => <div className={styles.carouselSlide}>Test Sub</div>
  
  return (
    <div className={styles.carouselInnerContainer} ref={carouselRef}>
      <div className={styles.carouselSlide}>Test1</div>
      <div className={styles.carouselSlide}>Test2</div>
      <div className={styles.carouselSlide}>Test3</div>
      <Slide />
    </div>
  )
}

carousel.module.scss

.carouselInnerContainer {
  display: flex;
  flex-wrap: nowrap;
  overflow-x: scroll;
  scroll-snap-type: x mandatory;
}

.carouselSlide {
  flex: 0 0 auto;
  width: 50%;
  margin-left: 2rem;
  background-color: aquamarine;
  height: 200px;
  scroll-snap-align: center;
}

The flickering will NOT be there if I do ONE of the following:

  • comment out: setCurrentScrollPos(e.target.scrollLeft)
  • comment out: <Slide />
  • comment out: scroll-snap-align: center; in the CSS

Any ideas on that weird behaviour?


Solution

  • The problem occurs when you try update the state every time the scroll position changes

    const carouselScrollUpdate = (e) => {
      setCurrentScrollPos(e.target.scrollLeft)
    }
    

    Each setCurrentScrollPos will cause a renderer in your component, causing it to flicker

    Instead set state every time you can observer when the scroll stops using setTimout:

    const carouselScrollUpdate = (e) => {      
        clearInterval(timer);
        timer = setTimeout(() => {
            console.log('set scroll')
            setCurrentScrollPos(e.target.scrollLeft)
        }, 500);      
    }
    

    or just set your state when it satisfy some condition:

    const carouselScrollUpdate = (e) => {      
        if (isNearNextSlide()) {
          setCurrentScrollPos(e.target.scrollLeft)
        }         
    }
    
    const isNearNextSlide = () => {
       // add logic to satisfy your conditions
    }
    

    Edit:

    After some testing I saw the problem is the inner Slide component inside and I managed to fix it by moving the component outside the main component, preventing that the component from being recreated when rendering

    import styles from './carousel.module.scss'
    import { useEffect, useRef, useState } from 'react';
    
    const Slide = () => <div className={styles.carouselSlide}>Test Sub</div>
    
    export default function Carousel() {
      const [currentScollPos, setCurrentScrollPos] = useState(0)
      const carouselRef = useRef()
    
      useEffect(() => {
        const carouselScrollUpdate = (e) => {
          setCurrentScrollPos(e.target.scrollLeft)
        }
        carouselRef?.current?.addEventListener('scroll', carouselScrollUpdate, { passive: true })
        return () => {
          carouselRef?.current?.removeEventListener('scroll', carouselScrollUpdate, { passive: true })
        }
      }, [carouselRef])
          
      return (
        <div className={styles.carouselInnerContainer} ref={carouselRef}>
          <div className={styles.carouselSlide}>Test1</div>
          <div className={styles.carouselSlide}>Test2</div>
          <div className={styles.carouselSlide}>Test3</div>
          <Slide />
        </div>
      )
    }