React useState: unexpected behavior when partitioning infinite feed by time

Viewed 46
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?

1 Answers

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>

    )},
);
Related