My component fetches and displays posts using infinite scrolling (via an IntersectionObserver). The API call that the component makes is dependent on the current number of fetched posts. This is passed as an offset to the API call.
The desired effect is that, if the posts array is empty, its length is 0 and my API will return the first 5 posts. When the last post in the UI intersects with the viewport, another fetch is made, but this time the API call is passed an offset of 5, so the API skips the first 5 posts in the collection and returns the next 5 instead.
Here is my code:
export default function Feed() {
const [posts, setPosts] = useState([]);
const fetchPosts = async () => {
const newPosts = await postAPI.getFeed(posts.length);
setPosts((prevPosts) => [...prevPosts, ...newPosts]);
};
useEffect(fetchPosts, []);
const observerRef = useRef(
new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
observerRef.current.unobserve(entry.target);
fetchPosts();
}
})
);
const observePost = useCallback((node) => node && observerRef.current.observe(node), []);
return (
<>
{posts.map((post) => {
const key = post._id;
const isLastPost = key === posts.at(-1)._id;
const callbackRef = !isLastPost ? undefined : observePost; //only if last post in state, watch it!
return <PostCard {...{ key, post, callbackRef }} />;
})}
</>
);
}
However, every time the fetchPosts function is called, the value it uses for posts.length is 0 - the number the function was originally created with.
Can someone explain to me why the closure over posts.length is stale here? I thought that every time a component re-renders, all nested functions within it were recreated from scratch? As such, surely the fetchPosts function should be using the latest value of posts.length every time it is called? Any help is appreciated! :)
You are creating a new IntersectionObserver on every render - not a good idea. However, only the first of all these created observers, the one you store in the ref (the ref that is never updated), is the one on which you call observe(), and that first observer uses a stale closure over the initial value of posts.
Instead, I would suggest creating the intersection observer(s) inside the observePost function:
const [posts, setPosts] = useState([]);
const fetchPosts = useCallback(async () => {
const newPosts = await postAPI.getFeed(posts.length);
setPosts((prevPosts) => [...prevPosts, ...newPosts]);
}, [posts.length]);
// ^^^^^^^^^^^^
useEffect(fetchPosts, []);
const observePost = useCallback((node) => {
if (!node) return;
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) {
observer.unobserve(entry.target);
fetchPosts();
}
});
observer.observe(node);
}, [fetchPosts]);
// ^^^^^^^^^^
Important here is the dependency of the observePost callback on the fetchPosts callback which has a dependency on the length of posts, to avoid getting stale. It's probably also possible to solve this with refs, but I don't think they're necessary here.