Skip to main content

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 | PersonDeleted and 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): success
  • Result.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.tagfailure.value isMeaning
'expected'E (your domain error type)A domain error you designed for
'unexpected'Error | unknownA 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.