Skip to content

Hooks

React hooks for accessing route data and navigation state.

Import

ts
import {
  useLoaderData,
  useRouteLoaderData,
  useActionData,
  useNavigation,
  useError,
  useRouteError,
  isRouteErrorResponse,
  useParams,
  useSearchParams,
  useLocation,
} from '@ereo/client'

// Types
import type {
  NavigationStatus,
  NavigationStateHook,
  LocationState,
  LoaderDataContextValue,
  ActionDataContextValue,
  NavigationContextValue,
  ErrorContextValue,
  ParamsContextValue,
  LocationContextValue,
  LoaderDataProviderProps,
  ActionDataProviderProps,
  NavigationProviderProps,
  ErrorProviderProps,
  ParamsProviderProps,
  LocationProviderProps,
  EreoProviderProps,
} from '@ereo/client'

Types

ts
// Navigation status for useNavigation hook
type NavigationStatus = 'idle' | 'loading' | 'submitting'

// Navigation state returned by useNavigation
interface NavigationStateHook {
  status: NavigationStatus
  location?: string          // The URL being navigated to
  formData?: FormData        // Form data being submitted
  formMethod?: string        // Form method being used
  formAction?: string        // Form action URL
}

// Location object returned by useLocation
interface LocationState {
  pathname: string           // e.g., '/users/123'
  search: string             // e.g., '?page=1&sort=name'
  hash: string               // e.g., '#section-2'
  state: unknown             // History state object
  key: string                // Unique key for this location entry
}

useLoaderData

Accesses the data returned by the route's loader.

Signature

ts
function useLoaderData<T = unknown>(): T

Example

tsx
import { useLoaderData } from '@ereo/client'

interface LoaderData {
  posts: Post[]
  totalCount: number
}

export default function Posts() {
  const { posts, totalCount } = useLoaderData<LoaderData>()

  return (
    <div>
      <h1>Posts ({totalCount})</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  )
}

With Type Inference

tsx
import { createLoader } from '@ereo/data'
import { useLoaderData } from '@ereo/client'

export const loader = createLoader(async () => {
  const posts = await db.posts.findMany()
  return { posts, count: posts.length }
})

export default function Posts() {
  // Type is inferred from loader
  const { posts, count } = useLoaderData<Awaited<ReturnType<typeof loader>>>()

  return <div>...</div>
}

useRouteLoaderData

Accesses loader data from a specific route by its ID. Useful for reading data from a parent layout or sibling route without prop drilling.

Signature

ts
function useRouteLoaderData<T = unknown>(routeId: string): T | undefined

Returns undefined if no matching route is found.

Example

tsx
import { useRouteLoaderData } from '@ereo/client'

// Access root layout's loader data from any nested route
function UserAvatar() {
  const rootData = useRouteLoaderData<{ user: User }>('root-layout')

  if (!rootData?.user) return null

  return <img src={rootData.user.avatar} alt={rootData.user.name} />
}

useActionData

Accesses the data returned by the route's action.

Signature

ts
function useActionData<T = unknown>(): T | undefined

Returns undefined if no action has been submitted.

Example

tsx
import { useActionData } from '@ereo/client'
import { Form } from '@ereo/client'

interface ActionData {
  error?: string
  success?: boolean
  values?: { email: string }
}

export default function Subscribe() {
  const actionData = useActionData<ActionData>()

  return (
    <Form method="post">
      <input
        name="email"
        type="email"
        defaultValue={actionData?.values?.email}
      />

      {actionData?.error && (
        <p className="error">{actionData.error}</p>
      )}

      {actionData?.success && (
        <p className="success">Subscribed successfully!</p>
      )}

      <button type="submit">Subscribe</button>
    </Form>
  )
}

Handling Multiple States

tsx
export default function ContactForm() {
  const actionData = useActionData<{
    errors?: Record<string, string>
    success?: boolean
    formData?: Record<string, string>
  }>()

  return (
    <Form method="post">
      <div>
        <label htmlFor="name">Name</label>
        <input
          id="name"
          name="name"
          defaultValue={actionData?.formData?.name}
        />
        {actionData?.errors?.name && (
          <span className="error">{actionData.errors.name}</span>
        )}
      </div>

      <div>
        <label htmlFor="email">Email</label>
        <input
          id="email"
          name="email"
          type="email"
          defaultValue={actionData?.formData?.email}
        />
        {actionData?.errors?.email && (
          <span className="error">{actionData.errors.email}</span>
        )}
      </div>

      <button type="submit">Send</button>
    </Form>
  )
}

useNavigation

Tracks the current navigation state.

Signature

ts
function useNavigation(): NavigationStateHook

interface NavigationStateHook {
  status: 'idle' | 'loading' | 'submitting'
  location?: string           // The URL being navigated to
  formData?: FormData
  formMethod?: string
  formAction?: string
}

States

  • idle - No navigation in progress
  • loading - Navigating to a new page (loader running)
  • submitting - Form submission in progress (action running)

Example

tsx
import { useNavigation } from '@ereo/client'

export default function Page() {
  const navigation = useNavigation()

  return (
    <div>
      {navigation.status === 'loading' && (
        <div className="loading-bar" />
      )}

      <h1>Page Content</h1>
    </div>
  )
}

Form Submit Indicator

tsx
import { useNavigation } from '@ereo/client'
import { Form } from '@ereo/client'

export default function NewPost() {
  const navigation = useNavigation()
  const isSubmitting = navigation.status === 'submitting'

  return (
    <Form method="post">
      <input name="title" disabled={isSubmitting} />
      <textarea name="content" disabled={isSubmitting} />

      <button type="submit" disabled={isSubmitting}>
        {isSubmitting ? 'Creating...' : 'Create Post'}
      </button>
    </Form>
  )
}

Global Loading Indicator

tsx
// components/LoadingIndicator.tsx
import { useNavigation } from '@ereo/client'

export function LoadingIndicator() {
  const navigation = useNavigation()

  if (navigation.status === 'idle') {
    return null
  }

  return (
    <div className="fixed top-0 left-0 right-0 h-1 bg-blue-500 animate-pulse" />
  )
}

Optimistic UI

tsx
import { useNavigation } from '@ereo/client'
import { Form } from '@ereo/client'

export default function TodoItem({ todo }) {
  const navigation = useNavigation()

  // Check if this item is being toggled
  const isToggling =
    navigation.status === 'submitting' &&
    navigation.formData?.get('todoId') === todo.id &&
    navigation.formData?.get('intent') === 'toggle'

  // Optimistic completed state
  const completed = isToggling ? !todo.completed : todo.completed

  return (
    <Form method="post">
      <input type="hidden" name="todoId" value={todo.id} />
      <input type="hidden" name="intent" value="toggle" />
      <button type="submit" className={completed ? 'completed' : ''}>
        {todo.title}
      </button>
    </Form>
  )
}

useError

Accesses the error thrown in an error boundary.

Signature

ts
function useError(): Error | undefined

Example

tsx
import { useError } from '@ereo/client'

export function ErrorBoundary() {
  const error = useError()

  return (
    <div className="error-page">
      <h1>Oops!</h1>
      <p>{error?.message || 'Something went wrong'}</p>
      <a href="/">Go home</a>
    </div>
  )
}

useRouteError

Accesses the error thrown in a route's error boundary. Unlike useError (which returns Error | undefined), useRouteError returns unknown — this allows you to distinguish HTTP error responses from runtime errors using isRouteErrorResponse.

Signature

ts
function useRouteError(): unknown

Example

tsx
import { useRouteError, isRouteErrorResponse } from '@ereo/client'

export default function PostsError() {
  const error = useRouteError()

  if (isRouteErrorResponse(error)) {
    // HTTP error response (e.g., thrown from a loader)
    if (error.status === 404) {
      return <h1>Post not found</h1>
    }
    return <h1>Error {error.status}: {error.statusText}</h1>
  }

  // Runtime error
  return <h1>Something went wrong</h1>
}

useError vs useRouteError: Use useError when you just need the error message. Use useRouteError when you need to check if the error is an HTTP error response (e.g., 404, 403) and render different UI based on the status code.

isRouteErrorResponse

Type guard that checks if an error is an HTTP error response (has status, statusText, and data properties). Use with useRouteError to distinguish HTTP errors from runtime errors.

Signature

ts
function isRouteErrorResponse(error: unknown): error is RouteErrorResponse

interface RouteErrorResponse {
  status: number
  statusText: string
  data?: unknown
}

Example

tsx
import { useRouteError, isRouteErrorResponse } from '@ereo/client'

export default function ErrorPage() {
  const error = useRouteError()

  if (isRouteErrorResponse(error)) {
    switch (error.status) {
      case 404:
        return <h1>Page not found</h1>
      case 401:
        return <h1>Unauthorized — please log in</h1>
      case 403:
        return <h1>You don't have permission to view this page</h1>
      default:
        return <h1>Error {error.status}</h1>
    }
  }

  return (
    <div>
      <h1>Something went wrong</h1>
      <p>{error instanceof Error ? error.message : 'Unknown error'}</p>
    </div>
  )
}

useParams

Accesses the URL parameters from dynamic route segments.

Signature

ts
function useParams<T extends Record<string, string> = Record<string, string>>(): T

Example

tsx
import { useParams } from '@ereo/client'

// In a route file at routes/users/[id].tsx
function UserProfile() {
  const { id } = useParams<{ id: string }>()

  return <div>User ID: {id}</div>
}

useSearchParams

Accesses and modifies the current URL search parameters. Returns a tuple of [URLSearchParams, setSearchParams], similar to useState.

Signature

ts
function useSearchParams(): [
  URLSearchParams,
  (
    nextParams:
      | URLSearchParams
      | Record<string, string>
      | ((prev: URLSearchParams) => URLSearchParams | Record<string, string>),
    options?: { replace?: boolean }
  ) => void
]

Example

tsx
import { useSearchParams } from '@ereo/client'

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams()
  const page = searchParams.get('page') || '1'
  const sort = searchParams.get('sort') || 'name'

  return (
    <div>
      <select
        value={sort}
        onChange={(e) => setSearchParams({ page, sort: e.target.value })}
      >
        <option value="name">Name</option>
        <option value="price">Price</option>
      </select>

      <button onClick={() => setSearchParams({ page: String(Number(page) + 1), sort })}>
        Next Page
      </button>

      {/* Use replace to avoid adding to history */}
      <button onClick={() => setSearchParams({ page: '1', sort }, { replace: true })}>
        Reset
      </button>
    </div>
  )
}

Functional Updates

tsx
// Update based on previous params
setSearchParams((prev) => {
  const next = new URLSearchParams(prev)
  next.set('page', '2')
  return next
})

useLocation

Accesses the current location object.

Signature

ts
function useLocation(): LocationState

interface LocationState {
  pathname: string    // e.g., '/users/123'
  search: string      // e.g., '?page=1'
  hash: string        // e.g., '#section-2'
  state: unknown      // History state object
  key: string         // Unique key for this location entry
}

Example

tsx
import { useLocation } from '@ereo/client'

function Breadcrumbs() {
  const location = useLocation()
  const segments = location.pathname.split('/').filter(Boolean)

  return (
    <nav>
      <a href="/">Home</a>
      {segments.map((seg, i) => (
        <span key={i}>
          {' / '}
          <a href={'/' + segments.slice(0, i + 1).join('/')}>{seg}</a>
        </span>
      ))}
    </nav>
  )
}

Context Providers

EreoJS exports context providers for testing, custom setups, and direct context access.

Import

ts
import {
  // Providers
  LoaderDataProvider,
  ActionDataProvider,
  NavigationProvider,
  ErrorProvider,
  ParamsProvider,
  LocationProvider,
  EreoProvider,

  // Contexts (for useContext)
  LoaderDataContext,
  ActionDataContext,
  NavigationContext,
  ErrorContext,
  ParamsContext,
  LocationContext,

  // Internal context accessors
  useLoaderDataContext,
  useActionDataContext,
  useNavigationContext,
  useErrorContext,
} from '@ereo/client'

Provider Props

ts
interface LoaderDataProviderProps {
  children: ReactNode
  initialData?: unknown
}

interface ActionDataProviderProps {
  children: ReactNode
  initialData?: unknown
}

interface NavigationProviderProps {
  children: ReactNode
  initialState?: NavigationStateHook
}

interface ErrorProviderProps {
  children: ReactNode
  initialError?: Error
}

interface ParamsProviderProps {
  children: ReactNode
  initialParams?: Record<string, string>
}

interface LocationProviderProps {
  children: ReactNode
  initialLocation?: LocationState
}

interface EreoProviderProps {
  children: ReactNode
  loaderData?: unknown                   // Initial loader data (typically from SSR)
  actionData?: unknown                   // Initial action data
  navigationState?: NavigationStateHook  // Initial navigation state
  error?: Error                          // Initial error (if rendering error boundary)
  params?: Record<string, string>        // Initial route params (from route matching)
  location?: LocationState               // Initial location (from request URL or window.location)
  matches?: RouteMatchData[]             // Initial matched routes (from route matching)
}

EreoProvider

Combines all providers into a single wrapper:

tsx
import { EreoProvider } from '@ereo/client'

function App({ loaderData, actionData }) {
  return (
    <EreoProvider loaderData={loaderData} actionData={actionData}>
      <Routes />
    </EreoProvider>
  )
}

Individual Providers

tsx
import { LoaderDataProvider, ActionDataProvider } from '@ereo/client'

function App() {
  return (
    <LoaderDataProvider initialData={{ posts: [] }}>
      <ActionDataProvider>
        <MyComponent />
      </ActionDataProvider>
    </LoaderDataProvider>
  )
}

Testing with Providers

tsx
import { render } from '@testing-library/react'
import { LoaderDataProvider } from '@ereo/client'
import PostList from './PostList'

test('renders posts', () => {
  render(
    <LoaderDataProvider initialData={{ posts: [{ id: 1, title: 'Test Post' }] }}>
      <PostList />
    </LoaderDataProvider>
  )

  expect(screen.getByText('Test Post')).toBeInTheDocument()
})

Context Accessors (Internal)

These hooks provide direct access to the full context value including setters:

ts
// Get full loader data context with setter
const { data, setData } = useLoaderDataContext()

// Get full action data context with setter and clear
const { data, setData, clearData } = useActionDataContext()

// Get full navigation context with state controls
const { state, setState, startLoading, startSubmitting, complete } = useNavigationContext()

// Get full error context with setter and clear
const { error, setError, clearError } = useErrorContext()

Custom Hooks

Build on top of the base hooks:

useIsSubmitting

ts
function useIsSubmitting(action?: string) {
  const navigation = useNavigation()

  if (action) {
    return (
      navigation.status === 'submitting' &&
      navigation.formAction === action
    )
  }

  return navigation.status === 'submitting'
}

useIsLoading

ts
function useIsLoading() {
  const navigation = useNavigation()
  return navigation.status === 'loading'
}

useFormStatus

ts
function useFormStatus() {
  const navigation = useNavigation()
  const actionData = useActionData()

  return {
    isSubmitting: navigation.status === 'submitting',
    isSuccess: actionData?.success === true,
    isError: actionData?.error !== undefined,
    error: actionData?.error
  }
}

Released under the MIT License.