Steve Kinney

Concurrent React and Scheduling

React 18 introduced concurrent features that fundamentally change how React processes updates. Instead of blocking the main thread until every component renders, React can now pause and resume work—keeping your app responsive even during expensive operations. This isn’t just about making things faster; it’s about making the right things fast and ensuring user interactions never feel sluggish.

Understanding React’s scheduler helps you write components that cooperate with the system rather than fight against it. You’ll learn to distinguish between urgent and non-urgent work, leverage built-in prioritization, and design interfaces that feel smooth regardless of the complexity happening underneath.

How React’s Scheduler Works

Before concurrent features, React’s rendering was like a single-lane highway—once React started rendering, it couldn’t stop until the entire component tree was complete. If you had a heavy component that took 200ms to render, your entire UI would freeze for that duration.

React’s concurrent scheduler introduces time-slicing and priority-based scheduling. Think of it as upgrading from a single-lane highway to a smart traffic system with multiple lanes, priority rules, and the ability to pause lower-priority traffic when emergency vehicles need to pass.

Here’s the key insight: not all updates are created equal. A user clicking a button expects immediate feedback, while updating a data visualization can wait a few milliseconds. The scheduler uses this priority hierarchy:

// From React's internal priority levels (simplified)
enum Priority {
  ImmediatePriority = 1, // User input, focus events
  UserBlockingPriority = 2, // Click handlers, form inputs
  NormalPriority = 3, // Data fetching, network requests
  LowPriority = 4, // Analytics, logging
  IdlePriority = 5, // Background cleanup tasks
}

The scheduler can interrupt lower-priority work when higher-priority work arrives, ensuring your app stays responsive to user interactions.

Understanding Transitions

The most practical way to leverage React’s scheduler is through transitions. A transition marks an update as non-urgent, allowing React to interrupt it for more important work.

import { useTransition, useState } from 'react';

function SearchResults() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleSearch = (searchQuery: string) => {
    setQuery(searchQuery); // Urgent: update input immediately

    startTransition(() => {
      // Non-urgent: expensive filtering can be interrupted
      const filtered = expensiveSearch(searchQuery);
      setResults(filtered);
    });
  };

  return (
    <div>
      <input
        value={query}
        onChange={e => handleSearch(e.target.value)}
        placeholder="Search..."
      />
      {isPending && <div>Searching...</div>}
      <ResultsList results={results} />
    </div>
  );
}

This pattern keeps the input responsive—users see their typing immediately—while the expensive search operation happens in the background and can be interrupted if they keep typing.

Deferred Values for Expensive Computations

Sometimes you don’t control the state update (maybe it’s coming from a parent component), but you still want to defer expensive work. useDeferredValue lets you create a “delayed” version of a value that lags behind during rapid updates.

import { useDeferredValue, useMemo } from 'react';

function ExpensiveChart({ data }: { data: number[] }) {
  const deferredData = useDeferredValue(data);

  const chartElements = useMemo(() => {
    // This expensive computation uses the deferred value
    return deferredData.map((value, index) => (
      <ChartBar key={index} value={value} />
    ));
  }, [deferredData]);

  return (
    <div>
      {/* Show a hint that data is updating */}
      {data !== deferredData && (
        <div className="opacity-50">Updating chart...</div>
      )}
      <div className="chart">{chartElements}</div>
    </div>
  );
}

When data changes rapidly, deferredData will lag behind, preventing expensive chart recalculations on every keystroke. React will batch the updates and process them when it has spare time.

Avoiding Common Scheduling Pitfalls

Don’t Wrap Everything in Transitions

Transitions are for non-urgent updates that can be interrupted. Don’t wrap urgent interactions like button clicks or form submissions:

// ❌ Bad: Makes button feel unresponsive
function Button({ onClick }: { onClick: () => void }) {
  const [, startTransition] = useTransition();

  return (
    <button
      onClick={() => startTransition(onClick)}
    >
      Submit
    </button>
  );
}

// ✅ Good: Button responds immediately
function Button({ onClick }: { onClick: () => void }) {
  return (
    <button onClick={onClick}>
      Submit
    </button>
  );
}

Be Mindful of Transition Boundaries

Transitions only affect the updates inside the startTransition callback. Updates outside remain urgent:

// ❌ This won't work as expected
function SearchComponent() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [, startTransition] = useTransition();

  const handleSearch = (newQuery: string) => {
    setQuery(newQuery); // Still urgent!

    startTransition(() => {
      const filtered = expensiveFilter(newQuery);
      setResults(filtered); // Only this is in the transition
    });
  };
}

If you want both updates to be part of the transition, include them both:

// ✅ Both updates are now non-urgent
const handleSearch = (newQuery: string) => {
  startTransition(() => {
    setQuery(newQuery);
    const filtered = expensiveFilter(newQuery);
    setResults(filtered);
  });
};

Real-World Scheduling Patterns

Progressive Enhancement Pattern

Show immediate feedback for urgent updates while deferring expensive secondary work:

function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [recommendations, setRecommendations] = useState<User[]>([]);
  const [, startTransition] = useTransition();

  useEffect(() => {
    // Urgent: Load user data immediately
    fetchUser(userId).then(setUser);

    // Non-urgent: Recommendations can wait
    startTransition(() => {
      fetchRecommendations(userId).then(setRecommendations);
    });
  }, [userId]);

  return (
    <div>
      {user ? (
        <UserCard user={user} />
      ) : (
        <UserCardSkeleton />
      )}

      {recommendations.length > 0 && (
        <RecommendationsList users={recommendations} />
      )}
    </div>
  );
}

Optimistic Updates with Fallback

Combine transitions with optimistic updates for smooth interactions:

function TodoItem({ todo, onToggle }: { todo: Todo; onToggle: (id: string) => void }) {
  const [isOptimistic, setIsOptimistic] = useState(false);
  const [, startTransition] = useTransition();

  const handleToggle = () => {
    setIsOptimistic(true); // Immediate visual feedback

    startTransition(() => {
      onToggle(todo.id); // The actual update can be deferred
      setIsOptimistic(false);
    });
  };

  return (
    <div
      className={`todo-item ${isOptimistic ? 'updating' : ''}`}
      onClick={handleToggle}
    >
      <Checkbox checked={todo.completed} />
      <span>{todo.text}</span>
    </div>
  );
}

Working with External Libraries

Not all libraries are concurrent-ready. Some perform synchronous operations that can’t be interrupted:

// ❌ Problematic: Heavy synchronous work
function DataGrid({ data }: { data: any[] }) {
  const deferredData = useDeferredValue(data);

  // This heavy calculation still blocks the main thread
  const processedData = heavySyncProcessing(deferredData);

  return <Grid data={processedData} />;
}

// ✅ Better: Break work into chunks
function DataGrid({ data }: { data: any[] }) {
  const deferredData = useDeferredValue(data);
  const [processedData, setProcessedData] = useState([]);

  useEffect(() => {
    // Process data in chunks with yielding
    processDataInChunks(deferredData, setProcessedData);
  }, [deferredData]);

  return <Grid data={processedData} />;
}

async function processDataInChunks(data: any[], setResult: (data: any[]) => void) {
  const chunks = chunkArray(data, 100);
  const results = [];

  for (const chunk of chunks) {
    const processed = processChunk(chunk);
    results.push(...processed);

    // Yield control back to the browser
    await new Promise(resolve => setTimeout(resolve, 0));
    setResult([...results]);
  }
}

Performance Monitoring and Debugging

React DevTools Profiler helps you understand how your transitions behave:

// Add labels to make transitions easier to identify in DevTools
function SearchResults() {
  const [, startTransition] = useTransition();

  const handleSearch = (query: string) => {
    startTransition(() => {
      // This label appears in React DevTools
      React.unstable_trace('search-filtering', () => {
        const results = expensiveSearch(query);
        setResults(results);
      });
    });
  };
}

You can also measure transition performance in production:

import { unstable_trace } from 'react';

function trackTransitionPerformance(name: string, fn: () => void) {
  const start = performance.now();

  unstable_trace(name, () => {
    fn();

    // Log timing data to your analytics
    const duration = performance.now() - start;
    analytics.track('transition-performance', { name, duration });
  });
}

When Not to Use Concurrent Features

Concurrent features aren’t always the answer. Avoid them when:

  • Critical business logic: Don’t defer validation or security checks
  • Simple, fast updates: The overhead isn’t worth it for trivial operations
  • External timing requirements: Some operations need precise timing control
// ❌ Don't defer critical validation
function PaymentForm() {
  const [, startTransition] = useTransition();

  const handleSubmit = (data: PaymentData) => {
    startTransition(() => {
      validatePayment(data); // This should be immediate!
      submitPayment(data);
    });
  };
}

// ✅ Keep validation urgent, defer non-critical work
function PaymentForm() {
  const [, startTransition] = useTransition();

  const handleSubmit = async (data: PaymentData) => {
    const isValid = await validatePayment(data); // Urgent
    if (!isValid) return;

    await submitPayment(data); // Also urgent

    // Non-urgent: Analytics and cleanup can wait
    startTransition(() => {
      trackPaymentSuccess(data);
      clearFormCache();
    });
  };
}

Next Steps

React’s concurrent features give you fine-grained control over when work happens, but they’re tools that require thoughtful application. Start by identifying the expensive operations in your app—data processing, complex renders, or heavy computations—and experiment with wrapping them in transitions.

The goal isn’t to make everything concurrent, but to ensure user interactions always feel responsive while non-critical work happens in the background. Your users will notice the difference, even if they can’t quite put their finger on why your app feels more polished than the competition.

Last modified on .