Ask
176
@bobbin_bind ·

useSuspenseQuery drops the whole page to the root fallback instead of the card Suspense

I have a stats card that fetches its own numbers with useSuspenseQuery. I wrapped it like this:

function StatsCard() {
  const { data } = useSuspenseQuery({ queryKey: ['stats'], queryFn })
  return <Suspense fallback={<Skeleton />}><Card data={data} /></Suspense>
}

On first load the entire page goes blank and shows the route-level spinner for about 700ms, then everything appears at once. I expected the rest of the page to render immediately and just this card to skeleton.

Next 15.3, React 19, the card is in a 'use client' file. What is wrong with the boundary?

10 answers Share
Report

Answering anonymously — a moderator will review it first.

  • @sdcard_sid · 2mo ago · 3 replies

    The boundary is inside the component that suspends, so it never gets a chance to render. React throws at the useSuspenseQuery call, which happens before your JSX is returned, so it walks up the tree looking for a boundary and finds your route-level one. A <Suspense> in the returned JSX of the suspending component is decoration.

    Split it in two:

    function StatsCard() {
      return <Suspense fallback={<Skeleton />}><StatsCardInner /></Suspense>
    }
    function StatsCardInner() {
      const { data } = useSuspenseQuery({ queryKey: ['stats'], queryFn })
      return <Card data={data} />
    }
    

    Rule of thumb that has never let me down: the hook and the boundary must live in different components, and the boundary must be the parent.

    158
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @bobbin_bind · last mo.

      That fixed it in about ninety seconds. Slightly annoyed at how obvious it is once written down - of course the throw happens before the return.

      47
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @flashcard_fen · 2mo ago

      The inner/outer split is also where you want your error boundary, for the same reason. Pair them and you can copy the two-component pattern everywhere without thinking about it.

      39
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @torque_spec · 2mo ago

    Worth checking whether you also have a loading.tsx in that route segment, because that is the boundary you are currently hitting. It is convenient for the initial route transition and it is completely the wrong granularity for a single card - once every leaf has its own boundary, the segment-level one should be showing up rarely.

    64
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @clickertrainbee · last mo. · 3 replies

    Once you have the boundary in the right place, the next surprise is updates. Change the query key - swap the date range on that card - and you will get the skeleton flashing back over content that was already on screen. Wrap the state change in startTransition and React keeps the old UI up while the new data loads instead of tearing it down. useTransition also hands you an isPending you can use to dim the card.

    92
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
    • @clickertrainbee · 2mo ago

      Any state update that causes a suspending component to suspend again. Wrap the setDateRange call in startTransition and React keeps the previous tree mounted until the new data resolves. The fallback only comes back for content that was never on screen in the first place.

      43
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
    • @bobbin_bind · 2mo ago

      Does that apply to changing the key on a suspense query specifically, or only to route transitions? I had assumed the fallback flash was unavoidable when the key changes.

      31
      Share
      Reply

      Answering anonymously — a moderator will review it first.

      Report
  • @mulch_marta · 2mo ago

    Also make sure the card is actually being streamed rather than blocking. If a parent server component awaits something before rendering the client card, nothing downstream can flush early no matter how many boundaries you place. The clue is that the whole page appears at once after 700ms rather than in two stages, which is what you described.

    48
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @film_fridge · 2mo ago

    Minor thing that bit us: with useSuspenseQuery there is no isLoading to branch on, and data is non-nullable, which is lovely until somebody writes a conditional early return above the hook and then the whole thing explodes on the second render. Keep the inner component boring - hook, then JSX, nothing else.

    35
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report
  • @sam_the_temp · 2mo ago

    For anyone finding this later: same thing applies to use(). The boundary has to be above the component that calls it, not returned by it.

    21
    Share
    Reply

    Answering anonymously — a moderator will review it first.

    Report