Skip to Content
ExamplesAsync React demo

Async React demo

The final demo from Rick Hanlon’s React Conf 2025 Async React talk (rickhanlonii/async-react , live at async-react.dev ), rebuilt on react-rx + RxJS. The talk’s thesis: with routing that runs in transitions, suspense-by-default data fetching, and design components that own their pending/optimistic feedback through action props, product code stays simple and declarative — and the app automatically adapts to network speed.

Things to try (open the network debugger at the bottom of the preview):

  • Fast network (all delays at 0): log out and back in, switch tabs, search, toggle lessons — nothing ever looks “loading”. The pending shimmers technically exist, but a CSS animation-delay keeps them invisible for 300ms (1.5s on search), so fast actions finish first.
  • Slow down /lessons (~1500ms): tab switches now shimmer the optimistic tab, searching shimmers the input while showing your keystrokes immediately (useOptimistic), and the list only shows skeletons on first load — transitions keep old results visible for every later change.
  • Slow down /login and /lessons, then log out and in: the login button stays pending through the POST and the prefetch. Under 1s of lessons latency you land on a fully-loaded home screen; above it, login navigates anyway and the Suspense fallback takes over — that’s the Promise.race in prefetchLessons.
  • Slow down /lesson/:id/toggle and complete a lesson on the “In progress” tab: the checkmark flips instantly (optimistic), the button shimmers after 300ms, and when the mutation and refetch land, the item leaves the list — updated in place, no fallback.
import './demo.css'
import Home from './Home'
import Login from './Login'
import NetworkDebugger from './NetworkDebugger'
import {Router, useRouter} from './router'

/**
 * The React Conf 2025 "Async React" demo (github.com/rickhanlonii/async-react)
 * rebuilt on react-rx + RxJS. Product code stays declarative: routing runs
 * in transitions, data reads suspend by default, design components own
 * their pending/optimistic feedback through `action` props — and the data
 * layer is streams, so revalidations update visible lists in place.
 *
 * Try it: open the network debugger at the bottom, give /lessons some
 * latency, and log in again. Under ~150ms nothing ever looks "loading".
 */
function Screen() {
  const router = useRouter()

  if (router.url === '/login') {
    return <Login />
  }
  return (
    <>
      <header className="lesson">
        <strong>Course Lessons</strong>
        {/* Log out is a plain transition navigation, so you can replay the
            login flow with different latencies. */}
        <button
          type="button"
          className="outline"
          onClick={() =>
            router.navigate('/login')
          }
        >
          Log out
        </button>
      </header>
      <Home />
    </>
  )
}

export default function App() {
  return (
    <Router>
      <Screen />
      <hr />
      <NetworkDebugger />
    </Router>
  )
}

Open on CodeSandboxOpen Sandbox

What react-rx changes

The React side is untouched from the original — the same useTransition / useOptimistic / <Suspense> patterns, the same action-prop design components, the same transition router. The data layer is where streams take over (api.ts):

  • The original’s cache-of-promises plus revalidate() (clear everything, re-render, re-fetch) becomes revalidate-driven streams: lessons$(tab, search) is a cached observable that re-fetches when revalidate$ fires. Because useObservablePromise updates in place after the first emission, revalidations can never re-trigger a Suspense fallback — a guarantee the original had to engineer around.
  • prefetchLessons() is preloadObservablePromise raced against a 1s timeout — the same promise the home screen’s hook reads, warmed before navigation.
  • Returning to a previously-seen tab replays the last result instantly while a fresh fetch streams in behind it (shareReplay + startWith): stale-while-revalidate in two operators.
  • The network debugger is streams end-to-end: the delay knobs are BehaviorSubjects the fetch layer reads, and the request log is one scan over request events, read with useObservable.

Route state, by contrast, deliberately stays in React state: only React state updates can be marked as transitions, which is exactly what navigation wants. Streams for server data, transitions for view state — each tool where it’s strongest.

One omission: the original animates with React’s <ViewTransition>, which is still experimental-only — this port runs on stable React, so those animations are left out.

Last updated on