EmpireUI
Get Pro
← Blog9 min read#activity feed#react#timeline

Activity Feed in React: Timeline, Filters, Infinite Load

Build a full activity feed in React with a timeline layout, type filters, and infinite scroll — no library magic, just composable hooks and clean state.

React code on a monitor showing a timeline activity feed component

What an Activity Feed Actually Needs

Most teams underestimate an activity feed until they're three sprints deep and the product manager wants filters, avatars, timestamps, infinite scroll, and real-time updates — all at once. That's when 'just render a list' stops cutting it.

In practice, an activity feed is four separate problems duct-taped together: data fetching with cursor-based pagination, a timeline UI with connectors between events, a filter bar that doesn't cause a full re-fetch on every click, and a smooth load-more trigger. Each one is solvable. The mistake is building them as one monolithic component.

This guide splits them apart. You'll end up with a useActivityFeed hook that owns the data, a TimelineItem component that owns the visuals, and a filter layer that sits between them. You can swap any piece without touching the others.

Worth noting: the examples here use React 18 with TypeScript. If you're on React 19, the patterns are identical — the compiler just makes some of the memoization unnecessary.

Data Model and the useActivityFeed Hook

Start by pinning down your event type. Keep it flat. Nested objects inside feed events are a serialization headache you don't want.

type ActivityEvent = {
  id: string;
  type: 'comment' | 'like' | 'follow' | 'mention' | 'deploy';
  actor: { id: string; name: string; avatarUrl: string };
  target?: { id: string; label: string; href: string };
  createdAt: string; // ISO 8601
  meta?: Record<string, string>;
};

type FeedPage = {
  events: ActivityEvent[];
  nextCursor: string | null;
};

Now build the hook. The key decision here is cursor vs. offset pagination. Use cursors — they don't drift when new events arrive between page fetches, which is exactly the scenario you'll hit in a live feed.

import { useState, useCallback, useRef } from 'react';

export function useActivityFeed(
  fetchPage: (cursor: string | null, types: string[]) => Promise<FeedPage>
) {
  const [events, setEvents] = useState<ActivityEvent[]>([]);
  const [cursor, setCursor] = useState<string | null>(null);
  const [loading, setLoading] = useState(false);
  const [hasMore, setHasMore] = useState(true);
  const activeTypes = useRef<string[]>([]);

  const loadMore = useCallback(async () => {
    if (loading || !hasMore) return;
    setLoading(true);
    try {
      const page = await fetchPage(cursor, activeTypes.current);
      setEvents(prev => [...prev, ...page.events]);
      setCursor(page.nextCursor);
      setHasMore(page.nextCursor !== null);
    } finally {
      setLoading(false);
    }
  }, [cursor, loading, hasMore, fetchPage]);

  const resetWithTypes = useCallback((types: string[]) => {
    activeTypes.current = types;
    setEvents([]);
    setCursor(null);
    setHasMore(true);
  }, []);

  return { events, loading, hasMore, loadMore, resetWithTypes };
}

Notice activeTypes is a ref, not state. That's intentional. Changing filter types should trigger resetWithTypes which resets state — you don't want activeTypes itself to be in the dependency array and cause a stale closure in loadMore. This is one of those subtle bugs that bites you in production around 3 AM.

Building the Timeline Layout

The timeline visual is mostly a CSS problem. You've got a vertical line, nodes on the line, and event cards next to each node. The trap is using absolute positioning — it breaks as soon as card heights vary. Grid is cleaner.

/* TimelineFeed.tsx */
function TimelineFeed({ events }: { events: ActivityEvent[] }) {
  return (
    <ol className="timeline-feed" role="feed">
      {events.map((event, i) => (
        <TimelineItem
          key={event.id}
          event={event}
          isLast={i === events.length - 1}
        />
      ))}
    </ol>
  );
}
/* timeline.css */
.timeline-feed {
  display: grid;
  grid-template-columns: 40px 1fr;
  row-gap: 0;
  list-style: none;
  padding: 0;
  margin: 0;
}

.timeline-item__connector {
  display: flex;
  flex-direction: column;
  align-items: center;
}

.timeline-item__line {
  width: 2px;
  flex: 1;
  background: #e2e8f0; /* replace with your design token */
  min-height: 24px;
}

.timeline-item__node {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  border: 2px solid #e2e8f0;
  background: white;
  z-index: 1;
}

Honestly, the 2px line width is the only value you'll ever fight about in a design review. Pin it early. Use a CSS custom property like --feed-connector-width: 2px so the designer can override it without touching your component.

For the TimelineItem itself, keep it dumb. It receives an event and a flag for whether it's the last item (to hide the trailing connector line). It doesn't know about fetching, filters, or scroll.

function TimelineItem({
  event,
  isLast,
}: {
  event: ActivityEvent;
  isLast: boolean;
}) {
  return (
    <li className="timeline-item">
      <div className="timeline-item__connector">
        <div className="timeline-item__node">
          <EventIcon type={event.type} />
        </div>
        {!isLast && <div className="timeline-item__line" />}
      </div>
      <div className="timeline-item__card">
        <EventCard event={event} />
      </div>
    </li>
  );
}

One more thing — role="feed" on the <ol> is the correct ARIA role for this exact pattern. Screen readers know how to navigate feed regions. Pair it with aria-busy when loading and you've covered the basics without a library. Check the react accessibility guide if you want to go deeper on that.

Filter Bar Without the Re-Fetch Thrash

The classic mistake is calling the API on every filter toggle. Your users click fast. You'll fire three requests in 400ms and then race-condition your way into stale data. Debounce the filter commit instead.

const FILTER_TYPES = [
  { value: 'comment', label: 'Comments' },
  { value: 'like', label: 'Likes' },
  { value: 'follow', label: 'Follows' },
  { value: 'mention', label: 'Mentions' },
  { value: 'deploy', label: 'Deploys' },
];

function FilterBar({
  onCommit,
}: {
  onCommit: (types: string[]) => void;
}) {
  const [selected, setSelected] = useState<Set<string>>(new Set());
  const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);

  function toggle(value: string) {
    setSelected(prev => {
      const next = new Set(prev);
      next.has(value) ? next.delete(value) : next.add(value);
      return next;
    });
  }

  useEffect(() => {
    if (timerRef.current) clearTimeout(timerRef.current);
    timerRef.current = setTimeout(() => {
      onCommit(selected.size > 0 ? [...selected] : []);
    }, 300);
    return () => {
      if (timerRef.current) clearTimeout(timerRef.current);
    };
  }, [selected, onCommit]);

  return (
    <div className="filter-bar" role="group" aria-label="Filter activity types">
      {FILTER_TYPES.map(({ value, label }) => (
        <button
          key={value}
          onClick={() => toggle(value)}
          aria-pressed={selected.has(value)}
          className={`filter-chip ${
            selected.has(value) ? 'filter-chip--active' : ''
          }`}
        >
          {label}
        </button>
      ))}
    </div>
  );
}

The 300ms debounce covers most click sequences. If your design calls for a 'Apply' button instead, just wire onCommit to the button's onClick and drop the useEffect. Same hook, different trigger.

Quick aside: aria-pressed on those filter chips is doing real work. Toggle buttons are not the same as checkboxes from a semantics standpoint — aria-pressed is the right choice here. Don't swap it for a checkbox just because it looks like one.

Infinite Scroll with IntersectionObserver

You could use a scroll event listener, but you'd be computing scroll position on every wheel tick. IntersectionObserver fires only when your sentinel element enters or exits the viewport. That's the right tool.

// useIntersectionTrigger.ts
import { useEffect, useRef } from 'react';

export function useIntersectionTrigger(
  callback: () => void,
  options: IntersectionObserverInit = { rootMargin: '200px' }
) {
  const ref = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    const el = ref.current;
    if (!el) return;

    const observer = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting) callback();
    }, options);

    observer.observe(el);
    return () => observer.disconnect();
  }, [callback, options]);

  return ref;
}

The rootMargin: '200px' means the load fires 200px before the sentinel hits the bottom of the viewport. That gives your fetch time to complete before the user visually reaches the end. In practice, bump it to 400px if your events are large cards — you want the next batch rendered before anyone notices the gap.

Wire it into the feed:

function ActivityFeedPage() {
  const { events, loading, hasMore, loadMore, resetWithTypes } =
    useActivityFeed(myApiFetcher);

  // Load first page on mount
  useEffect(() => { loadMore(); }, []);

  const sentinelRef = useIntersectionTrigger(
    useCallback(() => { if (hasMore && !loading) loadMore(); }, [hasMore, loading, loadMore])
  );

  return (
    <div>
      <FilterBar onCommit={types => { resetWithTypes(types); loadMore(); }} />
      <TimelineFeed events={events} />
      {loading && <SkeletonLoader />}
      {!loading && hasMore && <div ref={sentinelRef} aria-hidden="true" />}
      {!hasMore && <p className="feed-end">You're all caught up.</p>}
    </div>
  );
}

That sentinel div is invisible — it's just a trigger. Keep it small, keep it aria-hidden. If you want the skeleton loaders to match your event cards exactly, look at how the skeleton loader guide handles height estimation from content type.

Grouping Events by Date

Most feeds group events under date headers — Today, Yesterday, then formatted dates. This is a pure transform on your sorted array; it doesn't need to live in state.

type GroupedFeed = Array<
  | { kind: 'header'; label: string }
  | { kind: 'event'; event: ActivityEvent }
>;

function groupByDate(events: ActivityEvent[]): GroupedFeed {
  const result: GroupedFeed = [];
  let lastLabel = '';

  const now = new Date();
  const todayStr = now.toDateString();
  const yesterdayStr = new Date(
    now.getTime() - 86_400_000
  ).toDateString();

  for (const event of events) {
    const d = new Date(event.createdAt);
    const dateStr = d.toDateString();
    const label =
      dateStr === todayStr
        ? 'Today'
        : dateStr === yesterdayStr
        ? 'Yesterday'
        : d.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' });

    if (label !== lastLabel) {
      result.push({ kind: 'header', label });
      lastLabel = label;
    }
    result.push({ kind: 'event', event });
  }
  return result;
}

Call useMemo(() => groupByDate(events), [events]) in your feed component. React 19's compiler will likely optimize this away automatically, but the explicit memo doesn't hurt.

That said, date grouping shifts where 'isLast' applies — you can't just check array index anymore. Adjust your TimelineItem to receive hideConnector based on whether the next item in the flat list is a date header or the actual end.

Look, this is the kind of thing that's obvious once you've hit it in code review. Better to know now.

Styling Your Feed — Glassmorphism or Neobrutalism?

The feed component we've built is style-agnostic, which means you can skin it however the rest of your product looks. If you're building a dashboard with frosted-glass panels, the event cards slot right into a glassmorphism components aesthetic — backdrop-filter: blur(12px), semi-transparent backgrounds, subtle borders.

If your product is more aggressive in its visual language — solid borders, offset shadows, high contrast — the neobrutalism style works surprisingly well for feed items. The strong borders make each event feel distinct without needing separator lines.

For the connector line itself, a gradient from --color-primary to transparent looks clean on dark themes. On light themes, a flat #e2e8f0 is usually enough. Don't overthink it — the content is what matters.

Quick aside: if you want to prototype different event card styles rapidly without writing CSS from scratch, the glassmorphism generator and box shadow generator both export ready-to-paste CSS. Ten seconds to a polished card shadow is worth it.

FAQ

Should I use virtualization for a large activity feed?

Only if you're rendering 500+ events at once, which infinite scroll prevents. Virtualization adds complexity — start without it and only add react-virtual or TanStack Virtual if you measure a real paint problem.

How do I handle real-time updates without duplicating events?

Prepend new events to the front of the list and deduplicate by id before setting state. A Map<string, ActivityEvent> keyed on id is the fastest way to do this without an extra library.

Can I use this hook with React Query or SWR?

Yes — replace the internal fetch logic with useInfiniteQuery from TanStack Query. The IntersectionObserver sentinel and the filter reset pattern stay exactly the same.

What's the right page size for activity feed pagination?

20–25 events per page is a solid default. Below 10 and you're making too many requests; above 50 and your initial render slows down noticeably on low-end hardware.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

Infinite Scroll in React: Intersection Observer, React Query, VirtualizationStepper Component in React: Multi-Step Forms and OnboardingSocial Feed UI Design: Post Cards, Reactions, Infinite ScrollE-Commerce Product Filters UI: Sidebar, Chips, Active State