Skip to main content

Testing

The advantage of values​

Most data-fetching libraries require mocking to test. You mock the HTTP layer, or wrap your component in a provider, or stub a global cache, and your test is more about the library's internals than your component's behavior.

use-remote-data doesn't have this problem. A component that takes a RemoteDataStore<T> doesn't know or care where the data came from. It's just a value, passed as a parameter. You construct the value, pass it in, and assert what renders.

No mocking. No providers. No test utilities. Just data.

RemoteDataStore.of(): a store in any state​

The key function for testing is RemoteDataStore.of(). It creates a store that's permanently in whatever state you give it:

import { RemoteData, RemoteDataStore } from 'use-remote-data';

// A store that has data
const loaded = RemoteDataStore.of(RemoteData.success({ name: 'Alice', email: 'alice@example.com' }));

// A store that's loading
const loading = RemoteDataStore.of(RemoteData.Pending);

// A store that hasn't started
const idle = RemoteDataStore.of(RemoteData.Initial);

That's it. These are real RemoteDataStore instances; your components can't tell the difference between these and a store created by useRemoteData. They render the same way, with the same types.

Testing a component​

Suppose you have a component that displays a user profile:

function UserCard({ store }: { store: RemoteDataStore<User> }) {
return (
<Await store={store}>
{(user) => (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
</div>
)}
</Await>
);
}

Testing every state is straightforward:

import { render, screen } from '@testing-library/react';
import { Failure, RemoteData, RemoteDataStore } from 'use-remote-data';

test('renders the user when data is available', () => {
const store = RemoteDataStore.of(RemoteData.success({ name: 'Alice', email: 'alice@example.com' }));
render(<UserCard store={store} />);
expect(screen.getByText('Alice')).toBeInTheDocument();
});

test('renders a spinner while loading', () => {
const store = RemoteDataStore.of(RemoteData.Pending);
render(<UserCard store={store} />);
expect(screen.getByText('Loading...')).toBeInTheDocument();
});

test('renders an error with retry', () => {
const retry = vi.fn().mockResolvedValue(undefined);
// Failure.unexpected() wraps a crash (like a network failure).
// The errors array is one element for a single store.
const store = RemoteDataStore.of(RemoteData.Failed([Failure.unexpected(new Error('Network error'))], retry));
render(<UserCard store={store} />);
expect(screen.getByText(/Network error/)).toBeInTheDocument();
});

No act() wrappers. No waitFor(). No timers. The store is already in the state you want, so the component renders synchronously.

Testing combined stores​

RemoteDataStore.all() works with of() stores. You can test how your component behaves when one request succeeds and another fails:

test('dashboard renders when all data is loaded', () => {
const userStore = RemoteDataStore.of(RemoteData.success({ name: 'Alice' }));
const postsStore = RemoteDataStore.of(RemoteData.success([{ title: 'Hello world' }]));

const combined = RemoteDataStore.all(userStore, postsStore);
render(<Dashboard store={combined} />);

expect(screen.getByText('Alice')).toBeInTheDocument();
expect(screen.getByText('Hello world')).toBeInTheDocument();
});

test('dashboard shows error when one request fails', () => {
const userStore = RemoteDataStore.of(RemoteData.success({ name: 'Alice' }));
// Failure.unexpected() wraps a crash; see "Failures and Retries" for details
const postsStore = RemoteDataStore.of(
RemoteData.Failed([Failure.unexpected(new Error('timeout'))], async () => {})
);

const combined = RemoteDataStore.all(userStore, postsStore);
render(<Dashboard store={combined} />);

expect(screen.getByText(/timeout/)).toBeInTheDocument();
});

The combined store computes its state from the constituent stores immediately, with no async coordination.

Testing stale states​

You can test how your component looks when data is stale:

test('shows dimmed text during background refresh', () => {
const staleStore = RemoteDataStore.of(RemoteData.StalePending(RemoteData.Success({ name: 'Alice' }, new Date())));
render(<UserCard store={staleStore} />);

// The data is still visible (it's stale, not gone)
expect(screen.getByText('Alice')).toBeInTheDocument();
// Your component can use isStale to style it differently
});

Storybook​

The same approach works for Storybook. Each story is a different state:

import { Failure, RemoteData, RemoteDataStore } from 'use-remote-data';

export const Loaded = () => (
<UserCard store={RemoteDataStore.of(RemoteData.success({ name: 'Alice', email: 'alice@example.com' }))} />
);

export const Loading = () => <UserCard store={RemoteDataStore.of(RemoteData.Pending)} />;

export const Failed = () => (
<UserCard
store={RemoteDataStore.of(
RemoteData.Failed([Failure.unexpected(new Error('Server returned 500'))], async () => {})
)}
/>
);

export const Refreshing = () => (
<UserCard
store={RemoteDataStore.of(
RemoteData.StalePending(
RemoteData.Success({ name: 'Alice (stale)', email: 'alice@example.com' }, new Date())
)
)}
/>
);

No mock servers. No interceptors. No delay hacks to catch the loading state. Just the state you want, rendered immediately.

Why this works​

The design choice that makes testing easy is the same one that makes the library work: stores are values, and values are passed as parameters.

Your component doesn't call useRemoteData itself; it receives a RemoteDataStore<T> from its parent. This means the component has no idea whether the store came from a hook, from RemoteDataStore.of(), from a test, or from server-rendered data.

This isn't a testing trick. It's the architecture. The same property that makes SSR simple (pass initial data), Storybook simple (pass a static store), and component sharing simple (pass a store as a prop) is what makes testing simple.

When data is a value and dependencies are parameters, testing is just calling a function with the arguments you choose.