1

I'm trying to use the InteractionObserver to show which items are currently in a div viewport,

but when I try to use a state variable inside the callback it always has the initial value, no matter how may times it was updated outside the callback

these are the component's variables

function _IntersectionObserverTest_( props ){
  const { data } = props
  const scrollDiv = useRef( null )
  const itemsRef = useRef( [] )
  const [ visible, setVisible ] = useState([])
  const visibilityThreshold = 0.5

this is the callback's code

  const onInteraction = (entries, opts)=>{
    console.log(visible) // <-- never gets updated, it's always []
    var onView = [ ...visible ]
    entries.forEach( e => {
      const key = e.target.getAttribute('itemKey')
      const t = e.intersectionRatio
      console.log(key, t)
      if( t < visibilityThreshold ){
        onView = onView.filter( k => k != key )
      } else {
        onView.push( key )
      }
    })

    setVisible( onView ) // <-- this updates the variable
  }

this is the code test

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<div id="root"></div>

<script type="text/babel" defer>
// import React, { useState, useEffect } from "react";
// import ReactDOM from "react-dom";
const { useEffect, useRef, useState, useCallback } = React; // web-browser variant

function IntersectionObserverTest( props ){
  const { itemsCount=10 } = props
  const data = new Array(itemsCount).fill(null).map( (a, i) => `item ${i}`)

  return <_IntersectionObserverTest_ data={data}/> 
}

function _IntersectionObserverTest_( props ){
  const { data } = props
  const scrollDiv = useRef( null )
  const itemsRef = useRef( [] )
  const [ visible, setVisible ] = useState([])
  const visibilityThreshold = 0.5

  const getVisible = useCallback(() => {
    return visible
  }, [ visible ])

  useEffect(() => {
     itemsRef.current = itemsRef.current.slice(0, props.data.length);
  }, [props.data]);

  useEffect( () => {
    const observer = new IntersectionObserver( onInteraction, {
      root: scrollDiv.current,   
      threshold: visibilityThreshold,
    })

    for( var i = 0; i < data.length; i++ ){
      observer.observe( itemsRef.current[i] )
    }
  }, [ itemsRef ])

  const onInteraction = (entries, opts)=>{
    console.log(visible, getVisible())
    var onView = [ ...visible ]
    entries.forEach( e => {
      const key = e.target.getAttribute('itemKey')
      const t = e.intersectionRatio
      console.log(key, t)
      if( t < visibilityThreshold ){
        onView = onView.filter( k => k != key )
      } else {
        onView.push( key )
      }
    })

    setVisible( onView )
  }

  return <>
    <div ref={scrollDiv} style={{width: '100%', overflow: 'scroll'}}> 
      <div style={{ display: 'flex', flexDirection: 'row' }}>
        { data.map( (d, di) => 
          <div 
            ref={el => itemsRef.current[di] = el} 
            style={{ display: 'block', minWidth: '200px', width: '200px', border: '1px solid black', padding: '1rem', textAlign: 'center'}} 
            key={di}
            itemKey={`item-${di}`}
          >
            {d}
          </div>
        )}
      </div>
    </div>
    <div>
      on view:
      <div>{visible.map( v => <div key={v}>{v} </div>)}</div>
    </div>
  </>
}


const rootElement = document.getElementById("root");
ReactDOM.render(<IntersectionObserverTest />, rootElement);
</script>

<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
<script src="https://unpkg.com/react@17/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>
  • Does this answer your question? [The useState set method is not reflecting a change immediately](https://stackoverflow.com/questions/54069253/the-usestate-set-method-is-not-reflecting-a-change-immediately) – Randy Casburn Mar 17 '23 at 16:47
  • FYI: You are creating a memory leak by creating many IntersectionObserver objects on each call to `useEffect()`. You should move `new IntersectionObserver()` to the root of your component and only use 'observer` inside the `useEffect()`. – Randy Casburn Mar 17 '23 at 17:05
  • @RandyCasburn I think it doesn't, as I see the problem is that the refernce to *visible* ( the state variable ) it's different in the callback, somehow there are two scopes and one of them never gets updated. About the memory leak, I think if I move it to the root of the component it will be re-instantiated each time the component re-renders, am I wrong? – Jose Miguel Arroyave Mar 17 '23 at 17:25
  • @RandyCasburn I read again the question you commented before, and even if it didn't solve my issue helped me understand the problem, thanks. – Jose Miguel Arroyave Mar 17 '23 at 21:29

1 Answers1

0

Well, this is how I solved it...

First of all the problem is caused by something called "stale closures" and it's an issue of lost references between re-renders.

Read more on this links

To solved it I rewrote the component as a Class Component,

<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.6.3/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.6.3/umd/react-dom.production.min.js"></script>
<div id="root"></div>

<script type="text/babel" defer>
const { useEffect, useRef, useState, useCallback } = React; 

const visibilityThreshold = 0.5

class IntersectionObserverTest extends React.Component{
  constructor( props ){
    super(props)
    this.state = {
      data: new Array(props.itemsCount ?? 10).fill(null).map( (a, i) => `item ${i}`),
      visible: [],
    }

    this.scrollDiv = React.createRef( )
    this.itemsRef = React.createRef()
    this.itemsRef.current = []

    this.onInteraction = this.onInteraction.bind(this)
  }

  componentDidMount(){
    this.itemsRef.current = this.itemsRef.current.slice(0, this.state.data.length);

    const observer = new IntersectionObserver( this.onInteraction, {
      root: this.scrollDiv.current,   
      threshold: visibilityThreshold,
    })

    for( var i = 0; i < this.state.data.length; i++ ){
      observer.observe( this.itemsRef.current[i] )
    }

    console.log("INITIALIZED")

  }

  onInteraction(entries, opts){
    console.log("visible:", this.state.visible)
    var onView = [ ...this.state.visible ]
    entries.forEach( e => {
      const key = e.target.getAttribute('itemKey')
      const t = e.intersectionRatio
      console.log(key, t)
      if( t < visibilityThreshold ){
        onView = onView.filter( k => k != key )
      } else {
        onView.push( key )
      }
    })

    this.setState( { visible: onView } )
  }

  render( ){
    const { data } = this.props

    return <>
      <div ref={this.scrollDiv} style={{width: '100%', overflow: 'scroll'}}> 
        <div style={{ display: 'flex', flexDirection: 'row' }}>
          { this.state.data.map( (d, di) => 
            <div 
              ref={el => this.itemsRef.current[di] = el} 
              style={{ display: 'block', minWidth: '200px', width: '200px', border: '1px solid black', padding: '1rem', textAlign: 'center'}} 
              key={di}
              itemKey={`item-${di}`}
            >
              {d}
            </div>
          )}
        </div>
      </div>
      <div>
        on view:
        <div>{this.state.visible.map( v => <div key={v}>{v} </div>)}</div>
      </div>
    </>
  }

}


const rootElement = document.getElementById("root");
ReactDOM.render(<IntersectionObserverTest />, rootElement);
</script>

<script src="https://unpkg.com/@babel/standalone@7/babel.min.js"></script>
<script src="https://unpkg.com/react@17/umd/react.production.min.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.production.min.js"></script>