@zerothrow/react
v0.2.1
Published
React hooks for type-safe error handling with Result types. Stop throwing, start returning.
Maintainers
Readme
@zerothrow/react
🧠 ZeroThrow Layers
• ZT – primitives (try,tryAsync,ok,err)
• Result – combinators (map,andThen,match)
• ZeroThrow – utilities (collect,enhanceAsync)
• @zerothrow/* – ecosystem packages (resilience, jest, etc)
ZeroThrow Ecosystem · Packages ⇢
React hooks for type-safe error handling with Result types. Stop throwing, start returning.
🎉 What's New?
This package brings the power of Result types to React applications with:
useResult- Async operations with Result typesuseResilientResult- Integration with @zerothrow/resilience policiesResultBoundary- Error boundaries that return Results instead of crashinguseResultContext- Safe context access that returns Results instead of throwingcreateResultContext- Helper for creating Result-based contexts
Why @zerothrow/react?
React error handling is fragmented:
try/catchin effects doesn't composeisLoading/isError/datapatterns are repetitive- Error boundaries are coarse and destructive
- Async errors surprise developers
Solution: Result-based hooks that make errors first-class citizens.
Installation
npm install @zerothrow/react @zerothrow/core
# or
pnpm add @zerothrow/react @zerothrow/coreFor resilient operations:
npm install @zerothrow/resilienceQuick Start
Basic Async Operations
import { useResult } from '@zerothrow/react'
import { ZT } from '@zerothrow/core'
function UserProfile({ userId }: { userId: string }) {
const { result, loading, reload } = useResult(
async () => {
const response = await fetch(`/api/users/${userId}`)
if (!response.ok) {
return ZT.err(new Error(`Failed to fetch user: ${response.status}`))
}
const user = await response.json()
return ZT.ok(user)
},
{ deps: [userId] }
)
if (loading) return <Spinner />
return result?.match({
ok: user => <UserCard {...user} />,
err: error => (
<ErrorMessage error={error}>
<button onClick={reload}>Retry</button>
</ErrorMessage>
)
}) ?? null
}Resilient Operations
import { useResilientResult } from '@zerothrow/react'
import { RetryPolicy, CircuitBreakerPolicy } from '@zerothrow/resilience'
function DataDashboard() {
const policy = RetryPolicy.exponential({ maxRetries: 3 })
.chain(CircuitBreakerPolicy.create({
failureThreshold: 5,
resetTimeout: 30000
}))
const { result, loading, retryCount, nextRetryAt, circuitState } = useResilientResult(
async () => {
const data = await fetchDashboardData() // might throw
return data
},
policy
)
if (loading) {
return nextRetryAt ? (
<div>
Retrying in {Math.round((nextRetryAt - Date.now()) / 1000)}s...
(Attempt {retryCount + 1})
</div>
) : (
<Spinner />
)
}
if (circuitState === 'open') {
return <Alert>Service temporarily unavailable. Circuit breaker is open.</Alert>
}
return result?.match({
ok: data => <Dashboard data={data} />,
err: error => <ErrorView error={error} retries={retryCount} />
}) ?? null
}Error Boundaries
import { ResultBoundary } from '@zerothrow/react'
function App() {
return (
<ResultBoundary
fallback={(result, reset) => (
<ErrorFallback
error={result.error}
onRetry={reset}
/>
)}
onError={(error, errorInfo) => {
console.error('Boundary caught:', error)
sendToTelemetry(error, errorInfo)
}}
>
<Router>
<Routes>
{/* Your app routes */}
</Routes>
</Router>
</ResultBoundary>
)
}Safe Context Access
import { useResultContext, createResultContext } from '@zerothrow/react'
// Using with existing context
const ThemeContext = createContext<Theme | undefined>(undefined)
function ThemedButton() {
const themeResult = useResultContext(ThemeContext)
return themeResult.match({
ok: (theme) => (
<button style={{ background: theme.primary }}>
Click me
</button>
),
err: (error) => (
<button>Default Button (no theme)</button>
)
})
}
// Creating a Result-based context
const { Provider, useContext } = createResultContext<UserSettings>('UserSettings')
function SettingsForm() {
const settingsResult = useContext()
return settingsResult.match({
ok: (settings) => <Form initialValues={settings} />,
err: () => <Alert>Please configure settings first</Alert>
})
}Core API
useResult
Hook for async operations that return Results.
function useResult<T, E = Error>(
fn: () => Promise<Result<T, E>> | Result<T, E>,
options?: UseResultOptions
): UseResultReturn<T, E>
interface UseResultOptions {
immediate?: boolean // Execute on mount (default: true)
deps?: DependencyList // Re-execute when deps change
}
interface UseResultReturn<T, E> {
result: Result<T, E> | undefined
loading: boolean
reload: () => void
reset: () => void
}useResilientResult
Hook for async operations with resilience policies.
function useResilientResult<T, E = Error>(
fn: () => Promise<T>,
policy: Policy<T, E>,
options?: UseResilientResultOptions
): UseResilientResultReturn<T, E>
interface UseResilientResultReturn<T, E> {
result: Result<T, E> | undefined
loading: boolean
retryCount: number
nextRetryAt?: number
circuitState?: CircuitState
reload: () => void
reset: () => void
}ResultBoundary
Error boundary that converts thrown errors to Results.
interface ResultBoundaryProps {
fallback: (result: Result<never, Error>, reset: () => void) => ReactNode
onError?: (error: Error, errorInfo: ErrorInfo) => void
children: ReactNode
}useResultContext
Safe context access that returns Results instead of throwing.
function useResultContext<T>(
context: Context<T | undefined | null>,
options?: { contextName?: string }
): Result<T, ContextError>createResultContext
Helper to create Result-based contexts with companion hooks.
function createResultContext<T>(contextName: string): {
Provider: React.Provider<T | undefined>
useContext: () => Result<T, ContextError>
Context: React.Context<T | undefined>
}Patterns
Form Validation
function ContactForm() {
const { result: submitResult, loading, reload } = useResult(
async () => {
const validation = validateForm(formData)
if (!validation.ok) return validation
const response = await submitForm(formData)
return response
},
{ immediate: false } // Don't submit on mount
)
const handleSubmit = (e: FormEvent) => {
e.preventDefault()
reload() // Trigger submission
}
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
{submitResult?.match({
ok: () => <SuccessMessage />,
err: error => <ValidationErrors error={error} />
})}
<button type="submit" disabled={loading}>
{loading ? 'Submitting...' : 'Submit'}
</button>
</form>
)
}Parallel Data Fetching
function Dashboard() {
const userResult = useResult(() => fetchUser())
const statsResult = useResult(() => fetchStats())
const notificationsResult = useResult(() => fetchNotifications())
const allLoading = userResult.loading || statsResult.loading || notificationsResult.loading
if (allLoading) return <DashboardSkeleton />
return (
<div>
{userResult.result?.match({
ok: user => <UserWidget user={user} />,
err: () => <UserWidgetError onRetry={userResult.reload} />
})}
{statsResult.result?.match({
ok: stats => <StatsWidget stats={stats} />,
err: () => <StatsWidgetError onRetry={statsResult.reload} />
})}
{notificationsResult.result?.match({
ok: notifs => <NotificationsList notifications={notifs} />,
err: () => <NotificationsError onRetry={notificationsResult.reload} />
})}
</div>
)
}Dependent Queries
function PostDetails({ postId }: { postId: string }) {
const postResult = useResult(() => fetchPost(postId), { deps: [postId] })
const authorResult = useResult(
async () => {
if (!postResult.result?.ok) return ZT.err(new Error('No post'))
return fetchAuthor(postResult.result.value.authorId)
},
{ deps: [postResult.result] }
)
return (
<div>
{postResult.result?.match({
ok: post => <PostContent post={post} />,
err: error => <ErrorMessage error={error} />
})}
{authorResult.result?.match({
ok: author => <AuthorBio author={author} />,
err: () => null // Silent fail for author
})}
</div>
)
}Testing
import { renderHook, waitFor } from '@testing-library/react'
import { useResult } from '@zerothrow/react'
import { ZT } from '@zerothrow/core'
test('fetches user successfully', async () => {
const mockFetch = vi.fn().mockResolvedValue(ZT.ok({ id: 1, name: 'Alice' }))
const { result } = renderHook(() => useResult(mockFetch))
expect(result.current.loading).toBe(true)
await waitFor(() => {
expect(result.current.loading).toBe(false)
expect(result.current.result?.value).toEqual({ id: 1, name: 'Alice' })
})
})Best Practices
- Return Results from async functions - Don't throw
- Use policies for resilience - Let policies handle retries
- Provide loading feedback - Especially with retry delays
- Test error paths - Results make this easy
- Compose at the edge - Keep components Result-aware
License
MIT
