Skip to Content
ExamplesTransitions and refetching

Transitions and refetching

Swapping which observable a live component renders follows React’s client-side refetch pattern : change the data source inside startTransition or behind useDeferredValue, and the previous content stays visible until the new data is ready. No Suspense fallback.

A suspended transition render never commits, and commit is otherwise what starts a fetch. So for exactly this case useObservablePromise starts the swapped-in source during the transition render itself. A consumer that is already live, meaning committed, visible, and subscribed, re-rendering with a new observable identity starts the fetch; the suspended transition settles and the swap commits. Mounts, server rendering, disabled consumers, and hidden <Activity> pre-renders have no live subscription and stay fully lazy. Rendering alone still never fetches for them.

Try it:

  1. Click Grace. The pending timer runs for about 1.5s while Ada stays on screen, then the swap commits. No preloading, no fallback flash.
  2. Click Ada again. Settled observables are retained (ttl: 60_000 here), so swapping back commits instantly.
  3. Click Reset demo. A fresh mount is not a swap: the initial fetch starts at the hook caller’s commit while the Suspense fallback shows.
import {
  Suspense,
  useEffect,
  useState,
  useTransition,
} from 'react'
import {useObservablePromise} from 'react-rx'

import {
  fetchProfile$,
  resetProfileCache,
} from './api'
import ProfileCard from './ProfileCard'

const NAMES = ['Ada', 'Grace', 'Alan'] as const

function Spinner() {
  return <p style={{opacity: 0.7}}>🌀 Loading…</p>
}

/**
 * Mounted only while a transition is pending — the window where the
 * swapped-in profile is still fetching and the previous one stays visible.
 */
function PendingTimer() {
  const [elapsed, setElapsed] = useState(0)

  useEffect(() => {
    const startedAt = Date.now()
    const id = setInterval(() => {
      setElapsed(Date.now() - startedAt)
    }, 100)
    return () => clearInterval(id)
  }, [])

  return (
    <>
      ⏳ transition pending for{' '}
      {(elapsed / 1000).toFixed(1)}s…
    </>
  )
}

function TransitionStatus({
  pending,
}: {
  pending: boolean
}) {
  return (
    <p
      style={{
        minHeight: '1.5em',
        margin: '8px 0',
      }}
    >
      {pending ? (
        <PendingTimer />
      ) : (
        'idle — no transition pending'
      )}
    </p>
  )
}

function ProfileSwitcher() {
  const [name, setName] =
    useState<(typeof NAMES)[number]>('Ada')
  const [isPending, startTransition] =
    useTransition()
  // The Map-stable identity is what routes every render to the same cache
  // entry; the long ttl keeps settled profiles retained for the whole demo
  // session so swapping back commits instantly.
  const promise = useObservablePromise(
    fetchProfile$(name),
    {ttl: 60_000},
  )

  return (
    <>
      <div style={{display: 'flex', gap: 8}}>
        {NAMES.map((candidate) => (
          <button
            key={candidate}
            type="button"
            onClick={() => {
              // React's canonical refetch pattern, no preloading required:
              // this consumer is live, so the transition render that swaps in
              // the new observable also starts its fetch. The old profile
              // stays visible while it loads, and the swap commits when the
              // fetch settles.
              startTransition(() => {
                setName(candidate)
              })
            }}
            style={{
              fontWeight:
                candidate === name ? 700 : 400,
            }}
          >
            {candidate}
          </button>
        ))}
      </div>
      <TransitionStatus pending={isPending} />
      <Suspense fallback={<Spinner />}>
        <ProfileCard promise={promise} />
      </Suspense>
    </>
  )
}

export default function App() {
  const [epoch, setEpoch] = useState(0)

  return (
    <div
      style={{
        fontFamily: 'system-ui',
        padding: 16,
        maxWidth: 480,
      }}
    >
      <h2 style={{marginTop: 0}}>
        Swap observables inside a transition
      </h2>
      <p style={{fontSize: 14}}>
        Each profile fetch takes ~1.5s. Switching
        profiles inside a transition keeps the
        previous profile visible while the next
        one loads — only the initial mount shows
        the Suspense fallback.
      </p>
      <button
        type="button"
        onClick={() => {
          resetProfileCache()
          setEpoch((n) => n + 1)
        }}
        style={{marginBottom: 12}}
      >
        Reset demo (forget all profiles)
      </button>
      <ProfileSwitcher key={epoch} />
    </div>
  )
}

Open on CodeSandboxOpen Sandbox

The rules that still apply

  • Identities must be stable. react-rx keys its promise cache by observable identity, so every render asking for the same data must receive the same instance. That is what the Map in fetchProfile$ is for. A factory creating a fresh observable per call gives every render its own entry.
  • The <Suspense> boundary sits between the hook caller and the use() reader. The hook caller must be able to commit, or already be live. use()-ing the hook’s own promise in the same component still deadlocks, exactly like use()-ing a promise created during your own render.
  • Preloading is now an optimization, not a requirement. preloadObservablePromise on hover or in a route loader means the swap target can already be in flight, or even settled, by the time the transition renders. That shortens or removes the pending period. See Activity and preload.
  • An abandoned transition may have started a fetch nobody consumes. It settles into the shared cache and stays reusable within ttl. Cap never-settling sources with RxJS timeout, just as you would for preloads.
Last updated on