Introduction

When building modern web applications, ensuring smooth user interactions is critical. React, with its powerful declarative UI updates and component-based architecture, offers great flexibility. However, as the application grows in complexity, performance can degrade, especially when dealing with large datasets or intensive user interactions. This article highlights the challenges we faced when implementing a simple search filter in React and how we overcame performance issues by optimizing the component.

The Problem: User Interaction with Large Datasets

Imagine an application that displays a large list of users, and the user can filter this list by typing in a search box. At first glance, this seems like a simple task, but as we scale the number of users, even small inefficiencies can lead to significant performance problems.
We initially built a search filter component that allowed users to filter through a list of 5,000 users by name or email. The interaction involved typing in an input field and dynamically filtering the list as the user typed. The performance, however, quickly became problematic.

The Original Approach

Here is the first version of our component:

import React, { useState, useTransition } from "react";

// 🔧 Fake user generator
const generateUsers = (count: number) => {
  const names = ["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Helen"];
  return Array.from({ length: count }, (_, i) => ({
    id: i,
    name: `${names[i % names.length]} ${i}`,
    email: `user${i}@example.com`,
  }));
};

const usersData = generateUsers(5000);

export default function MassiveSearchFilter() {
  const [query, setQuery] = useState("");
  const [filteredUsers, setFilteredUsers] = useState(usersData);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (e: { target: { value: any; }; }) => {
    const value = e.target.value;
    setQuery(value);

    startTransition(() => {
      const lower = value.toLowerCase();
      const filtered = usersData.filter(
        (user) =>
          user.name.toLowerCase().includes(lower) ||
          user.email.toLowerCase().includes(lower)
      );
      setFilteredUsers(filtered);
    });
  };

  const highlight = (text: string, query: string) => {
    if (!query) return text;
    const index = text.toLowerCase().indexOf(query.toLowerCase());
    if (index === -1) return text;
    return (
      <>
        {text.slice(0, index)}
        <mark className="bg-pink-200 text-black">
          {text.slice(index, index + query.length)}
        </mark>
        {text.slice(index + query.length)}
      </>
    );
  };

  return (
    <div className="min-h-screen bg-gray-100 p-6 font-sans">
      <div className="max-w-3xl mx-auto bg-white p-6 rounded-2xl border-2 border-pink-200 shadow-md">
        <h1 className="text-2xl font-bold text-pink-600 mb-4">
          👩‍💻 Massive Search Filter (5,000 Users)
        </h1>

        <input
          type="text"
          value={query}
          onChange={handleSearch}
          placeholder="Search name or email..."
          className="w-full p-3 mb-4 border-2 border-pink-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-400"
        />

        {isPending && (
          <div className="text-pink-500 italic mb-2 text-sm animate-pulse">
            🔄 Loading results...
          </div>
        )}

        <div className="max-h-[500px] overflow-y-auto divide-y divide-pink-100 border-t border-pink-100">
          {filteredUsers.map((user) => (
            <div
              key={user.id}
              className="p-3 hover:bg-pink-50 transition-colors duration-150"
            >
              <p className="font-medium text-gray-800 text-base">
                {highlight(user.name, query)}
              </p>
              <p className="text-sm text-gray-500">
                {highlight(user.email, query)}
              </p>
            </div>
          ))}
        </div>
      </div>
    </div>
  );
}

Challenges Faced

At first, everything seemed fine. The search was fast, and the results appeared almost instantly. However, as we started testing with larger datasets (e.g., 5,000+ users), the following issues quickly became evident:

  1. Performance Bottleneck:
    The component would slow down significantly when typing quickly in the search field.
    React re-renders the entire list of 5,000 users with each keystroke, causing delays in UI updates.

  2. UI Freezing:
    Since the search filter was directly tied to the input field and user interactions, typing or deleting text quickly caused the UI to freeze, as React was overwhelmed with filtering and re-rendering thousands of DOM nodes.

  3. Over-reliance on Filtering:
    Every change in the search input would trigger a full filter of the list, even when the user was still typing, which was inefficient.

The Optimization Process

After identifying the issues, we applied several techniques to improve the performance and user experience:

Step 1: Use react-window for Virtualization

Rendering 5,000+ items is a classic case for virtualization. We decided to use react-window, which allows React to only render the items that are visible in the viewport. This means that even with large datasets, we don't need to render the entire list, drastically reducing the load on the browser.

Step 2: Memoization of Filtered Results

By using the useMemo hook, we memoized the filtered results based on the query. This ensures that the filtering computation only happens when the query changes, rather than on every render.

Step 3: Implementing startTransition for Smooth UI Updates

The useTransition hook allows us to prioritize updates to the input field while deferring the filtering computation. This ensures that the UI remains responsive while the app performs the more expensive task of filtering.

The Optimized Component

Here’s the optimized version of the component after these changes:

import React, { useState, useMemo, useTransition } from "react";
import { FixedSizeList as List } from "react-window";

const generateUsers = (count: number) => {
  const names = ["Alice", "Bob", "Charlie", "David", "Eve", "Frank", "Grace", "Helen"];
  return Array.from({ length: count }, (_, i) => ({
    id: i,
    name: `${names[i % names.length]} ${i}`,
    email: `user${i}@example.com`,
  }));
};

const usersData = generateUsers(5000);

export default function MassiveSearchFilter() {
  const [query, setQuery] = useState("");
  const [isPending, startTransition] = useTransition();

  const filteredUsers = useMemo(() => {
    const lower = query.toLowerCase();
    return usersData.filter(
      (user) =>
        user.name.toLowerCase().includes(lower) ||
        user.email.toLowerCase().includes(lower)
    );
  }, [query]);

  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    const value = e.target.value;
    startTransition(() => setQuery(value));
  };

  const highlight = (text: string, query: string) => {
    if (!query) return text;
    const index = text.toLowerCase().indexOf(query.toLowerCase());
    if (index === -1) return text;
    return (
      <>
        {text.slice(0, index)}
        <mark className="bg-pink-200 text-black">
          {text.slice(index, index + query.length)}
        </mark>
        {text.slice(index + query.length)}
      </>
    );
  };

  const Row = ({ index, style }: { index: number; style: React.CSSProperties }) => {
    const user = filteredUsers[index];
    return (
      <div
        style={style}
        key={user.id}
        className="p-3 border-b border-pink-100 hover:bg-pink-50"
      >
        <p className="font-medium text-gray-800 text-base">
          {highlight(user.name, query)}
        </p>
        <p className="text-sm text-gray-500">{highlight(user.email, query)}</p>
      </div>
    );
  };

  return (
    <div className="min-h-screen bg-gray-100 p-6 font-sans">
      <div className="max-w-3xl mx-auto bg-white p-6 rounded-2xl border-2 border-pink-200 shadow-md">
        <h1 className="text-2xl font-bold text-pink-600 mb-4">
          👩‍💻 Massive Search Filter (Virtualized)
        </h1>

        <input
          type="text"
          value={query}
          onChange={handleSearch}
          placeholder="Search name or email..."
          className="w-full p-3 mb-4 border-2 border-pink-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-pink-400"
        />

        {isPending && (
          <div className="text-pink-500 italic mb-2 text-sm animate-pulse">
            🔄 Filtering...
          </div>
        )}

        {filteredUsers.length === 0 ? (
          <div className="p-4 text-gray-500 italic text-center">No users found.</div>
        ) : (
          <List
            height={500}
            itemCount={filteredUsers.length}
            itemSize={70}
            width="100%"
            className="border-t border-pink-100"
          >
            {Row}
          </List>
        )}
      </div>
    </div>
  );
}

Conclusion

By applying virtualization, memoization, and UI prioritization strategies, we were able to significantly improve the performance of our search filter component. The user interaction is now smooth and responsive, even with thousands of items to filter through.

This experience highlights the importance of optimizing for performance when dealing with large datasets or complex user interactions. React’s built-in hooks like useMemo, useTransition, and third-party libraries like react-window can make a huge difference in how performant and smooth your application feels to users.

Code reference: https://github.com/paghar/react19-next15-trickysample