reactseverity: workaround
#300

React Error #300: A component suspended while responding to synchronous input

Component suspended on synchronous input

90% fixable~15 mindifficulty: advanced

Verified against React 19 source (ReactFiberWorkLoop.js), React docs: startTransition, React docs: Suspense · Updated May 2026

> quick_fix

A component called a Suspense-aware data hook (use, useSWR with suspense, React Query suspense) inside a synchronous update path. Wrap the state setter that triggered the render in startTransition, or hoist the data fetch into a parent Suspense boundary so the loading fallback handles it.

import { startTransition, useState } from 'react'

function SearchBox() {
  const [query, setQuery] = useState('')
  return (
    <input onChange={(e) => {
      // Wrap to allow suspending
      startTransition(() => setQuery(e.target.value))
    }} />
  )
}

// And ensure a Suspense ancestor exists
<Suspense fallback={<Spinner />}>
  <SearchResults query={query} />
</Suspense>

What causes this error

When a component throws a Promise (the Suspense protocol), React looks for a Suspense ancestor and shows its fallback. But if the update was triggered by synchronous user input (typing, clicking) without startTransition, React refuses to swap to a fallback - that would cause UI to disappear under the user's cursor. Instead it throws #300 to force you to wrap the update or restructure the data dependency.

> advertisementAdSense placeholder

How to fix it

  1. 01

    step 1

    Identify the suspending hook

    The trace points at the component that suspended. Look for use(promise), useSuspenseQuery, useSWR with { suspense: true }, or any custom hook that throws a Promise.

    // Suspending hooks - any of these can trigger #300
    const data = use(somePromise)
    const result = useSuspenseQuery(...)
    const value = useSWR(key, fetcher, { suspense: true })
  2. 02

    step 2

    Wrap the triggering update in startTransition

    Synchronous events (onChange, onClick) cannot suspend. Wrap the setState call in startTransition to mark it as non-urgent, allowing React to show the previous UI while the new render suspends.

    import { startTransition } from 'react'
    
    function Filter() {
      const [filter, setFilter] = useState('')
      return (
        <input onChange={(e) => {
          const v = e.target.value
          startTransition(() => setFilter(v))
        }} />
      )
    }
  3. 03

    step 3

    Add a Suspense boundary above the suspending component

    Even with startTransition, you need a Suspense ancestor for the eventual fallback. Place it above the component that reads the suspending data.

    <Suspense fallback={<TableSkeleton />}>
      <FilteredTable filter={filter} />
    </Suspense>
  4. 04

    step 4

    Move data dependencies above the input

    If the component that uses the data also handles the input, you have a self-suspending update. Split into a controlled-input parent and a data-reading child, with Suspense between them.

    // Bad - input and data in same component
    function Combined() {
      const [q, setQ] = useState('')
      const data = useSuspenseQuery(q)  // suspends on every keystroke
    }
    
    // Good - split
    function Container() {
      const [q, setQ] = useState('')
      return <>
        <Input onChange={(e) => startTransition(() => setQ(e.target.value))} />
        <Suspense fallback={<Spinner />}>
          <Results query={q} />
        </Suspense>
      </>
    }
  5. 05

    step 5

    Use useDeferredValue for cheap throttling

    useDeferredValue lets React render the old value while computing the new one, automatically deferring the suspending render. Less code than startTransition for input-driven cases.

    import { useDeferredValue } from 'react'
    
    function Container() {
      const [q, setQ] = useState('')
      const deferredQ = useDeferredValue(q)
      return <>
        <input onChange={(e) => setQ(e.target.value)} />
        <Suspense fallback={<Spinner />}>
          <Results query={deferredQ} />  {/* old value while new is loading */}
        </Suspense>
      </>
    }

Why #300 happens at the runtime level

React's reconciler tracks a 'lane' for every update. Synchronous lanes (originating from user-event handlers) cannot show a Suspense fallback because doing so would visually replace UI the user is interacting with. When a component throws a Promise during a sync-lane render, ReactFiberWorkLoop.js detects the suspension, checks the lane priority, and finds it incompatible with showing a fallback. It then throws invariant #300 instead of completing the render. Wrapping in startTransition reclassifies the update to the transition lane, which is allowed to suspend and show fallbacks because the previous render remains visible while the new one is computed.

Common debug mistakes for #300

  • Calling useSuspenseQuery directly inside an onChange handler-driven component without a transition.
  • Forgetting to add a Suspense ancestor; startTransition alone isn't enough if no fallback is registered.
  • Migrating from useEffect-fetched state to use(promise) without restructuring the input-handling components.
  • Suspending in a portal that's mounted by a synchronous event (modal open showing async-loaded content).
  • Using flushSync inside an onChange that also reads suspending data, forcing synchronous evaluation.

When #300 signals a deeper problem

Hitting #300 repeatedly means the component tree mixes input-handling and data-reading at the same level. The architectural fix is to split components by concern: parent owns input state, child reads data, Suspense between them. Adopt the 'render-as-you-fetch' pattern where data dependencies are kicked off above the suspending component, not inside it. Frameworks like Next.js and Relay enforce this at the routing layer. The deeper signal is that switching to Suspense-style data fetching changes how you compose components; a codebase migrated halfway from useEffect to use() will hit #300 until the boundary discipline catches up.

Frequently asked questions

Why does React reject suspending on synchronous input?

If a component suspends during a synchronous update, the only way to render is to show the Suspense fallback in place of the current UI. For events triggered by user input (typing, clicking), this means the UI under the user's cursor disappears mid-interaction, which feels broken. React's choice is to throw #300 instead, forcing the developer to either mark the update as a transition (acknowledging the old UI stays visible) or restructure so the suspending data isn't read in the synchronous-input render path. This decision is documented in the concurrent-rendering RFC.

When should I use startTransition vs useDeferredValue?

startTransition wraps an update so that any rendering it triggers is non-urgent - useful when you control the setState call site. useDeferredValue accepts a value and returns a 'lagging' copy of it, useful when the value comes from props or a parent component you don't own. Practical rule: if the suspending render is triggered by your component's own state, use startTransition at the setState call. If the suspending render is downstream of a value you receive, use useDeferredValue at the consumer.

Does Suspense for data require a framework?

In React 19, the use() hook can read promises and integrates with Suspense without a framework. Frameworks like Next.js App Router add server-side suspense boundaries, streaming HTML, and request deduplication. For client-only suspense, libraries like React Query (suspense: true), SWR (suspense: true), and Relay all interoperate with React's Suspense protocol. The error #300 is independent of which library; the rule applies to any hook that throws a Promise.

Why doesn't onClick suspend cleanly when onChange does?

Both can trigger #300. The difference users see is that onChange fires more frequently (every keystroke), making the error more visible. Both are 'synchronous user input' from React's perspective and both require startTransition to permit suspending. A common pattern that hits this: clicking a 'Refresh' button that calls a data refetch hook that throws a Promise. Wrap the click handler's setState in startTransition, or move the data read to a useEffect-triggered fetch.

disclosure:Errordex runs AdSense, has zero third-party affiliate or sponsored links, and occasionally links to the editor’s own paid digital products (clearly labelled). Every fix is manually verified against official sources listed in the “sources” sidebar. If a fix here didn’t work for you, please email so we can update the page.