@hex-di/result-react
v0.3.0
Published
React bindings for @hex-di/result - hooks, components, and utilities for Result-driven UIs
Maintainers
Readme
@hex-di/result-react
React hooks, components, and adapters for @hex-di/result. Type-safe error handling in React with errors as values, not exceptions.
Install
npm install @hex-di/result-react @hex-di/result reactQuick Start
import { ok, err, fromPromise } from "@hex-di/result";
import { useResultAsync, Match } from "@hex-di/result-react";
function UserProfile({ id }: { id: string }) {
const { result, isLoading, refetch } = useResultAsync(
(signal) =>
fromPromise(
fetch(`/api/users/${id}`, { signal }).then((r) => r.json()),
(e) => ({ _tag: "FetchError" as const, cause: e }),
),
[id],
);
if (isLoading || !result) return <Spinner />;
return (
<Match
result={result}
ok={(user) => <UserCard user={user} />}
err={(error) => (
<div>
<p>Failed: {String(error.cause)}</p>
<button onClick={refetch}>Retry</button>
</div>
)}
/>
);
}Components
<Match>
Renders one of two branches based on the variant of a Result. Uses React key isolation to unmount the previous branch when the variant changes, preventing stale state leakage.
import { Match } from "@hex-di/result-react";
<Match
result={result}
ok={(value) => <SuccessView data={value} />}
err={(error) => <ErrorBanner message={error.message} />}
/>Both ok and err props are required -- omitting either is a TypeScript compile error. When the result flips between Ok and Err, component state inside each branch resets completely.
Matching Error Variants
When the error type is a tagged union, match all variants inside the err branch:
import { createError, assertNever } from "@hex-di/result";
const NotFound = createError("NotFound");
const Forbidden = createError("Forbidden");
const Timeout = createError("Timeout");
type FetchError =
| ReturnType<typeof NotFound<{ id: string }>>
| ReturnType<typeof Forbidden<{ role: string }>>
| ReturnType<typeof Timeout<{ ms: number }>>;
<Match
result={result as Result<User, FetchError>}
ok={(user) => <UserCard user={user} />}
err={(error) => {
switch (error._tag) {
case "NotFound": return <NotFoundPage id={error.id} />;
case "Forbidden": return <ForbiddenPage />;
case "Timeout": return <RetryButton onClick={refetch} />;
default: return assertNever(error);
}
}}
/>If you add a new variant to FetchError without handling it, TypeScript reports a compile error on the assertNever call.
Plugin alternative: The
@hex-di/result-typescript-plugindetects missing variants automatically — noassertNeverneeded. If "Timeout" is omitted from the switch, the plugin reports directly in your editor:SUGGESTION: Missing case "Timeout" in switch statementThis works in real-time as a Language Service Plugin and in CI via
ts-patch.
Error Groups
createErrorGroup creates namespaced error families with built-in type guards, useful for filtering errors by group in the err branch:
import { createErrorGroup } from "@hex-di/result";
const Http = createErrorGroup("HttpError");
const NotFound = Http.create("NotFound");
const Timeout = Http.create("Timeout");
<Match
result={result}
ok={(data) => <DataView data={data} />}
err={(error) => {
if (Http.isTag("Timeout")(error)) return <RetryButton />;
if (Http.is(error)) return <HttpErrorPage status={error._tag} />;
return <GenericError />;
}}
/>See the core library docs for full createErrorGroup API details.
Hooks
useResultAsync -- Eager async fetching
Executes on mount and whenever deps change. Provides AbortSignal for cancellation.
const { result, isLoading, refetch } = useResultAsync(
(signal) => fetchUser(id, signal),
[id],
{ retry: 3, retryDelay: (attempt) => 1000 * 2 ** attempt },
);| Return | Type | Description |
|--------|------|-------------|
| result | Result<T, E> \| undefined | undefined until first resolution |
| isLoading | boolean | true while in flight |
| refetch | () => void | Re-execute (stable reference) |
Options: retry (number of retries), retryDelay (ms or function), retryOn (predicate).
useResultAction -- Lazy actions
Does not execute until execute() is called. Each call aborts the previous in-flight operation.
const { result, isLoading, execute, reset } = useResultAction(
(signal, data: FormData) =>
fromPromise(
fetch("/api/submit", { signal, method: "POST", body: data }).then((r) => r.json()),
(e) => String(e),
),
);
// In an event handler:
await execute(formData);| Return | Type | Description |
|--------|------|-------------|
| result | Result<T, E> \| undefined | Last resolved result |
| isLoading | boolean | true while in flight |
| execute | (...args: A) => Promise<Result<T, E>> | Trigger the action (stable) |
| reset | () => void | Clear result and abort (stable) |
useResult -- Result state
Manages a Result<T, E> as React state with convenience setters.
const { result, setOk, setErr, set, reset } = useResult<string, Error>();
// With initial value (result is never undefined):
const { result, setOk } = useResult(ok("default"));All action callbacks (setOk, setErr, set, reset) are referentially stable across re-renders.
useSafeTry -- Generator composition
Composes multiple Result operations with early return on first Err:
const { result, isLoading } = useSafeTry(
async function* (signal) {
const user = yield* fetchUser(id, signal);
const posts = yield* fetchPosts(user.id, signal);
return ok({ user, posts });
},
[id],
);Supports both sync and async generators. Abort signal is delivered on unmount or deps change.
useResultSuspense -- Suspense integration
Suspends the component until the ResultAsync resolves. Must be wrapped in <Suspense>.
function DashboardStats() {
const result = useResultSuspense(
() => fromPromise(fetch("/api/stats").then((r) => r.json()), String),
[],
);
// result is always Result<T, E>, never undefined
return <Match result={result} ok={(s) => <Stats data={s} />} err={(e) => <p>{e}</p>} />;
}
// Parent:
<Suspense fallback={<Skeleton />}>
<DashboardStats />
</Suspense>Err results are returned as values, not thrown -- they do not trigger ErrorBoundary.
createResultResource -- Render-as-you-fetch
Creates a Suspense resource outside the component tree for prefetching:
const userResource = createResultResource(() => fetchUser(id));
// Preload during navigation
userResource.preload();
// Inside component (suspends if not ready):
function UserProfile() {
const result = userResource.read();
return <Match result={result} ok={(u) => <p>{u.name}</p>} err={(e) => <p>{e}</p>} />;
}| Method | Description |
|--------|-------------|
| read() | Returns Result if resolved, suspends if pending |
| preload() | Starts fetch eagerly without suspending |
| invalidate() | Clears cache; next read() re-fetches |
useOptimisticResult -- React 19
Wraps React 19's useOptimistic for Result values:
const { result, setOptimistic } = useOptimisticResult(
serverResult,
(_current, optimistic) => ok(optimistic),
);Requires React 19. Throws at import time on React 18.
useResultTransition -- React 19
Wraps React 19's async useTransition for Result-returning operations:
const { result, isPending, startResultTransition } = useResultTransition<Data, Error>();
startResultTransition(() => fetchData(query));Requires React 19. Throws at import time on React 18.
Utilities
fromAction
Wraps a throwing async function (e.g., a server action) into one that returns ResultAsync:
import { fromAction } from "@hex-di/result-react";
const safeCreatePost = fromAction(
createPost,
(e) => ({ _tag: "CreatePostError" as const, cause: e }),
);
const result = await safeCreatePost("Hello"); // Result<Post, CreatePostError>Server Utilities
Pure functions for React Server Components. No React hooks, no "use client" required.
import { matchResult, matchResultAsync, matchOption, resultAction } from "@hex-di/result-react/server";matchResult
Pattern-match a Result with named handlers:
// app/page.tsx (Server Component)
export default async function UserPage({ params }: Props) {
const result = await fetchUser(params.id);
return matchResult(result, {
ok: (user) => <UserCard user={user} />,
err: (error) => <ErrorPage message={error.message} />,
});
}For tagged error unions, match exhaustively inside the err handler:
import { assertNever } from "@hex-di/result";
export default async function UserPage({ params }: Props) {
const result = await fetchUser(params.id);
return matchResult(result, {
ok: (user) => <UserCard user={user} />,
err: (error) => {
switch (error._tag) {
case "NotFound": return <NotFoundPage />;
case "Forbidden": return <ForbiddenPage />;
case "Timeout": return <RetryMessage ms={error.ms} />;
default: return assertNever(error);
}
},
});
}matchResultAsync
Async variant that awaits a ResultAsync or Promise<Result> before matching:
export default async function Dashboard() {
return matchResultAsync(fetchDashboardData(), {
ok: (data) => <Dashboard data={data} />,
err: (error) => <ErrorPage code={error.status} />,
});
}matchOption
Pattern-match an Option with named handlers:
matchOption(avatar, {
some: (url) => <img src={url} alt="avatar" />,
none: () => <DefaultAvatar />,
})resultAction
Wraps a server action to return Promise<Result<T, E>> instead of throwing:
"use server";
import { resultAction } from "@hex-di/result-react/server";
export const createPost = resultAction(
async (title: string) => await db.posts.create({ title }),
(e) => ({ _tag: "CreatePostError" as const, cause: e }),
);Adapters
Thin wrappers for integrating with TanStack Query and SWR.
import { toQueryFn, toQueryOptions, toMutationFn, toMutationOptions } from "@hex-di/result-react/adapters";
import { toSwrFetcher } from "@hex-di/result-react/adapters";TanStack Query
import { useQuery, useMutation } from "@tanstack/react-query";
import { toQueryFn, toMutationFn } from "@hex-di/result-react/adapters";
// Query
const { data, error } = useQuery({
queryKey: ["user", id],
queryFn: toQueryFn(() => fetchUser(id)),
});
// Query with options helper
const { data } = useQuery(toQueryOptions(["user", id], () => fetchUser(id)));
// Mutation
const { mutate } = useMutation({
mutationFn: toMutationFn((data: CreateUser) => createUser(data)),
});SWR
import useSWR from "swr";
import { toSwrFetcher } from "@hex-di/result-react/adapters";
const fetcher = toSwrFetcher((key: string) =>
fromPromise(fetch(key).then((r) => r.json()), String),
);
const { data, error } = useSWR("/api/users", fetcher);Testing Utilities
Helpers for testing Result-based components and hooks.
import {
createResultFixture,
mockResultAsync,
setupResultReactMatchers,
renderWithResult,
ResultDecorator,
} from "@hex-di/result-react/testing";createResultFixture
Factory for test data with consistent defaults:
const userFixture = createResultFixture({ id: "1", name: "Alice", email: "[email protected]" });
userFixture.ok(); // Ok({ id: "1", name: "Alice", email: "[email protected]" })
userFixture.ok({ name: "Bob" }); // Ok({ id: "1", name: "Bob", email: "[email protected]" })
userFixture.err("not found"); // Err("not found")
userFixture.okAsync({}, 100); // ResultAsync, resolves after 100ms
userFixture.errAsync("fail", 50); // ResultAsync Err, resolves after 50msmockResultAsync
Deferred ResultAsync with manual resolve/reject for testing timing:
const mock = mockResultAsync<User, Error>();
// Pass to hook under test
const { result } = renderHook(() => useResultAsync(() => mock.resultAsync, []));
expect(result.current.isLoading).toBe(true);
// Resolve at the right moment
act(() => mock.resolve({ id: "1", name: "Alice" }));
await waitFor(() => {
expect(result.current.result).toBeOk();
});Double-settling throws Error("MockResultAsync already settled").
setupResultReactMatchers
Registers toBeLoading() Vitest matcher:
// vitest.setup.ts
import { setupResultMatchers } from "@hex-di/result-testing";
import { setupResultReactMatchers } from "@hex-di/result-react/testing";
setupResultMatchers();
setupResultReactMatchers();expect(hookResult.current).toBeLoading(); // asserts isLoading === truerenderWithResult
Thin wrapper around @testing-library/react's render:
renderWithResult(<Match result={ok("hi")} ok={(v) => <p>{v}</p>} err={() => null} />);ResultDecorator
Storybook decorator for Result-based stories:
export default {
title: "Components/UserCard",
decorators: [ResultDecorator()],
};Subpath Exports
| Import | Description |
|--------|-------------|
| @hex-di/result-react | Components, hooks, utilities |
| @hex-di/result-react/adapters | TanStack Query & SWR adapters |
| @hex-di/result-react/server | Server-safe pure functions |
| @hex-di/result-react/testing | Test helpers & matchers |
Internal modules (@hex-di/result-react/internal/*) are blocked.
Invariants
- All action callbacks are referentially stable across re-renders
- Async hooks abort in-flight operations on unmount and deps change
- Generation counters prevent stale responses from overwriting newer data
- Err results are values, never thrown -- no ErrorBoundary triggered
Matchbranches have independent component state via key isolation- React 19 hooks throw descriptively at import time on React 18
- Server utilities have no React runtime dependency
- Suspense hooks throw promises (not errors) and return
Result<T, E> - StrictMode compatible (double-mount safe)
TypeScript Plugin
For compile-time and editor-time static analysis, add @hex-di/result-typescript-plugin:
npm install -D @hex-di/result-typescript-plugin{
"compilerOptions": {
"plugins": [
{
"name": "@hex-di/result-typescript-plugin"
}
]
}
}The plugin catches common mistakes when using @hex-di/result with React:
- Must-use diagnostics -- warns when a
Resultreturned by a hook or utility is silently discarded - Unsafe import gating -- flags
@hex-di/result/unsafeimports outside allowed patterns - Exhaustiveness hints -- detects incomplete error handling in
match()calls and the<Match>component's type-level contract - Code fixes -- quick-fix actions for wrapping in
fromAction, convertingisOkchecks tomatch, and more - Code quality lints -- suggests idiomatic patterns like
andThenover manualisOk+map - Result hover -- simplified type display showing error variants inline
For CI enforcement, use the compiler transformer with ts-patch. See the plugin README for full configuration.
Related Packages
| Package | Description |
|---------|-------------|
| @hex-di/result | Core Result and Option types |
| @hex-di/result-testing | Vitest matchers and test utilities |
| @hex-di/result-typescript-plugin | TypeScript plugin for static analysis |
Requirements
- React >= 18.0.0 (React 19 for
useOptimisticResultanduseResultTransition) @hex-di/result>= 0.2.0- TypeScript >= 5.0 (optional)
License
MIT
