Skip to main content

Fetch data in React without the boilerplate.

One hook. One component. Your data is T, not T | undefined.
Loading, error, success — always one state, always type-safe.

The old way
function UserProfile({ id }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetchUser(id)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [id]);
if (loading) return <Spinner />;
if (error) return <p>Something broke</p>;
return <h1>{user.name}</h1>;
// user is User | null. Oops.
}
use-remote-data
function UserProfile({ id }) {
const userStore = useRemoteData(
() => fetchUser(id), { dependencies: [id] }
);
return (
<Await store={userStore}>
{(user) => <h1>{user.name}</h1>}
{/* user is User. Always. */}
</Await>
);
}

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 seconds
refresh: 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 list
refreshes: [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 mock
const 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.

Stop guessing.

Get Startednpm install use-remote-data