useTransition in React: Building a High-Performance Search for 50K Record Case Study
Fatemeh Paghar

Fatemeh Paghar @fpaghar

About: Programming is a major entertainment of my life. I’m interested in learning new technology development concepts and making suggestions for improvement. GitHub: https://github.com/paghar

Location:
Germany
Joined:
Dec 11, 2022

useTransition in React: Building a High-Performance Search for 50K Record Case Study

Publish Date: Apr 20
12 3

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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

Comments 3 total

  • Nevo David
    Nevo DavidApr 21, 2025

    Insane how much smoother things get once you start using stuff like virtualization and useTransition - honestly feels way better for my own code too.

    • Fatemeh Paghar
      Fatemeh PagharApr 21, 2025

      I ran into a big problem when trying to show more than 1,000 records in the UI. useTransition works okay for small amounts of data, but using a tool like react-window is much smoother and gives better results for things like loading time (LCP) and how fast the screen updates (INP) in the performance tab.

  • Aldorax
    AldoraxApr 26, 2025

    I tested this out with a twist and expanded the dataset even further. I generated a dataset of 100,000 users, each with a name and a skill instead of email addresses. I also added an additional input field for searching users by skill, but only after a user types in a name first.

    Here's how it works:

    • The first input field is for searching by user name (e.g., "Alice", "Bob").
    • The second input field is for searching by skillset (e.g., "React", "Python", "AWS"), but it stays disabled until the user provides some text in the name field.
    • Once a name is entered, the skill input becomes active, allowing users to refine their search further.

    To keep everything smooth and performant:

    • I used react-window's virtualized list so the UI can handle 100,000 users without lag.
    • I wrapped the filtering logic with startTransition and useMemo to make the filtering non-blocking and responsive, even with huge datasets.
    • I added highlighting for the matched parts of the text, making the search experience feel more interactive and polished.

    Finally, I included a performance statistics footer that shows:

    • Total users available
    • Number of users found after search
    • Last search duration (in milliseconds)
    • Whether the app is currently filtering (pending state indicator)

    This setup makes the experience super responsive even at massive scale while providing instant feedback to the user.


    Now, onto the downsides:

    • Memory Usage

      Even though the UI uses react-window for virtualized rendering, the full usersData array (100,000 users) remains fully in memory. This can lead to memory bloat on low-end devices or mobile browsers. It's manageable for local demos but risky for production datasets.

    • Client-Side Filtering

      All filtering happens completely in the browser using .filter() on the entire array. As datasets grow (e.g., 500,000+ users), this approach can cause small lags even with startTransition helping to defer updates.

    • Initial Load Time

      On initial page load, the app instantly generates and holds 100,000 users in memory. There's no lazy-loading or pagination. This increases the time to first interaction (TTI), which can affect mobile and slower devices noticeably.


    Overall Conclusion:

    This library is great for small to lower-mid scale projects where datasets are not overwhelmingly large. For larger, production-grade projects with very large datasets, I would not recommend this approach without additional optimizations like server-side filtering, pagination, or smarter data handling.

    My code:

    "use client"
    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"];
      const skills = ["React", "Python", "Node.js", "Django", "Rust", "Go", "Flutter", "AWS"];
    
      return Array.from({ length: count }, (_, i) => ({
        id: i,
        name: `${names[i % names.length]} ${i}`,
        skill: skills[i % skills.length],
      }));
    };
    
    const usersData = generateUsers(100000);
    
    export default function MassiveSkillSearch() {
      const [nameQuery, setNameQuery] = useState("");
      const [skillQuery, setSkillQuery] = useState("");
      const [isPending, startTransition] = useTransition();
      const [searchDuration, setSearchDuration] = useState<number>(0);
    
      const filteredUsers = useMemo(() => {
      const start = performance.now();
    
      const lowerName = nameQuery.toLowerCase();
      const lowerSkill = skillQuery.toLowerCase();
    
      const result = usersData.filter((user) => {
        const matchesName = user.name.toLowerCase().includes(lowerName);
        if (!matchesName) return false;
        if (lowerSkill) {
          return user.skill.toLowerCase().includes(lowerSkill);
        }
        return true;
      });
    
      const end = performance.now();
      setSearchDuration(end - start);
      return result;
    }, [nameQuery, skillQuery]);
    
    
      const handleNameSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
        const value = e.target.value;
        startTransition(() => setNameQuery(value));
      };
    
      const handleSkillSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
        const value = e.target.value;
        startTransition(() => setSkillQuery(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-green-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-green-100 hover:bg-green-50"
          >
            <p className="font-medium text-gray-800 text-base">
              {highlight(user.name, nameQuery)}
            </p>
            <p className="text-sm text-gray-500">
              {highlight(user.skill, skillQuery)}
            </p>
          </div>
        );
      };
    
      return (
        <div className="min-h-screen bg-gray-100 p-6 font-sans">
          <div className="max-w-4xl mx-auto bg-white p-6 rounded-2xl border-2 border-green-200 shadow-md">
            <h1 className="text-2xl font-bold text-green-600 mb-4">
              🧠 Skillset Search Filter (Virtualized)
            </h1>
    
            <input
              type="text"
              value={nameQuery}
              onChange={handleNameSearch}
              placeholder="Search by name..."
              className="w-full p-3 mb-4 border-2 border-green-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-400"
            />
    
            <input
              type="text"
              value={skillQuery}
              onChange={handleSkillSearch}
              disabled={!nameQuery}
              placeholder="Search by skill (enter name first)"
              className="w-full p-3 mb-6 border-2 border-green-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-400 disabled:bg-gray-200"
            />
    
            {isPending && (
              <div className="text-green-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-green-100"
              >
                {Row}
              </List>
            )}
          </div>
          <div className="mt-6 text-sm text-gray-600 text-center">
      <p>Total users: {usersData.length}</p>
      <p>Filtered users: {filteredUsers.length}</p>
      <p>Last search took: {searchDuration.toFixed(2)} ms</p>
      <p>{isPending ? "Searching..." : "Idle"}</p>
    </div>
        </div>
      );
    }
    
    Enter fullscreen mode Exit fullscreen mode
Add comment