'React useState: unexpected behavior when partitioning infinite feed by time

Context

I have a component that displays an infinite feed of posts. I want to detect the first element that is older than a day so I can display a "posted yesterday" banner above just that element (eventually doing for week and month).

Buggy Solution

I do this by checking if the post is older than a day when rendering my <li> items, but there is some weird behavior when I try to use a boolean state flag to prevent "posted yesterday" being attached to every post older than a day.

Code

Infinite Scroll Component:

export function InfiniteFeed(props: InfiniteFeedProps) {
    const [page, setPage] = useState(0);
    const [shouldDisplay, setShouldDisplay] = useState(true)
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(false);
    const [posts, setPosts] = useState<Post[]>([])
    const [hasMore, setHasMore] = useState(false);

    const nowMillis = DateTime.local({zone: 'utc'}).toMillis();
    
    // have omitted dataFetching logic + ref observer logic

    const timeCheck = (eventTimeMillis: number) => {
        if (!shouldDisplay) {
            return false
        }
        if (!olderThanDay(eventTimeMillis, nowMillis)) {
            return false
        }
        setShouldDisplay(false) // setting this renders every time-check(post) to false
        return true
    }


    return (
        <div>
            <FeedWrapper>
                <Banner title="Infinite Feed"/>
                <ul style={{padding: 0}}>
                    {posts.map((post, i) => (
                        <FeedElement
                            key={post.id}
                            ref={(post.length === i + 1) ? lastEventElementRef : () => null}
                            id={post.id}
                            display={timeCheck(post.eventTimestamp)}  // if true, element prepends "posted yesterday"
                            // other props
                        />
                    ))}
                </ul>
            </FeedWrapper>
            <div></div>
        </div>
    );
}
Behavior

If I don't do anything with "shouldDisplay" state, the feed correctly prepends every post older than a day with "Posted Yesterday". However, if I set shouldDisplay to false as seen in the code, timeCheck() is always false and "posted yesterday is not displayed".

Ask

Are there any pointers to what is going wrong here?



Solution 1:[1]

I had overlooked that setState() causes a re-render, thus making timeCheck() return false every time. To get my desired behavior of a time partitioned feed, I calculated which indices should prepend a time string when I fetched my posts, I then set shouldDisplay() = true if the elements index were in this array. Here is my code

Solution
export function InfiniteFeed(props: InfiniteFeedProps) {
    const [page, setPage] = useState(0);
    const [shouldDisplay, setShouldDisplay] = useState(true)
    const [loading, setLoading] = useState(true);
    const [error, setError] = useState(false);
    const [posts, setPosts] = useState<Post[]>([])
    const [hasMore, setHasMore] = useState(false);

    const [timePartitions, setTimePartitions] = useState<number[]>([]) // indices of time buckets
    const now = DateTime.local({zone: 'utc'});
    // using luxon library for handling time and using
    // Duration api to account for the variable month lengths
    const timeBuckets = [
        now.minus(Duration.fromObject({month: 1})).toMillis(),
        now.minus(Duration.fromObject({week: 1})).toMillis(),
        now.minus(Duration.fromObject({day: 1})).toMillis()
    ];

    // have omitted useRef dom observer logic
    useEffect(() => {
        // ...
        CoreApi().post<GetEventsResponse>('posts', request)
            .then((res) => {
                const allPosts = [...posts, ...res.data.posts]
                setPosts(allPosts);
                calculateTimePartitions(allEvents)
                // ...
            })
    }, [page])

    const shouldDisplay = (index: number): boolean => {
        return timePartitions.find(num => num == index) != undefined
    }

    // calculate the indexes where we want to prepend time banner
    const calculateTimePartitions = (eventList: NewsEvent[]) => {
        let indexes = [];
        let intervals = timeBuckets
        for (let i = 0; i < posts.length; i++) {
            if (eventList[i].eventTimestamp < intervals[intervals.length - 1] ) {
                intervals.pop()
                indexes.push(i);
            }
        }
        setTimePartitions(indexes)
    }

    return (
        <div>
            <FeedWrapper>
                <Banner title="Infinite Feed"/>
                <ul style={{padding: 0}}>
                    {posts.map((post, i) => (
                        <FeedElement
                            key={post.id}
                            ref={(post.length === i + 1) ? lastEventElementRef : () => null}
                            id={post.id}
                            display={shouldDisplay(i)}  // if true, element prepends "posted yesterday"
                            // other props
                        />
                    ))}
                </ul>
            </FeedWrapper>
            <div></div>
        </div>
    );
}
Feed element logic
export const FeedElement = forwardRef<any, NewsEvent & FeedElementProps>((props: Post & FeedElementProps, ref) => {

    
    // can be more expensive
    const getTimeBucket = () => {
        const nowMillis = DateTime.local({zone: 'utc'})
        const eventTime = DateTime.fromMillis(props.eventTimestamp, {zone: 'utc'})
        const interval = Interval.fromDateTimes(eventTime, nowMillis)

        if (interval.length('day') > 1 && interval.length('day') < 2) {
            return 'Posted yesterday'
        }
        if (interval.length('day') > 2 && interval.length('week') < 1) {
            return 'Posted this week'
        }
        if (interval.length('week') > 1 && interval.length('month') < 1) {
            return 'Posted this month'
        }
        else if (interval.length('month') > 1) {
            return 'Posted over a month ago'
        }
        else {
            return '';
        }
    }

    return (
                <div>
                    {props.display ? (
                        <Banner title={getTimeBucket()} />
                    ) : (<></>)}
                    <div>
                        <li>
                        {/*other stuff*/}
                        </li>
                    </div>
                </div>

    )},
);

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Stuart