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