# @ovineko/react-router

> Type-safe wrapper for React Router v7 with valibot schema validation, automatic error handling, and typed params.

# @ovineko/react-router

Type-safe wrapper for React Router v7 with valibot schema validation, automatic error handling, and typed params.

## Install

<Tabs>
  <TabItem value="pnpm" label="pnpm" default>
    ```bash
    pnpm add @ovineko/react-router react react-router valibot
    ```
  </TabItem>
  <TabItem value="npm" label="npm">
    ```bash
    npm install @ovineko/react-router react react-router valibot
    ```
  </TabItem>
  <TabItem value="yarn" label="yarn">
    ```bash
    yarn add @ovineko/react-router react react-router valibot
    ```
  </TabItem>
  <TabItem value="bun" label="bun">
    ```bash
    bun add @ovineko/react-router react react-router valibot
    ```
  </TabItem>
  <TabItem value="deno" label="deno">
    ```bash
    deno add npm:@ovineko/react-router npm:react npm:react-router npm:valibot
    ```
  </TabItem>
</Tabs>

Peer dependencies: `react@^19`, `react-router@^7`, `valibot@^1`.

## Features

- Valibot schema validation — Runtime validation for URL params and search params
- Two hook variants — Normal hooks (auto-redirect on error) and Raw hooks (manual error handling)
- Smart validation — Ignores extra query params and invalid optional params
- Global error handling — Configure fallback redirect URLs globally or per-route
- Conditional redirects — `useRedirect` hook for declarative navigation with infinite loop prevention
- Optional search params helper — `optionalSearchParams` utility to avoid repetitive `v.optional()` calls
- Type-safe — Full TypeScript support with inferred types
- Hash support — Generate paths with hash fragments

## Quick Start

```tsx

const userRoute = createRouteWithParams("/users/:id", {
  params: v.object({ id: v.pipe(v.string(), v.uuid()) }),
  searchParams: optionalSearchParams({
    tab: v.string(),
    page: v.pipe(v.string(), v.transform(Number), v.number()),
  }),
  errorRedirect: "/404",
});

// Normal hooks - auto-redirect on validation error
function UserPage() {
  const params = userRoute.useParams(); // Readonly<{ id: string }>
  const [searchParams, setSearchParams] = userRoute.useSearchParams();

  return (
    
      User {params.id}, Page {searchParams.page ?? 1}
    
  );
}

// Raw hooks - manual error handling
function AdvancedUserPage() {
  const [params, error] = userRoute.useParamsRaw();
  const { data, error: searchError } = userRoute.useSearchParamsRaw();

  if (error) return Invalid user ID;
  if (searchError) console.warn("Invalid search params", searchError);

  return User {params?.id};
}
```

## Global Error Redirect

Configure a global fallback URL for validation errors:

```tsx

// In your app entry point
setGlobalErrorRedirect("/error");

// Priority: route-level > global > default "/"
const route = createRouteWithParams("/users/:id", {
  params: v.object({ id: v.string() }),
  errorRedirect: "/404", // Overrides global
});
```

## Validation Behavior

### Path Params

- Always strict validation
- Redirect on any validation error

### Search Params

- **Extra params** (not in schema) — ignored
- **Invalid optional params** — ignored (treated as undefined)
- **Invalid required params** — error (triggers redirect)

```tsx
const route = createRouteWithParams("/search", {
  params: v.object({}),
  searchParams: v.object({
    q: v.string(), // required
    page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
  }),
});

// URL: /search?q=react&page=invalid&debug=true
// Result: { q: "react", page: undefined }
// - page=invalid ignored (optional + invalid)
// - debug=true ignored (not in schema)
```

## API Reference

### Route Creation

#### `createRouteWithParams`

```tsx
const route = createRouteWithParams<TParams, TSearchParams>(pattern, config);
```

**Config:**

- `params` — Valibot schema for URL params (required)
- `searchParams` — Valibot schema for search params (optional)
- `errorRedirect` — Redirect URL on validation error (optional)

**Returns:**

- `path(params, searchParams?, hash?)` — Generate URL path
- `parseURLParams(url)` — Parse and validate URL params
- `Link` — Type-safe Link component
- `useParams()` — Get validated params (auto-redirect on error)
- `useParamsRaw()` — Get params with error info `[data, error]`
- `useSearchParams()` — Get validated search params (auto-redirect on error)
- `useSearchParamsRaw()` — Get search params with error info `{data, error, setter}`
- `pattern` — Original route pattern

#### `createRouteWithoutParams`

```tsx
const route = createRouteWithoutParams<TSearchParams>(pattern, config?);
```

**Config:**

- `searchParams` — Valibot schema for search params
- `errorRedirect` — Redirect URL on validation error

**Returns:**

- `path(searchParams?, hash?)` — Generate URL path
- `Link` — Type-safe Link component
- `useSearchParams()` — Get validated search params
- `useSearchParamsRaw()` — Get search params with error info
- `pattern` — Original route pattern

### Examples

#### Path Generation

```tsx
const userRoute = createRouteWithParams("/users/:id", {
  params: v.object({ id: v.string() }),
  searchParams: v.object({
    tab: v.optional(v.string()),
  }),
});

userRoute.path({ id: "42" });
// "/users/42"

userRoute.path({ id: "42" }, { tab: "settings" });
// "/users/42?tab=settings"

userRoute.path({ id: "42" }, { tab: "settings" }, "profile");
// "/users/42?tab=settings#profile"
```

#### Type-safe Links

```tsx
<userRoute.Link params={{ id: "42" }} searchParams={{ tab: "profile" }}>
  View Profile
</userRoute.Link>

// Automatically includes prevPath in navigation state
```

#### Parse URL Params

```tsx
const params = userRoute.parseURLParams("https://example.com/users/42");
// { id: "42" }

// Throws URLParseError on invalid URL or validation error
```

#### Update Search Params

```tsx
const [searchParams, setSearchParams] = route.useSearchParams();

// Set new params
setSearchParams({ q: "react", page: 1 });

// Update based on previous
setSearchParams((prev) => ({ ...prev, page: prev.page + 1 }));

// With navigation options
setSearchParams({ q: "vue" }, { replace: true });
```

### Utilities

#### `useRedirect(path, condition, replace?)`

Declarative hook for conditional redirects with built-in infinite loop prevention.

**Parameters:**

- `path: string` — Target redirect URL
- `condition: boolean` — Whether to trigger the redirect
- `replace?: boolean` — Use replace instead of push (default: `true`)

**Features:**

- Prevents infinite redirect loops using `useRef` tracking
- Only redirects once when condition becomes `true`
- Resets automatically when condition becomes `false`
- Uses `replace: true` by default to prevent back-button issues

```tsx

function ProtectedPage() {
  const { isAuthenticated, isLoading } = useAuth();

  // Redirect to login if not authenticated
  useRedirect("/login", !isAuthenticated && !isLoading);

  if (isLoading) return <Spinner />;
  return Protected Content;
}

// Advanced usage with custom options
function UserProfile() {
  const { user, error } = useUser();

  // Redirect without replacing history
  useRedirect("/users", !user && !error, false);

  return {user?.name};
}
```

#### `GuardedRoute`

Declarative route guard wrapper for React Router v7 route configs. Makes route protection visible at the routing config level instead of hidden inside page components.

**Props:**

- `useGuard: () => GuardResult` — A React hook that returns guard state
- `loadingFallback?: React.ReactNode` — Optional loading UI (blocks child rendering)

**GuardResult interface:**

```tsx
interface GuardResult {
  allowed: boolean; // Whether the user is allowed to access this route
  isLoading: boolean; // Whether the guard data is still loading
  redirectTo: string; // Where to redirect when !allowed && !isLoading
}
```

**Behavior:**

- **Default (no `loadingFallback`):** Renders `<Outlet />` immediately while `isLoading` is true. This allows the child page's lazy import to start in parallel with the guard's data fetching.
- **With `loadingFallback`:** Renders the fallback instead of `<Outlet />` while `isLoading` is true. Use this when you need to block child rendering until the guard resolves.
- **Redirect:** When `isLoading` is false and `allowed` is false, redirects to `redirectTo` using `replace` (guards are access checks, not navigation).

**Example — Extracting Guard from Data Hook:**

```tsx

// Guard hook — extracted, visible in route config
function useOrderGuard(): GuardResult {
  const { orderId } = useParams<{ orderId: string }>();
  const { data, isLoading } = useSWR(`/api/orders/${orderId}`);

  return {
    allowed: Boolean(data?.order),
    isLoading,
    redirectTo: "/orders",
  };
}

// Data hook — pure, no redirects
function useOrderData() {
  const { orderId } = useParams<{ orderId: string }>();
  const { data, isLoading } = useSWR(`/api/orders/${orderId}`);
  return { order: data?.order, isLoading };
}

function Checkout() {
  const { order, isLoading } = useOrderData();
  if (isLoading) return <Spinner />;
  return Checkout for order {order.id};
}

// Route config — protection is visible and declarative
const routes: RouteObject[] = [
  {
    element: <GuardedRoute useGuard={useOrderGuard} />,
    children: [{ path: "orders/:orderId/checkout", element: <Checkout /> }],
  },
];
```

**Example — With `loadingFallback`:**

```tsx
const protectedRoutes: RouteObject[] = [
  {
    element: <GuardedRoute useGuard={useAuthGuard} loadingFallback={<Spinner />} />,
    children: [{ path: "dashboard", element: <Dashboard /> }],
  },
];
```

**Comparison with `useRedirect`:**

- `useRedirect` — Hook-level conditional redirect (used inside components)
- `GuardedRoute` — Config-level declarative guard (used in route definitions)

Both complement each other: `GuardedRoute` makes route protection explicit in the config, while `useRedirect` handles component-level conditional navigation.

#### `optionalSearchParams(entries)`

Utility to make all search param fields optional automatically, avoiding repetitive `v.optional()` calls.

```tsx

// Before: Manual v.optional() for each field
const route = createRouteWithParams("/search", {
  params: v.object({ id: v.string() }),
  searchParams: v.object({
    q: v.optional(v.string()),
    page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
    sort: v.optional(v.string()),
    filter: v.optional(v.string()),
  }),
});

// After: Clean and concise
const route = createRouteWithParams("/search", {
  params: v.object({ id: v.string() }),
  searchParams: optionalSearchParams({
    q: v.string(),
    page: v.pipe(v.string(), v.transform(Number), v.number()),
    sort: v.string(),
    filter: v.string(),
  }),
});

// All fields are automatically optional!
const [searchParams] = route.useSearchParams();
// Type: Readonly<{ q?: string; page?: number; sort?: string; filter?: string }>
```

#### `setGlobalErrorRedirect(url)`

Set global fallback redirect URL for validation errors.

```tsx
setGlobalErrorRedirect("/error");
```

#### `replaceState(state)`

Update browser history state without navigation.

```tsx

replaceState({ scrollTop: window.scrollY });
```

#### `URLParseError`

Error class thrown when URL parsing fails.

```tsx
try {
  route.parseURLParams("invalid-url");
} catch (error) {
  if (error instanceof URLParseError) {
    console.log(error.context); // { pattern, url, ... }
  }
}
```

## TypeScript

All types are automatically inferred from valibot schemas:

```tsx
const route = createRouteWithParams("/users/:id", {
  params: v.object({ id: v.string() }),
  searchParams: v.object({
    page: v.optional(v.pipe(v.string(), v.transform(Number), v.number())),
  }),
});

// Inferred types:
const params = route.useParams(); // Readonly<{ id: string }>
const [searchParams] = route.useSearchParams(); // Readonly<{ page?: number }>
```
