Skip to content

Loading async data

Fetch a customer. Spinner while it loads. Error if it fails. Card when it arrives. Never let a slow response for an old id clobber a new one.

Trivial feature. In React it’s the #1 place people ship a race condition.


import { useState, useEffect } from "react"
import { fetchCustomer, type Customer } from "./api"
export function CustomerCard({ id }: { id: string }) {
const [data, setData] = useState<Customer | null>(null)
const [isPending, setIsPending] = useState(true)
const [error, setError] = useState<unknown>(null)
useEffect(() => {
const controller = new AbortController()
setIsPending(true)
setError(null)
fetchCustomer(id, controller.signal)
.then((c) => {
if (controller.signal.aborted) return
setData(c); setIsPending(false)
})
.catch((err) => {
if (controller.signal.aborted) return
setError(err); setIsPending(false)
})
return () => controller.abort()
}, [id])
if (isPending) return <p><em>loading…</em></p>
if (error) return <p className="error">error: {String(error)}</p>
return (
<div className="card">
<p><strong>{data!.name}</strong>{data!.city}</p>
</div>
)
}

~38 lines. Five are the feature. The rest is ceremony you repeat in every async component — and get subtly wrong under pressure.

import { fetchCustomer } from "./api"
export component CustomerCard {
param id: string
resource customer(id) signal => fetchCustomer(id, signal)
view {
await(customer) {
<div className="card">
<p><strong>{customer.data?.name}</strong> — {customer.data?.city}</p>
</div>
} pending {
<p><em>loading…</em></p>
} error(e) {
<p className="error">error: {String(e)}</p>
}
}
}
Compiled React 19 — this is the file in your repo
import { fetchCustomer } from "./api"
import { Suspense, use, useEffect } from "react"
import { ErrorBoundary, useResource } from "@reactra/resource"
import { useRoute } from "@reactra/router"
export const CustomerCard = () => {
const __route = useRoute()
const id = __route.params.id
const customer = useResource(id, (id, { signal }) => (fetchCustomer(id, signal)), { resourceName: "customer" })
useEffect(() => { const h = globalThis.__REACTRA_TEST__; if (h) h.update("CustomerCard", { customer }) })
return (<>
<ErrorBoundary fallback={(e) => <><p className="error">error: {String(e)}</p></>}><Suspense fallback={<><p><em>loading…</em></p></>}>{(() => { const __AwaitInner = () => { use(customer.promise); return <><div className="card">
<p><strong>{customer.data?.name}</strong>{customer.data?.city}</p>
</div></> }; __AwaitInner.displayName = "Await(customer)"; return <__AwaitInner /> })()}</Suspense></ErrorBoundary>
</>)
}
export default CustomerCard

~14 lines. Every one is the feature.


  • 3 useStates for one value → 1 resource
  • The race (slow response for an old id overwriting the new) → handled by signal
  • The dependency array you keep getting wrong → it’s the (id) in the signature
  • if pending … if error … return data! → the structural await / pending / error
  • Cleanup, abort plumbing, the data! assertion → gone

Same screen. None of the machinery.


Reactra is a compiler. That component compiles at build time to ordinary React 19 — React.use, <Suspense>, an error boundary — the exact code you’d hand-write. Open the output; it reads like React, because it is.

You write what the screen is. The compiler writes the part where the bugs live.