Search code examples
javascriptreactjsintersection-observer

State in IntersectionObserver callback is undefined


I have a react app that displays a grid of 3 columns. I'm trying to implement the Intersection Observer Api to enable infinite scroll, however, it's not working. The following code prints out:

in handleObserver
TestStateUpdater2.tsx:168 this is data  undefined

and my current code:

import { Container } from "pixi.js";
import React, { useState, useRef, useEffect } from "react";

export function TestStateUpdater2() {
  const [data, setData] = useState<Array<ICarRequest>>()
  const ref = useRef(null);

  useEffect(()=> {
    setData([
      {
        priceMax: 1,
        newOrUsed:"new",
        make: "make",
        model: "model",
        year: 222,
        bodyType: "jeep",
        fuelType: "gas",
        color: "blue",
        thumbnail: "picture",
        trim: "nice",
        dealerPrice: "333",
        msrp: 345,
        driveTrain: "5"
      },
    ...
    ])
  }, [])

  useEffect(() => {
    const observer = new IntersectionObserver(
      handleObserver,
      {
        threshold: []
      }
    );
    if(ref && ref.current){
      observer.observe(ref.current);
    }

    return () => {
      if (ref.current) {
        observer.unobserve(ref.current);
      }
    }
  }, []);

  function handleObserver(x: IntersectionObserverEntry[]) {
    console.log("in handleObserver")
    console.log("this is data ", data)
    const newArray = [
      {
        priceMax: 1,
        newOrUsed:"new",
        make: "make",
        model: "model",
        year: 222,
        bodyType: "jeep",
        fuelType: "gas",
        color: "blue",
        thumbnail: "picture",
        trim: "nice",
        dealerPrice: "333",
        msrp: 345,
        driveTrain: "5"
      }
    ];
    // if(data)
    // setData([...data,...newArray]);
  }


  function getData(){
    if(data){
      const somedata = data.slice(0,10).map(x=>
        <div>
          <Card>
            <Card.Img variant="top" src={x.thumbnail} />
            <Card.Body>
            <Card.Title>{x.year} {x.make} {x.model} {x.trim}</Card.Title>
              <ListGroup>
                <ListGroup.Item key={x.make + x.model + x.year + x.dealerPrice}>Dealer price: ${x.dealerPrice}</ListGroup.Item>
                <ListGroup.Item key={x.make + x.model + x.year + x.msrp}>MSRP: ${x.msrp}</ListGroup.Item>
                <ListGroup.Item key={x.make + x.model + x.year + x.driveTrain}>Drive Train: {x.driveTrain}</ListGroup.Item>
              </ListGroup>
            </Card.Body>
          </Card>
        </div>
      )
      return somedata
    }
  }

  return (
    <Container className="container">
      <Container className="theContainer">
        <div id="carDisplay" ref={ref} className="row row-cols-3">
          {getData()}
        </div>
      </Container>
      <Container className="theContainer2">
        <div  ref={ref} className="footer">
          This is a footer
        </div>
      </Container>
    </Container>
  )
}

When I scroll, the Observer triggers, but what's in state is undefined. I want the handleObserver method to trigger, where I plan on making another api call to add to 'data'. I don't understand why 'data' is undefined in the callback?

If I change the useEffect that creates the Observer to:

useEffect(() => {        
  const observer = new IntersectionObserver(
  handleObserver,
  ...
}, [data]);

Now, the 'data' in handleObserver has values and is no longer undefined. But, if I go ahead and update 'data' there:

if(data) {
  setData([...data,...newArray]);
}

It sets off an infinite loop.

I'm not sure what I'm supposed to do? I want to scroll down on the page, have the Observer trigger the handleObserver callback, then load in more items to my grid.


Solution

  • The reason that data is undefined (before you added data to the deps array) is that the effect was capturing the handleObserver from the initial render, which "closes over" whatever the values were at the time of that initial render.

    Effectively, the effect that registers the IntersectionObserver ran just once on component mount and used the handleObserver that it had at the time. However, that handleObserver instance will forever reference the state from the initial render. When the data changes, the IntersectionObserver still has that handleObserver from the initial render. And that will not magically start referencing the new values. That's because it doesn't update the same object whenever you do setData. A new one is created. So you have stale references.

    When you then add data to the deps array, suddenly the IntersectionObserver is being removed when data changes, and a new one is created with a fresh handleObserver that will have references to the new state. This is usually the correct thing to do, there shouldn't be any dependencies missing from useEffect and data is indeed a dependency here, albeit indirectly.

    This is just a code smell and not the root cause (read on), but the conceptual dependencies become much clearer if everything used inside the effect from the outside that fundamentally may change its reference between renders, is referenced in deps. Lint rules enforce this behavior. Typically, you'd end up with something like:

    const handleObserver = useCallback(
      (x: IntersectionObserverEntry[]) => {
        console.log("in handleObserver");
        console.log("this is data ", data);
        const newArray = [
          {
            priceMax: 1,
            newOrUsed: "new",
            make: "make",
            model: "model",
            year: 222,
            bodyType: "jeep",
            fuelType: "gas",
            color: "blue",
            thumbnail: "picture",
            trim: "nice",
            dealerPrice: "333",
            msrp: 345,
            driveTrain: "5",
          },
        ];
        // if(data)
        //     setData([...data,...newArray]);
      },
      [data],
    );
    
    useEffect(() => {
      const observer = new IntersectionObserver(handleObserver, {
        threshold: [],
      });
      if (ref && ref.current) {
        observer.observe(ref.current);
      }
    
      return () => {
        if (ref.current) {
          observer.unobserve(ref.current);
        }
      };
    }, [handleObserver]);
    
    

    Which is more clear. The handleObserver callback needs to update when data changes, since it references it. And the effect needs to re-run when handleObserver changes, since it references it. In fact, doing this isn't optional. Unless you wan't weird stale reference bugs like you were experiencing.

    Now, back to your issue. When you instantiate an IntersectionObserver, its behaviour is that the callback (handleObserver for you) will fire instantly.

    That's ok, but you need to avoid calling setData() in that callback when you don't actually need to. Otherwise, you're going to get in a loop because calling setData() will cause data to change on the next re-render, which eventually will bubble up to the IntersectionObserver registering again and firing off such that setData() is called again. You can see how that will end up in a cycle.

    Practically speaking, that means you need to check if data already contains the data you are proposing to add, before you do so. Once you go async, this will include checking if the relevant API request is already in flight. Likely, that will require a supporting structure that holds the loading state for various triggers so that it can be checked here.

    If it's already there, or there's a request in flight, you will not call setData. This guards against the loop problem and will be necessary anyway once you go async.

    In addition to this you must use the callback form of setState here. So setData([...data,...newArray]); should be setData(data => [...data,...newArray]);. See the docs.

    By the way, mapping event based APIs like this to React primitives is tricky, as you can tell. Usually there's libraries that will make everything feel more ergonomic. For example, react-intersection-observer. Such things are already handling these complexities, which are common.