React.js Real-Life Problem ,  How I Optimized a 5,000+ Row Table for Smooth UX (step-by-step)

React.js Real-Life Problem , How I Optimized a 5,000+ Row Table for Smooth UX (step-by-step)

Large client-side tables are a classic real-world React pain point. Render thousands of rows and the page freezes, initial load is slow and scrolling becomes laggy. In this article I’ll show a practical, step-by-step approach to fix this using virtualized rendering (react-window), plus tips for real production concerns like selection, sorting, infinite loading and measuring the improvement.

Problem statement

You have a table (e.g. transactions, users, logs) with thousands of rows. Symptoms:

  1. Slow initial render.
  2. High memory / CPU while scrolling.
  3. Poor mobile performance and janky UX.

What we want: render only the rows visible on screen so the table feels instant and uses minimal memory.


Step 1 — Reproduce & measure the baseline

Before changing anything, record the baseline so you can measure improvement.

  1. Open Chrome DevTools → Performance tab.
  2. Click Record, then reproduce the slow behaviour (initial load + a few scrolls).
  3. Stop recording and look at scripting / rendering / paint times.
  4. Note CPU spikes and long frames (anything >16ms).

(You can also run Lighthouse to measure initial load / time-to-interactive.)


Step 2 — Pick the right tool: react-window

For most tables, react-window is lightweight, simple and fast.

Install:

npm install react-window
# or
yarn add react-window        

If you need infinite loading later, add:

npm install react-window-infinite-loader        

Step 3 — Convert the table body to a virtualized list (core example)

Below is a small, complete component that replaces a heavy <tbody> with a FixedSizeList. It includes a sticky header and a virtualized body.

// VirtualizedTable.jsx
import React from "react";
import { FixedSizeList as List } from "react-window";

const Row = React.memo(({ index, style, data }) => {
  const item = data[index];
  return (
    <div
      style={{
        ...style,
        display: "flex",
        padding: "10px 12px",
        boxSizing: "border-box",
        borderBottom: "1px solid #eee",
        alignItems: "center",
      }}
    >
      <div style={{ flex: "0 0 80px" }}>{item.id}</div>
      <div style={{ flex: "1 1 auto" }}>{item.name}</div>
      <div style={{ flex: "0 0 160px", textAlign: "right" }}>{item.amount}</div>
    </div>
  );
});

export default function VirtualizedTable({ data, height = 600, rowHeight = 56 }) {
  return (
    <div style={{ border: "1px solid #ddd", borderRadius: 6, overflow: "hidden" }}>
      {/* Header */}
      <div style={{ display: "flex", padding: "12px", background: "#f7f7f7", fontWeight: 600 }}>
        <div style={{ flex: "0 0 80px" }}>ID</div>
        <div style={{ flex: "1 1 auto" }}>Name</div>
        <div style={{ flex: "0 0 160px", textAlign: "right" }}>Amount</div>
      </div>

      {/* Virtualized list */}
      <List
        height={height}
        itemCount={data.length}
        itemSize={rowHeight}
        width="100%"
        itemData={data}
      >
        {Row}
      </List>
    </div>
  );
}        

Usage example:

const data = Array.from({ length: 5000 }, (_, i) => ({
  id: i + 1,
  name: `User ${i + 1}`,
  amount: (Math.random() * 1000).toFixed(2),
}));

<VirtualizedTable data={data} />        

Why this helps: only rows visible inside the height are mounted; offscreen rows aren’t in DOM , drastically lowers paint/scripting work.


Step 4 — Production hardening & best practices

  1. Memoize rows use React.memo for row components to avoid unnecessary re-renders.
  2. Avoid recreating itemData — if you pass an object to itemData, wrap it in useMemo so the reference is stable.
  3. Avoid inline functions/styles for heavy lists prefer CSS classes or memoized style objects.
  4. Stable callbacks use useCallback for handlers passed down to rows.
  5. Key selection state carefully keep minimal selection state (e.g., selectedId) and don’t pass large objects down unless necessary.
  6. Accessibility if semantics matter, consider innerElementType to render a ul or table-like element and add appropriate ARIA roles. (Virtualization complicates native table semantics.)

const itemData = useMemo(() => ({ items: data, selectedId, onRowClick }), [data, selectedId, onRowClick]);        

Step 5 — Sorting, filtering, selection

  • Sorting/filtering should be done on the source data (server side if huge) and the virtualized list simply consumes the filtered array.
  • For selection, store only the selected IDs in parent state and pass them (or a stable lookup) via itemData. Keep the payload minimal so list items don’t re-render unnecessarily.

Step 6 — Infinite loading (optional)

If your API pages results, combine react-window with react-window-infinite-loader:

import InfiniteLoader from "react-window-infinite-loader";

const isItemLoaded = index => index < data.length;

<InfiniteLoader
  isItemLoaded={isItemLoaded}
  loadMoreItems={loadMoreItems}      // function that fetches the next page
  itemCount={hasMore ? data.length + 1 : data.length}
>
  {({ onItemsRendered, ref }) => (
    <List
      height={600}
      itemCount={data.length}
      itemSize={56}
      width="100%"
      onItemsRendered={onItemsRendered}
      ref={ref}
      itemData={data}
    >
      {Row}
    </List>
  )}
</InfiniteLoader>        

This gives seamless “infinite scroll” experience while remaining virtualized.

Step 7 — Measure after changes

Repeat Step 1 profiling. Expected qualitative improvements:

  • Initial paint time drops significantly.
  • Long tasks reduce or disappear.
  • Smooth 60fps-like scrolling (no jank).

Quantify with before/after numbers for scripting/paint time from DevTools to show impact in your post readers love data.

Step 8 Edge cases & alternatives

  • Variable row height → use VariableSizeList (you must provide a way to measure/estimate heights).
  • Complex rows with images → lazy load images (loading="lazy" or IntersectionObserver) and keep placeholders.
  • If you need table semantics → you can virtualize rows but keep headers as real DOM; or use libraries that support virtualized tables (e.g., react-virtualized + CellMeasurer) but they are heavier.

Quick checklist (before you publish)

  • Baseline profile recorded (screenshots/metrics).
  • react-window implemented with fixed row height.
  • Rows memoized, callbacks stable.
  • Infinite loader added if using paged API.
  • Re-profiled and numbers included in post.

TL;DR

Stop rendering thousands of DOM rows virtualize. react-window is a tiny, robust way to render only what’s visible, dramatically improving load and scroll performance. Combine virtualization with memoization, stable props and server-side pagination for best results.


🔗 Resources

👉 1: Book 1:1 Mentorship / Pair Programming Help

Need 1-on-1 help? Book a session here →

👉 2: Learn Advanced React with My Favorite Udemy Courses

📚 React Next.js Rest API Backend Typescript Course 2025

📚 React Next JS Practical Application ShadCN UI 2025

👉 3: Subscribe to My Newsletter

Get React + Frontend tips weekly, short, simple, and practical:

Code & Coffee: Daily Dev Drops Newsletter


📣 CTA

Have you faced bugs like these? Or worse?

Drop a comment or DM , let’s debug together and build smarter.

Luis N. Cervantes

Full Stack Developer | Software Developer | Scrum Master | Business Intelligence (BI)

1d

Amazing! Thank you.

Like
Reply
HRITHIK VARSHNEY

Software Developer @ Etelligens Technologies

4d

Helpful insight, AYUSH

Like
Reply
Noah Beck

Freelance React & React Native Developer | Mobile and Web Apps for VC-Backed Startups | Remote Delivery

4d

CFBR, Great Share

Like
Reply

To view or add a comment, sign in

Explore topics