Inside Await, your data is never undefined.
The Await component narrows the type. Inside the callback, your data is the success type — no null checks, no optional chaining, no guessing.
<Await store={userStore}>{(user) => (// user is User — never undefined,// never null, never loading.<div><h1>{user.name}</h1><p>{user.email}</p></div>)}</Await>
Install. Import. Done.
No providers, no context, no config. One hook gives you a store. One component renders it. That's the entire API.
import { useRemoteData, Await } from "use-remote-data";function UserProfile({ id }) {const store = useRemoteData(() => fetch(`/api/users/${id}`).then(r => r.json()),{ dependencies: [id] });return (<Await store={store}loading={() => <Spinner />}>{(user) => <h1>{user.name}</h1>}</Await>);}
Errors are data, not exceptions.
When a request fails, the store moves to the Failed state — with the error and a retry callback. No try/catch. No error boundaries. No manual error state. Just a render prop.
<Await store={userStore}error={({ errors, retry }) => (<div><p>Something went wrong.</p><button onClick={retry}>Try again</button></div>)}>{(user) => <h1>{user.name}</h1>}</Await>
Retry only what broke.
When you combine three stores and one fails, retry() only re-fetches the failed request. The two successful stores keep their data. One button, surgical precision.
// When you combine three stores and one fails:const allStore = RemoteDataStore.all(userStore, postsStore, statsStore);// The combined store moves to "failed".// But retry() only re-fetches the broken one.// The two successful stores keep their data.//// One button. One click. Surgical retry.<Await store={allStore}error={({ retry }) => (<button onClick={retry}>Retry failed</button>)}>{([user, posts, stats]) => <Dashboard ... />}</Await>
Three API calls. One typed tuple.
Combine multiple stores into one with RemoteDataStore.all(). Still lazy, still type-safe, still with automatic retry.
const userStore = useRemoteData(() => fetchUser(id), { dependencies: [id] });const postsStore = useRemoteData(() => fetchPosts(id), { dependencies: [id] });const statsStore = useRemoteData(() => fetchStats(id), { dependencies: [id] });const allStore = RemoteDataStore.all(userStore, postsStore, statsStore);return (<Await store={allStore}>{([user, posts, stats]) => (<Dashboard user={user} posts={posts} stats={stats} />)}</Await>);
Data stays fresh automatically.
Tell the store how long data should live. It re-fetches in the background when data goes stale — your users keep seeing the old data while the new data loads. No spinners, no flicker.
const store = useRemoteData(() => fetchPrices(), {// Re-fetch every 30 secondsrefresh: RefreshStrategy.afterMillis(30_000),});// isStale is true while background// refresh is in progress — old data stays visible<Await store={store}>{(prices, isStale) => (<div style={{ opacity: isStale ? 0.6 : 1 }}><PriceTable prices={prices} /></div>)}</Await>
Mutations refresh reads.
After a successful write, the stores you depend on re-fetch automatically. No manual cache busting. No imperative refetch calls. Just declare what should refresh and it happens.
const todosStore = useRemoteData(() => fetchTodos());const addTodo = useRemoteUpdate((text) => api.addTodo(text), {// After a successful mutation,// automatically re-fetch the todo listrefreshes: [todosStore],});// addTodo.run("Buy milk")// → mutation fires// → on success, todosStore re-fetches// → Await re-renders with fresh data
Test without mocking.
Stores are values. Pass one to your component and assert what renders — no mock servers, no providers, no async coordination. RemoteDataStore.always() creates a store in any state you want. The same approach works for Storybook.
import { RemoteData, RemoteDataStore, Failure } from "use-remote-data";// A store that's already loaded — no fetch, no mockconst store = RemoteDataStore.always(RemoteData.success({ name: "Alice", email: "alice@ex.com" }));render(<UserCard store={store} />);expect(screen.getByText("Alice")).toBeInTheDocument();// Test loading? Errors? Same idea.const loading = RemoteDataStore.always(RemoteData.Pending);const failed = RemoteDataStore.always(RemoteData.Failed([Failure.unexpected(new Error("timeout"))],async () => {}));
Data lifetime follows component hierarchy.
Stores live in components, not in a global cache. Mount a page — its data fetches. Unmount — it's gone. No manual cache invalidation, no stale entries leaking across routes. Pass a store as a prop and every child shares the same fetch.
function UserPage({ id }) {// Store is created when UserPage mounts.// Fetches on first render. Caches while mounted.// Unmount UserPage → store is gone. No stale cache.const userStore = useRemoteData(() => fetchUser(id), { dependencies: [id] });// Pass the store down — child components// share the same fetch, same cache.return (<Layout><UserHeader store={userStore} /><UserPosts store={userStore} /></Layout>);}
Everything else you need
Zero dependencies, ~3.5kB gzipped
Just React. No runtime dependencies, no context providers, no bloat.
SSR ready
Pass server data as initial. No hydration boundaries.
Automatic cancellation
When deps change or a component unmounts, in-flight requests are aborted. Stale responses are always discarded.
Lazy by default
Stores only fetch when rendered. Define data dependencies upfront — only what mounts hits the network.
Mutations that refresh
First-class writes with useRemoteUpdate. After a successful mutation, dependent stores re-fetch automatically.
Typed errors
Separate domain errors from crashes. Validate with Zod, handle GraphQL unions — TypeScript knows which error you have.