Typed Errors
By default, use-remote-data treats all failures the same: a rejected promise means something went wrong,
and the error view gets a retry button. That's enough for most apps.
But sometimes you know what kind of failure happened and want to render it differently. Two common cases:
- Validation — you parse the API response with Zod and get field-level errors.
- GraphQL unions — the API returns
Person | PersonNotFound | PersonDeletedand each case needs its own UI.
useRemoteDataResult (and useRemoteDataMapResult for dynamic data) lets you separate
expected domain errors from unexpected crashes — and TypeScript knows which is which.
The idea
Your fetch function returns a Result instead of a plain value:
Result.ok(value)— successResult.err(error)— a domain error
If the promise rejects (network failure, thrown exception), that's an unexpected error handled automatically.
If it resolves with Result.err(...), that's a typed domain error you handle explicitly.
Both end up in the Failed state, but your error component can tell them apart:
error={({ errors, retry }) => (
<div>
{errors.map((failure, i) =>
failure.tag === 'expected'
? /* failure.value is your typed error E */
: /* failure.value is the thrown exception */
)}
<button onClick={retry}>Retry</button>
</div>
)}
Example: Zod validation
Validate API responses with a Zod schema.
When the response doesn't match, the ZodError becomes a typed domain error
with field-level details you can render.
import { Await, Result, useRemoteDataResult } from 'use-remote-data';
import { z } from 'zod';
const UserSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Must be a valid email'),
age: z.number().min(0, 'Age must be positive'),
});
type User = z.infer<typeof UserSchema>;
function fetchAndValidateUser(): Promise<Result<User, z.ZodError>> {
return fetch('/api/user')
.then((r) => r.json())
.then((raw) => {
const result = UserSchema.safeParse(raw);
if (result.success) return Result.ok(result.data);
return Result.err(result.error);
});
}
If the API returns { name: "", email: "not-an-email", age: -5 }, the store moves to Failed
with a ZodError containing three issues — one per field. Your error component renders each one:
<Await
store={store}
error={({ errors, retry }) => (
<div>
{errors.map((failure, i) =>
failure.tag === 'expected' ? (
<ul key={i}>
{failure.value.issues.map((issue, j) => (
<li key={j}>
{issue.path.join('.')}: {issue.message}
</li>
))}
</ul>
) : (
<p key={i}>Network error</p>
)
)}
<button onClick={retry}>Retry</button>
</div>
)}
>
{(user) => (
<p>
{user.name} ({user.email})
</p>
)}
</Await>
Try it — the simulated API alternates between valid and invalid responses:
import { z } from 'zod';
import {
Result,
ErrorProps,
useRemoteDataResult,
Await,
} from 'use-remote-data';
// Define the shape you expect from the API
const UserSchema = z.object({
name: z.string().min(1, 'Name is required'),
email: z.string().email('Must be a valid email'),
age: z.number().min(0, 'Age must be positive'),
});
type User = z.infer<typeof UserSchema>;
// Simulate an API that sometimes returns bad data
const responses: unknown[] = [
{ name: 'Alice', email: 'alice@example.com', age: 30 },
{ name: '', email: 'not-an-email', age: -5 },
{ name: 'Charlie', email: 'charlie@example.com', age: 25 },
];
let i = -1;
function fetchUser(): Promise<unknown> {
return new Promise((resolve) => {
i = (i + 1) % responses.length;
setTimeout(() => resolve(responses[i]), 800);
});
}
// Validate the response — if it doesn't match, return
// the ZodError as a typed domain error
async function fetchAndValidateUser(): Promise<Result<User, z.ZodError>> {
const raw = await fetchUser();
const result = UserSchema.safeParse(raw);
if (result.success) return Result.ok(result.data);
return Result.err(result.error);
}
function UserError({ errors, retry }: ErrorProps<z.ZodError>) {
return (
<div>
{errors.map((failure, i) =>
failure.tag === 'expected' ? (
<div key={i}>
<strong>Validation failed:</strong>
<ul>
{failure.value.issues.map((issue, j) => (
<li key={j}>
<code>{issue.path.join('.')}</code>:{' '}
{issue.message}
</li>
))}
</ul>
</div>
) : (
<div key={i}>
Unexpected error:{' '}
{failure.value instanceof Error
? failure.value.message
: 'unknown'}
</div>
)
)}
<button onClick={retry}>Retry</button>
</div>
);
}
export function Component() {
const store = useRemoteDataResult(fetchAndValidateUser);
return (
<Await store={store} error={(props) => <UserError {...props} />}>
{(user) => (
<p>
{user.name} ({user.email}), age {user.age}
</p>
)}
</Await>
);
}
Example: GraphQL union types
GraphQL APIs often return unions like Person | PersonNotFound | PersonDeleted.
These aren't crashes — they're expected outcomes with structured data.
You decide which variants are successes and which are domain errors:
import { Await, Result, useRemoteDataResult } from 'use-remote-data';
const store = useRemoteDataResult(async () => {
const result = await graphql<PersonResult>(PERSON_QUERY);
if (result.__typename === 'Person') return Result.ok(result);
return Result.err(result); // PersonNotFound | PersonDeleted
});
Your error component can switch on the __typename to show different messages for each case:
import {
Result,
ErrorProps,
useRemoteDataResult,
Await,
} from 'use-remote-data';
// GraphQL APIs often return union types for errors
type Person = { __typename: 'Person'; name: string; age: number };
type NotFound = { __typename: 'PersonNotFound'; reason: string };
type Deleted = { __typename: 'PersonDeleted'; reason: string };
type PersonError = NotFound | Deleted;
type PersonResult = Person | PersonError;
// Simulate a GraphQL endpoint that cycles through outcomes
const results: PersonResult[] = [
{ __typename: 'Person', name: 'Alice', age: 30 },
{ __typename: 'PersonNotFound', reason: 'No person with that ID' },
{ __typename: 'PersonDeleted', reason: 'Account removed on 2024-01-10' },
];
let i = -1;
function fetchPerson(): Promise<PersonResult> {
return new Promise((resolve) => {
i = (i + 1) % results.length;
setTimeout(() => resolve(results[i]), 800);
});
}
function PersonErrorView({ errors, retry }: ErrorProps<PersonError>) {
return (
<div>
{errors.map((failure, i) =>
failure.tag === 'expected' ? (
<div key={i}>
{failure.value.__typename === 'PersonNotFound'
? `Not found: ${failure.value.reason}`
: `Deleted: ${failure.value.reason}`}
</div>
) : (
<div key={i}>
Unexpected error:{' '}
{failure.value instanceof Error
? failure.value.message
: 'unknown'}
</div>
)
)}
<button onClick={retry}>Retry</button>
</div>
);
}
export function Component() {
const store = useRemoteDataResult(async () => {
const result = await fetchPerson();
if (result.__typename === 'Person') return Result.ok(result);
return Result.err(result);
});
return (
<Await store={store} error={(props) => <PersonErrorView {...props} />}>
{(person) => (
<p>
{person.name}, age {person.age}
</p>
)}
</Await>
);
}
How the error array works
Each element in the errors array is a Failure with two possible shapes:
failure.tag | failure.value is | Meaning |
|---|---|---|
'expected' | E (your domain error type) | A domain error you designed for |
'unexpected' | Error | unknown | A surprise crash (network failure, thrown exception) |
For a single store, errors always has one element.
For combined stores, it can have multiple — one per failed request.