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: product code stays simple and declarative when three things hold. Routing runs in transitions. Data fetching suspends by default. And design components own their pending/optimistic feedback through action props. The app then adapts to network speed on its own.

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). Fast actions finish first.
  • Slow down /lessons (~1500ms): tab switches now shimmer the optimistic tab. Searching shimmers the input while your keystrokes appear immediately (useOptimistic). 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. 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. 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. Same useTransition, useOptimistic, and <Suspense> patterns. Same action-prop design components. Same transition router.

The data layer is where streams take over (api.ts):

  • Revalidation can’t cause fallbacks. The original pairs a cache-of-promises with revalidate(): clear everything, re-render, re-fetch. Here, lessons$(tab, search) is a cached observable that re-fetches when revalidate$ fires. useObservablePromise updates in place after the first emission, so a revalidation can never re-trigger a Suspense fallback. The original had to engineer around that risk.
  • Prefetching is one call. prefetchLessons() is preloadObservablePromise raced against a 1s timeout. It warms the same promise the home screen’s hook reads.
  • Stale-while-revalidate for free. Returning to a previously-seen tab replays the last result instantly, while a fresh fetch streams in behind it. That’s shareReplay plus startWith.
  • The network debugger is streams end-to-end. The delay knobs are BehaviorSubjects the fetch layer reads. The request log is one scan over request events, read with useObservable.

Route state deliberately stays in React state. Only React state updates can be marked as transitions, and that 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