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.
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.