npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@firtoz/router-toolkit

v7.0.2

Published

Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management

Readme

@firtoz/router-toolkit

npm version npm downloads license

Type-safe React Router 7 framework mode helpers with enhanced fetching, form submission, and state management for React Router 7 framework mode.

⚠️ Early WIP Notice: This package is in very early development and is not production-ready. It is TypeScript-only and may have breaking changes. While I (the maintainer) have limited time, I'm open to PRs for features, bug fixes, or additional support (like JS builds). Please feel free to try it out and contribute! See CONTRIBUTING.md for details.

Features

  • Type-safe routing - Full TypeScript support with React Router 7 framework mode
  • 🚀 Enhanced fetching - Dynamic fetchers with caching and query parameter support
  • 📝 Form submission - Type-safe form handling with Zod validation
  • 📤 Concurrent submissions - Multiple parallel submissions per action with per-operation tracking and optimistic UI (ConcurrentSubmitterProvider + useConcurrentSubmitter)
  • 🔄 State tracking - Monitor fetcher state changes with ease
  • 🎯 Zero configuration - Works out of the box with React Router 7
  • 📦 Tree-shakeable - Import only what you need

Installation

npm install @firtoz/router-toolkit
# or
yarn add @firtoz/router-toolkit
# or
pnpm add @firtoz/router-toolkit
# or
bun add @firtoz/router-toolkit

Peer Dependencies

This package requires the following peer dependencies:

{
  "react": "^18.0.0 || ^19.0.0",
  "react-router": "^7.0.0",
  "zod": "^4.0.5"
}

Quick Start

Prerequisites: Make sure you have React Router 7 in framework mode set up. This toolkit requires the generated types from React Router's file-based routing.

1. Setup Your Route Files

Every route file needs to export a route constant for type inference:

// app/routes/users.tsx
import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';

export const route: RoutePath<"/users"> = "/users";

export const loader = async () => {
  return { users: [{ id: 1, name: "John" }] };
};

export default function UsersPage() {
  const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
  
  return (
    <div>
      <button onClick={() => fetcher.load()}>
        {fetcher.state === "loading" ? "Loading..." : "Refresh"}
      </button>
      {fetcher.data && <pre>{JSON.stringify(fetcher.data, null, 2)}</pre>}
    </div>
  );
}

2. Use in Other Routes

// app/routes/dashboard.tsx
import { useEffect } from 'react';
import { useDynamicFetcher } from '@firtoz/router-toolkit';

export default function Dashboard() {
  // Fetch data from the users route
  const usersFetcher = useDynamicFetcher<typeof import("./users")>("/users");
  
  useEffect(() => {
    usersFetcher.load(); // Load users data
  }, []);

  return (
    <div>
      <h1>Dashboard</h1>
      {usersFetcher.data?.users.map(user => (
        <div key={user.id}>{user.name}</div>
      ))}
    </div>
  );
}

3. Forms with Actions

// app/routes/create-user.tsx
import { useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';

export const route: RoutePath<"/create-user"> = "/create-user";

export async function action({ request }) {
  const formData = await request.formData();
  const name = formData.get("name");
  return { success: true, user: { name } };
}

export default function CreateUser() {
  const submitter = useDynamicSubmitter<typeof import("./create-user")>("/create-user");
  
  return (
    <submitter.Form method="post">
      <input name="name" placeholder="User name" required />
      <button type="submit">
        {submitter.state === "submitting" ? "Creating..." : "Create"}
      </button>
      {submitter.data?.success && <p>✅ User created!</p>}
    </submitter.Form>
  );
}

Key Points:

  • Export route: RoutePath<"your-path"> in every route file
  • Use useDynamicFetcher<typeof import("./route-file")> for type-safe data fetching
  • Use useDynamicSubmitter<typeof import("./route-file")> for type-safe form submission
  • Full TypeScript inference for fetcher.data and submitter.data

💡 Tip: Start with useDynamicFetcher for data loading, then add useDynamicSubmitter for forms. The useFetcherStateChanged hook is great for notifications and side effects.

Main Hooks

useDynamicFetcher

Enhanced version of React Router's useFetcher with type safety and query parameter support.

// app/routes/users.tsx
import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';

export const route: RoutePath<"/users"> = "/users";

export const loader = async () => {
  return {
    users: [
      { id: 1, name: "John Doe", email: "[email protected]" }
    ],
    timestamp: new Date().toISOString()
  };
};

export default function UsersPage() {
  const fetcher = useDynamicFetcher<typeof import("./users")>("/users");

  const handleRefresh = () => {
    fetcher.load(); // Basic fetch
  };

  const handleRefreshWithParams = () => {
    fetcher.load({ page: "1", limit: "10", sort: "name" }); // With query params
  };

  return (
    <div>
      <button onClick={handleRefresh} disabled={fetcher.state === "loading"}>
        {fetcher.state === "loading" ? "Loading..." : "Refresh Data"}
      </button>
      
      <button onClick={handleRefreshWithParams} disabled={fetcher.state === "loading"}>
        Load with Filters
      </button>
      
      {fetcher.data && (
        <div>
          <h3>Users ({fetcher.data.users.length}):</h3>
          <pre>{JSON.stringify(fetcher.data, null, 2)}</pre>
        </div>
      )}
    </div>
  );
}

useDynamicSubmitter

Type-safe form submission with Zod validation and enhanced submit functionality. Works seamlessly with route modules for full type inference.

// app/routes/contact.tsx
import { useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
import { z } from 'zod';
import type { Route } from './+types/contact';

// 1. Define your form schema
export const formSchema = z.object({
  name: z.string().min(1),
  email: z.email(),
});

// 2. Export route constant
export const route: RoutePath<"/contact"> = "/contact";

// 3. Define your action
export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // Simple validation
  if (!name || !email) {
    return {
      success: false,
      message: "Name and email are required"
    };
  }

  return {
    success: true,
    message: "Form submitted successfully!",
    submittedData: { name, email }
  };
}

// 4. Use the hook with typeof import for full type inference
export default function ContactForm() {
  const submitter = useDynamicSubmitter<typeof import("./contact")>("/contact");

  return (
    <div>
      <submitter.Form method="post">
        <div>
          <label htmlFor="name">Name:</label>
          <input
            id="name"
            name="name"
            type="text"
            required
          />
        </div>
        
        <div>
          <label htmlFor="email">Email:</label>
          <input
            id="email"
            name="email"
            type="email"
            required
          />
        </div>
        
        <button
          type="submit"
          disabled={submitter.state === "submitting"}
        >
          {submitter.state === "submitting" ? "Submitting..." : "Submit"}
        </button>
      </submitter.Form>

      {submitter.data && (
        <div>
          {submitter.data.success ? (
            <p>✅ {submitter.data.message}</p>
          ) : (
            <p>❌ {submitter.data.message}</p>
          )}
        </div>
      )}
    </div>
  );
}

ConcurrentSubmitterProvider + useConcurrentSubmitter

Run multiple submissions in parallel via the framework fetcher; each is tracked in operations with submittedData (for optimistic UI) and data when done. Wrap your app (or subtree) with ConcurrentSubmitterProvider, then use useConcurrentSubmitter<TInfo>() for typed submitJson / submitFormData with path and args per call.

  • submitJson(path, args, data, options?) — POST JSON to the given route; path/args are per call.
  • submitFormData(path, args, formData, submittedData?, options?) — POST multipart/form-data. Optional submittedData is a serializable object for the operations list (e.g. { type: "upload", label: "photo.jpg" }); FormData/File are not stored in state.
// Root (e.g. root.tsx)
import { ConcurrentSubmitterProvider } from '@firtoz/router-toolkit';

export default function App() {
  return (
    <ConcurrentSubmitterProvider>
      <Outlet />
    </ConcurrentSubmitterProvider>
  );
}

// Any route or component
import { useConcurrentSubmitter } from '@firtoz/router-toolkit';

function UploadList() {
  const { operations, submitFormData } = useConcurrentSubmitter<
    typeof import("./api.upload")
  >();

  const handleUpload = (file: File) => {
    const fd = new FormData();
    fd.set("file", file);
    submitFormData("/api/upload", undefined, fd, { type: "upload", label: file.name });
  };

  return (
    <ul>
      {Object.values(operations).map((op) => (
        <li key={op.id}>
          {op.status === "pending" && (
            <Skeleton>{(op.submittedData as { label?: string }).label}</Skeleton>
          )}
          {op.status === "done" && (
            <span>Saved: {op.data?.id}</span>
          )}
          {op.status === "error" && (
            <span>Failed: {String(op.error)}</span>
          )}
        </li>
      ))}
    </ul>
  );
}
  • operations: Record<string, Operation<T>> — each operation has id, status ("pending" | "done" | "error"), submittedData (payload or display object), and when done data (action response).
  • submitJson(path, args, data, options?) / submitFormData(path, args, formData, submittedData?, options?): each returns { id, promise }. submittedData defaults to {} and is used only for display in the operations list.

useFetcherStateChanged

Track changes in fetcher state and react to them. Perfect for triggering side effects, showing notifications, or handling state transitions in your application.

// app/routes/notification-form.tsx
import { useDynamicSubmitter, useFetcherStateChanged, type RoutePath } from '@firtoz/router-toolkit';
import { useState } from 'react';
import { z } from 'zod';
import type { Route } from './+types/notification-form';

export const route: RoutePath<"/notification-form"> = "/notification-form";

export const formSchema = z.object({
  message: z.string().min(1),
  type: z.enum(["info", "warning", "error"]),
});

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const message = formData.get("message") as string;
  const type = formData.get("type") as string;

  // Simulate processing
  await new Promise(resolve => setTimeout(resolve, 1000));

  return {
    success: true,
    message: "Notification sent!",
    data: { message, type }
  };
}

export default function NotificationForm() {
  const submitter = useDynamicSubmitter<typeof import("./notification-form")>("/notification-form");
  const [notifications, setNotifications] = useState<string[]>([]);

  // Track fetcher state changes for side effects
  useFetcherStateChanged(submitter, (lastState, newState) => {
    console.log(`Fetcher state changed from ${lastState} to ${newState}`);
    
    // Show success notification when form submission completes
    if (newState === 'idle' && lastState === 'submitting') {
      if (submitter.data?.success) {
        setNotifications(prev => [...prev, `✅ ${submitter.data.message}`]);
      } else {
        setNotifications(prev => [...prev, `❌ Submission failed`]);
      }
    }
    
    // Clear notifications when starting new submission
    if (newState === 'submitting' && lastState === 'idle') {
      setNotifications([]);
    }
  });

  return (
    <div>
      <h1>Send Notification</h1>
      
      <submitter.Form method="post">
        <div>
          <label htmlFor="message">Message:</label>
          <input
            id="message"
            name="message"
            type="text"
            required
          />
        </div>
        
        <div>
          <label htmlFor="type">Type:</label>
          <select id="type" name="type" required>
            <option value="info">Info</option>
            <option value="warning">Warning</option>
            <option value="error">Error</option>
          </select>
        </div>
        
        <button 
          type="submit" 
          disabled={submitter.state === 'submitting'}
        >
          {submitter.state === 'submitting' ? 'Sending...' : 'Send Notification'}
        </button>
        
        <p>Current state: <strong>{submitter.state}</strong></p>
      </submitter.Form>

      {/* Show notifications triggered by state changes */}
      {notifications.length > 0 && (
        <div style={{ marginTop: '20px' }}>
          <h3>Notifications:</h3>
          {notifications.map((notification, index) => (
            <div key={index} style={{ padding: '5px', margin: '5px 0', backgroundColor: '#f0f0f0' }}>
              {notification}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

Common Use Cases:

  • Notifications: Show success/error messages after form submissions
  • Analytics: Track form submission events and user interactions
  • UI Updates: Update other parts of the UI based on fetcher state
  • Side Effects: Trigger API calls, redirects, or other actions on state changes
  • Debugging: Log state transitions for debugging purposes

State Transitions:

  • idlesubmitting: Form submission started
  • submittingidle: Form submission completed (check fetcher.data for results)
  • idleloading: Data fetching started (with useDynamicFetcher)
  • loadingidle: Data fetching completed

Form Action Utilities

formAction

Type-safe form action wrapper that provides Zod validation and structured error handling for React Router actions. This utility integrates seamlessly with useDynamicSubmitter and the formSchema export pattern.

Features

  • Automatic form data validation using Zod schemas
  • 🛡️ Type-safe error handling with structured error types
  • 🔄 MaybeError integration for consistent error patterns
  • 🚀 React Router compatibility preserves redirects and responses
  • 📝 Full TypeScript support with inferred types from schemas

Basic Usage

// app/routes/register.tsx
import { z } from "zod";
import { formAction, type RoutePath } from "@firtoz/router-toolkit";
import { success, fail } from "@firtoz/maybe-error";

// Export the schema for useDynamicSubmitter integration
export const formSchema = z.object({
  email: z.string().email("Invalid email format"),
  password: z.string().min(8, "Password must be at least 8 characters"),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ["confirmPassword"],
});

export const action = formAction({
  schema: formSchema,
  handler: async (args, data) => {
    // data is fully typed based on the schema
    try {
      const user = await createUser({
        email: data.email,
        password: data.password,
      });
      
      return success({
        message: "Registration successful!",
        userId: user.id,
      });
    } catch (error) {
      return fail("Email already exists");
    }
  },
});

export const route: RoutePath<"/register"> = "/register";

Using with useDynamicSubmitter

The formAction utility works seamlessly with useDynamicSubmitter when you export a formSchema:

// app/routes/register.tsx (component)
import { useDynamicSubmitter } from "@firtoz/router-toolkit";

export default function Register() {
  const submitter = useDynamicSubmitter<typeof import("./register")>("/register");

  return (
    <submitter.Form method="post">
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      <input name="confirmPassword" type="password" required />
      <button type="submit" disabled={submitter.state === "submitting"}>
        {submitter.state === "submitting" ? "Registering..." : "Register"}
      </button>
    </submitter.Form>
  );
}

Error Handling

The formAction utility returns structured errors that you can handle in your components:

export default function Register() {
  const submitter = useDynamicSubmitter<typeof import("./register")>("/register");

  if (submitter.data && !submitter.data.success) {
    const error = submitter.data.error;
    
    switch (error.type) {
      case "validation":
        // Handle Zod validation errors
        console.log("Validation errors:", error.error);
        break;
      case "handler":
        // Handle business logic errors
        console.log("Handler error:", error.error);
        break;
      case "unknown":
        // Handle unexpected errors
        console.log("Unknown error occurred");
        break;
    }
  }

  // Rest of component...
}

Error Types

The formAction utility returns three types of errors:

  1. Validation Errors (type: "validation")

    • Occurs when form data doesn't match the Zod schema
    • Contains detailed field-level validation errors from Zod
    • The error.error field contains the result of z.treeifyError()
  2. Handler Errors (type: "handler")

    • Occurs when your handler function returns a fail() result
    • Contains the custom error you provided to fail()
    • The error.error field contains your custom error value
  3. Unknown Errors (type: "unknown")

    • Occurs when an unexpected exception is thrown
    • Logs the error to console for debugging
    • Does not expose the raw error to avoid information leakage

Advanced Features

File Uploads

const uploadSchema = z.object({
  title: z.string().min(1),
  file: z.instanceof(File),
  description: z.string().optional(),
});

export const action = formAction({
  schema: uploadSchema,
  handler: async (args, data) => {
    const uploadResult = await uploadFile(data.file, {
      title: data.title,
      description: data.description,
    });
    
    return success({ fileId: uploadResult.id });
  },
});

Complex Validation

const complexSchema = z.object({
  user: z.object({
    name: z.string().min(2),
    age: z.coerce.number().min(18),
  }),
  preferences: z.object({
    newsletter: z.boolean().default(false),
    theme: z.enum(["light", "dark"]).default("light"),
  }),
  terms: z.literal("on", { 
    errorMap: () => ({ message: "You must accept the terms" }) 
  }),
});

Redirects and Responses

React Router Response objects (like redirects) are automatically preserved:

export const action = formAction({
  schema: loginSchema,
  handler: async (args, data) => {
    const user = await authenticateUser(data.email, data.password);
    
    if (user) {
      // This redirect will be properly handled by React Router
      throw redirect("/dashboard");
    }
    
    return fail("Invalid credentials");
  },
});

Type Safety

The formAction utility provides full type safety:

  • Schema inference: Form data is typed based on your Zod schema
  • Handler types: Handler parameters are properly typed
  • Error types: Error handling is type-safe with discriminated unions
  • Integration: Works seamlessly with useDynamicSubmitter type inference

API Reference

function formAction<
  TSchema extends z.ZodTypeAny,
  TResult = undefined,
  TError = string,
  ActionArgs extends ActionFunctionArgs = ActionFunctionArgs,
>(config: {
  schema: TSchema;
  handler: (
    args: ActionArgs, 
    data: z.infer<TSchema>
  ) => Promise<MaybeError<TResult, TError>>;
}): (args: ActionArgs) => Promise<MaybeError<TResult, FormActionError<TError>>>;

type FormActionError<TError> =
  | { type: "validation"; error: ReturnType<typeof z.treeifyError> }
  | { type: "handler"; error: TError }
  | { type: "unknown" };

Type Utilities

RoutePath<T>

Type-safe route path helper that ensures you're using valid route paths from your React Router configuration.

import type { RoutePath } from '@firtoz/router-toolkit';

// Ensures "/users" is a valid route in your app
export const route: RoutePath<"/users"> = "/users";

// TypeScript error if route doesn't exist
export const invalidRoute: RoutePath<"/non-existent"> = "/non-existent"; // ❌ Error

This is the main type utility you'll use. It provides compile-time validation that your route paths actually exist in your React Router configuration.

Additional Utilities

useCachedFetch

Alternative to useDynamicFetcher that uses standard fetch() instead of React Router's fetcher system. Provides automatic caching and avoids route invalidation.

// app/routes/config.tsx
import { useCachedFetch, type RoutePath } from '@firtoz/router-toolkit';

export const route: RoutePath<"/config"> = "/config";

export const loader = async () => {
  return {
    apiUrl: "https://api.example.com",
    version: "1.0.0",
    features: ["auth", "payments"]
  };
};

export default function ConfigPage() {
  const { data, isLoading, error } = useCachedFetch<typeof import("./config")>("/config");

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <div>
      <h1>Configuration</h1>
      <p>API: {data?.apiUrl}</p>
      <p>Version: {data?.version}</p>
    </div>
  );
}

When to use useCachedFetch vs useDynamicFetcher:

  • useCachedFetch: Static data, configuration, content that rarely changes
  • useDynamicFetcher: Dynamic data, user-specific content, data that changes frequently

Configuration

Make sure your routes are properly typed in your react-router.config.ts:

// react-router.config.ts
import type { Config } from '@react-router/dev/config';

export default {
  // Your config
} satisfies Config;

// This will generate the Register types that the toolkit relies on

Real-World Examples

These examples are based on actual usage patterns from the router-toolkit test application. Each example is complete and can be copied directly into your project.

🚀 Quick Copy: Each example below is a complete, working route file. Copy the entire code block to get started immediately.

Data Loading with Refresh (Loader Test Pattern)

// app/routes/loader-test.tsx
import { useDynamicFetcher, type RoutePath } from '@firtoz/router-toolkit';

interface LoaderData {
  user: {
    id: number;
    name: string;
    email: string;
  };
  timestamp: string;
}

export const route: RoutePath<"/loader-test"> = "/loader-test";

export const loader = async (): Promise<LoaderData> => {
  // Simulate API call delay
  await new Promise((resolve) => setTimeout(resolve, 500));

  return {
    user: {
      id: 1,
      name: "John Doe",
      email: "[email protected]",
    },
    timestamp: new Date().toISOString(),
  };
};

export default function LoaderTest() {
  const fetcher = useDynamicFetcher<typeof import("./loader-test")>("/loader-test");

  const handleRefresh = () => {
    fetcher.load();
  };

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">Loader Test</h1>
      <p className="mb-4">Testing React Router useFetcher hook</p>

      <button
        type="button"
        onClick={handleRefresh}
        disabled={fetcher.state === "loading"}
        className="bg-blue-500 text-white px-4 py-2 rounded disabled:opacity-50"
      >
        {fetcher.state === "loading" ? "Loading..." : "Refresh Data"}
      </button>

      <div className="mt-6">
        <h2 className="text-lg font-semibold mb-2">Fetcher State:</h2>
        <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
          {JSON.stringify({ state: fetcher.state }, null, 2)}
        </pre>
      </div>

      {fetcher.data && (
        <div className="mt-6">
          <h2 className="text-lg font-semibold mb-2">Fetched Data:</h2>
          <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
            {JSON.stringify(fetcher.data, null, 2)}
          </pre>
        </div>
      )}

      {fetcher.state === "idle" && fetcher.data && (
        <div className="mt-4 p-3 bg-green-100 rounded">
          <p className="text-green-800">✅ Data loaded successfully!</p>
        </div>
      )}
    </div>
  );
}

Form Submission (Action Test Pattern)

// app/routes/action-test.tsx
import { useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
import { z } from 'zod';
import type { Route } from './+types/action-test';

interface ActionData {
  success: boolean;
  message: string;
  submittedData?: {
    name: string;
    email: string;
  };
}

export const route: RoutePath<"/action-test"> = "/action-test";

export const formSchema = z.object({
  name: z.string().min(1),
  email: z.email(),
});

export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
  const formData = await request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // Simulate processing delay
  await new Promise((resolve) => setTimeout(resolve, 1000));

  // Simple validation
  if (!name || !email) {
    return {
      success: false,
      message: "Name and email are required",
    };
  }

  return {
    success: true,
    message: "Form submitted successfully!",
    submittedData: { name, email },
  };
}

export default function ActionTest() {
  const submitter = useDynamicSubmitter<typeof import("./action-test")>("/action-test");

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">Action Test</h1>
      <p className="mb-4">Testing React Router form actions</p>

      <submitter.Form method="post" className="space-y-4 max-w-md">
        <div>
          <label htmlFor="name" className="block text-sm font-medium mb-1">
            Name:
          </label>
          <input
            id="name"
            name="name"
            type="text"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>

        <div>
          <label htmlFor="email" className="block text-sm font-medium mb-1">
            Email:
          </label>
          <input
            id="email"
            name="email"
            type="email"
            required
            className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
          />
        </div>

        <button
          type="submit"
          disabled={submitter.state === "submitting"}
          className="bg-green-500 text-white px-4 py-2 rounded disabled:opacity-50"
        >
          {submitter.state === "submitting" ? "Submitting..." : "Submit"}
        </button>
      </submitter.Form>

      {submitter.data && (
        <div className="mt-6">
          <h2 className="text-lg font-semibold mb-2">Action Result:</h2>
          <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
            {JSON.stringify(submitter.data, null, 2)}
          </pre>

          {submitter.data.success ? (
            <div className="mt-4 p-3 bg-green-100 rounded">
              <p className="text-green-800">✅ {submitter.data.message}</p>
            </div>
          ) : (
            <div className="mt-4 p-3 bg-red-100 rounded">
              <p className="text-red-800">❌ {submitter.data.message}</p>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

Combined Loader and Action (Full CRUD Pattern)

// app/routes/combined-test.tsx
import {
  useDynamicFetcher,
  useDynamicSubmitter,
  type RoutePath,
} from '@firtoz/router-toolkit';
import { useLoaderData } from 'react-router';
import { z } from 'zod';
import type { Route } from './+types/combined-test';

interface User {
  id: number;
  name: string;
  email: string;
  lastUpdated: string;
}

interface LoaderData {
  user: User;
}

type ActionData = {
  success: boolean;
  message: string;
  updatedUser?: User;
};

export const route: RoutePath<"/combined-test"> = "/combined-test";

export const formSchema = z.object({
  name: z.string().min(1),
  email: z.email(),
});

export const loader = async (): Promise<LoaderData> => {
  await new Promise((resolve) => setTimeout(resolve, 300));

  return {
    user: {
      id: 1,
      name: "John Doe",
      email: "[email protected]",
      lastUpdated: new Date().toISOString(),
    },
  };
};

export async function action({ request }: Route.ActionArgs): Promise<ActionData> {
  const formData = await request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  await new Promise((resolve) => setTimeout(resolve, 500));

  if (!name || !email) {
    return {
      success: false,
      message: "Name and email are required",
    };
  }

  const updatedUser: User = {
    id: 1,
    name,
    email,
    lastUpdated: new Date().toISOString(),
  };

  return {
    success: true,
    message: "User updated successfully!",
    updatedUser,
  };
}

export default function CombinedTest() {
  const loaderData = useLoaderData<LoaderData>();
  const fetcher = useDynamicFetcher<typeof import("./combined-test")>("/combined-test");
  const submitter = useDynamicSubmitter<typeof import("./combined-test")>("/combined-test");

  return (
    <div className="p-6">
      <h1 className="text-2xl font-bold mb-4">Combined Test</h1>
      <p className="mb-4">Testing both loader data and form actions</p>

      <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
        {/* Loader Data Section */}
        <div>
          <h2 className="text-lg font-semibold mb-3">Current User Data</h2>
          <div className="bg-blue-50 p-4 rounded">
            <h3 className="font-medium">Loaded from Server:</h3>
            <pre className="mt-2 text-sm bg-gray-200 p-3 rounded text-gray-800">
              {JSON.stringify(loaderData.user, null, 2)}
            </pre>
          </div>
        </div>

        {/* Action Form Section */}
        <div>
          <h2 className="text-lg font-semibold mb-3">Update User</h2>
          <submitter.Form method="post" className="space-y-4">
            <div>
              <label htmlFor="name" className="block text-sm font-medium mb-1">
                Name:
              </label>
              <input
                id="name"
                name="name"
                type="text"
                defaultValue={loaderData.user.name}
                required
                className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
              />
            </div>

            <div>
              <label htmlFor="email" className="block text-sm font-medium mb-1">
                Email:
              </label>
              <input
                id="email"
                name="email"
                type="email"
                defaultValue={loaderData.user.email}
                required
                className="w-full px-3 py-2 border border-gray-300 rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
              />
            </div>

            <button
              type="submit"
              disabled={submitter.state === "submitting"}
              className="bg-purple-500 text-white px-4 py-2 rounded disabled:opacity-50"
            >
              {submitter.state === "submitting" ? "Updating..." : "Update User"}
            </button>
          </submitter.Form>
        </div>
      </div>

      {/* Status Section */}
      <div className="mt-6">
        <h2 className="text-lg font-semibold mb-2">Action Status:</h2>
        <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
          {JSON.stringify({ state: submitter.state }, null, 2)}
        </pre>
      </div>

      {submitter.data && (
        <div className="mt-6">
          <h2 className="text-lg font-semibold mb-2">Action Result:</h2>
          <pre className="bg-gray-200 p-3 rounded text-sm text-gray-800">
            {JSON.stringify(submitter.data, null, 2)}
          </pre>

          {submitter.data.success ? (
            <div className="mt-4 p-3 bg-green-100 rounded">
              <p className="text-green-800">✅ {submitter.data.message}</p>
              {submitter.data.updatedUser && (
                <p className="text-sm text-green-700 mt-1">
                  Tip: Reload the page to see if data persists (it won't in this demo)
                </p>
              )}
            </div>
          ) : (
            <div className="mt-4 p-3 bg-red-100 rounded">
              <p className="text-red-800">❌ {submitter.data.message}</p>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

MaybeError Utility

The router-toolkit includes the @firtoz/maybe-error package, which provides type-safe error handling utilities using discriminated unions. This is perfect for handling operations that may fail in your route loaders and actions.

Basic Usage

import { success, fail, type MaybeError } from '@firtoz/router-toolkit';

// Define a function that may fail
function divide(a: number, b: number): MaybeError<number> {
  if (b === 0) {
    return fail("Division by zero");
  }
  return success(a / b);
}

// Type-safe error handling
const result = divide(10, 2);
if (result.success) {
  console.log(result.result); // 5 - TypeScript knows this is a number
} else {
  console.error(result.error); // "Division by zero" - TypeScript knows this is a string
}

Route Loader with Error Handling

// app/routes/user-profile.tsx
import { success, fail, type MaybeError, type RoutePath } from '@firtoz/router-toolkit';
import type { Route } from './+types/user-profile';

interface User {
  id: string;
  name: string;
  email: string;
}

interface ApiError {
  code: number;
  message: string;
}

export const route: RoutePath<"/user-profile/:id"> = "/user-profile/:id";

// Loader that returns MaybeError for type-safe error handling
export const loader = async ({ params }: Route.LoaderArgs): Promise<MaybeError<User, ApiError>> => {
  try {
    const response = await fetch(`/api/users/${params.id}`);
    
    if (!response.ok) {
      return fail({
        code: response.status,
        message: response.status === 404 ? "User not found" : "Failed to fetch user"
      });
    }
    
    const user = await response.json();
    return success(user);
  } catch (error) {
    return fail({
      code: 500,
      message: "Network error occurred"
    });
  }
};

export default function UserProfile() {
  const fetcher = useDynamicFetcher<typeof import("./user-profile")>("/user-profile/:id", { id: "123" });
  
  // Handle the MaybeError result
  if (!fetcher.data) {
    return <div>Loading...</div>;
  }
  
  if (!fetcher.data.success) {
    return (
      <div className="error">
        <h2>Error {fetcher.data.error.code}</h2>
        <p>{fetcher.data.error.message}</p>
      </div>
    );
  }
  
  return (
    <div>
      <h1>{fetcher.data.result.name}</h1>
      <p>Email: {fetcher.data.result.email}</p>
    </div>
  );
}

Action with Error Handling

// app/routes/create-user.tsx
import { success, fail, type MaybeError, useDynamicSubmitter, type RoutePath } from '@firtoz/router-toolkit';
import { z } from 'zod';
import type { Route } from './+types/create-user';

export const route: RoutePath<"/create-user"> = "/create-user";

export const formSchema = z.object({
  name: z.string().min(1),
  email: z.email(),
});

interface ValidationError {
  field: string;
  message: string;
}

export async function action({ request }: Route.ActionArgs): Promise<MaybeError<User, ValidationError[]>> {
  const formData = await request.formData();
  const name = formData.get("name") as string;
  const email = formData.get("email") as string;

  // Validation
  const errors: ValidationError[] = [];
  if (!name) errors.push({ field: "name", message: "Name is required" });
  if (!email) errors.push({ field: "email", message: "Email is required" });
  if (email && !email.includes("@")) errors.push({ field: "email", message: "Invalid email format" });

  if (errors.length > 0) {
    return fail(errors);
  }

  try {
    const response = await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ name, email })
    });

    if (!response.ok) {
      return fail([{ field: "general", message: "Failed to create user" }]);
    }

    const user = await response.json();
    return success(user);
  } catch (error) {
    return fail([{ field: "general", message: "Network error occurred" }]);
  }
}

export default function CreateUser() {
  const submitter = useDynamicSubmitter<typeof import("./create-user")>("/create-user");

  return (
    <div>
      <h1>Create User</h1>
      
      <submitter.Form method="post">
        <div>
          <label htmlFor="name">Name:</label>
          <input id="name" name="name" type="text" required />
        </div>
        
        <div>
          <label htmlFor="email">Email:</label>
          <input id="email" name="email" type="email" required />
        </div>
        
        <button type="submit" disabled={submitter.state === "submitting"}>
          {submitter.state === "submitting" ? "Creating..." : "Create User"}
        </button>
      </submitter.Form>

      {submitter.data && (
        <div>
          {submitter.data.success ? (
            <div className="success">
              <h3>User Created!</h3>
              <p>Name: {submitter.data.result.name}</p>
              <p>Email: {submitter.data.result.email}</p>
            </div>
          ) : (
            <div className="errors">
              <h3>Validation Errors:</h3>
              <ul>
                {submitter.data.error.map((error, index) => (
                  <li key={index}>
                    <strong>{error.field}:</strong> {error.message}
                  </li>
                ))}
              </ul>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

MaybeError API Reference

// Type definitions
type MaybeError<T = undefined, TError = string> = DefiniteSuccess<T> | DefiniteError<TError>;

type DefiniteSuccess<T> = {
  success: true;
  result: T; // Optional if T is undefined
};

type DefiniteError<TError> = {
  success: false;
  error: TError;
};

// Utility functions
const success = <T>(value: T): DefiniteSuccess<T> => ({ success: true, result: value });
const fail = <TError>(error: TError): DefiniteError<TError> => ({ success: false, error });

// Type utility
type AssumeSuccess<T extends MaybeError<unknown>> = /* extracts the success type */;

Benefits:

  • Type Safety: TypeScript enforces error handling at compile time
  • Explicit Error Handling: No more forgotten try-catch blocks
  • Consistent API: Same pattern across all operations that may fail
  • Composable: Easy to chain operations and handle errors at the right level

Troubleshooting

Common Issues

❌ "Type 'string' is not assignable to type 'RoutePath<...>'"

// ❌ Wrong - using string literal
export const route = "/users";

// ✅ Correct - using RoutePath type
export const route: RoutePath<"/users"> = "/users";

❌ "Property 'data' does not exist on type 'any'"

// ❌ Wrong - missing typeof import
const fetcher = useDynamicFetcher("/users");

// ✅ Correct - with typeof import for type inference
const fetcher = useDynamicFetcher<typeof import("./users")>("/users");

❌ "Cannot find module './+types/route-name'"

  • Make sure you're using React Router 7 in framework mode
  • Check that your react-router.config.ts is properly configured
  • The +types directory is auto-generated by React Router

❌ "fetcher.data is always undefined"

// ❌ Wrong - forgot to call load()
const fetcher = useDynamicFetcher<typeof import("./users")>("/users");

// ✅ Correct - call load() to fetch data
const fetcher = useDynamicFetcher<typeof import("./users")>("/users");
useEffect(() => {
  fetcher.load();
}, []);

Getting Help

  • Check the React Router 7 documentation for framework mode setup
  • Look at the test application in the tests/ directory for working examples
  • Open an issue on GitHub if you find a bug

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.

License

MIT © Firtina Ozbalikchi

Links