Skip to content

TrueFit/zodix-react-router

 
 

Repository files navigation

Zodix

Build Status npm version

Zodix is a collection of Zod utilities for React Router loaders and actions. It abstracts the complexity of parsing and validating FormData and URLSearchParams so your loaders/actions stay clean and are strongly typed.

React Router loaders often look like:

// Note: Types like ClientLoaderArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function loader({ params, request }: ClientLoaderArgs) {
  const { id } = params;
  const url = new URL(request.url);
  const count = url.searchParams.get('count') || '10';
  if (typeof id !== 'string') {
    throw new Error('id must be a string');
  }
  const countNumber = parseInt(count, 10);
  if (isNaN(countNumber)) {
    throw new Error('count must be a number');
  }
  // Fetch data with id and countNumber
};

Here is the same loader with Zodix:

// Note: Types like ClientLoaderArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function loader({ params, request }: ClientLoaderArgs) {
  const { id } = zx.parseParams(params, { id: z.string() });
  const { count } = zx.parseQuery(request, { count: zx.NumAsString });
  // Fetch data with id and countNumber
};

Check the example app for complete examples of common patterns.

Highlights

  • Significantly reduce React Router action/loader bloat
  • Avoid the oddities of FormData and URLSearchParams
  • Tiny with no external dependencies (Less than 1kb gzipped)
  • Use existing Zod schemas, or write them on the fly
  • Custom Zod schemas for stringified numbers, booleans, and checkboxes
  • Throw errors meant for React Router ErrorBoundary by default
  • Supports non-throwing parsing for custom validation/errors
  • Full unit test coverage

Setup

Install with npm, yarn, pnpm, etc.

npm install zodix zod react-router

Import the zx object, or specific functions:

import { zx } from 'zodix';
// import { parseParams, NumAsString } from 'zodix';

Usage

zx.parseParams(params: Params, schema: Schema)

Parse and validate the Params object from clientLoaderArgs.params or clientActionArgs.params using a Zod shape:

// Note: Types like ClientLoaderArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function loader({ params }: ClientLoaderArgs) {
  const { userId, noteId } = zx.parseParams(params, {
    userId: z.string(),
    noteId: z.string(),
  });
};

The same as above, but using an existing Zod object schema:

// This is if you have many pages that share the same params.
export const ParamsSchema = z.object({ userId: z.string(), noteId: z.string() });

// Note: Types like ClientLoaderArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function loader({ params }: ClientLoaderArgs) {
  const { userId, noteId } = zx.parseParams(params, ParamsSchema);
};

zx.parseForm(request: Request, schema: Schema)

Parse and validate FormData from a Request in a React Router action and avoid the tedious FormData dance:

// Note: Types like ClientActionArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function action({ request }: ClientActionArgs) {
  const { email, password, saveSession } = await zx.parseForm(request, {
    email: z.string().email(),
    password: z.string().min(6),
    saveSession: zx.CheckboxAsString,
  });
};

Integrate with existing Zod schemas and models/controllers:

// db.ts
export const CreateNoteSchema = z.object({
  userId: z.string(),
  title: z.string(),
  category: NoteCategorySchema.optional(), // Assuming NoteCategorySchema is defined elsewhere
});

export function createNote(note: z.infer<typeof CreateNoteSchema>) { /* ... */ }
import { CreateNoteSchema, createNote } from './db'; // Assuming db.ts is in the same directory or adjust path

// Note: Types like ClientActionArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function action({ request }: ClientActionArgs) {
  const formData = await zx.parseForm(request, CreateNoteSchema);
  createNote(formData); // No TypeScript errors here
};

zx.parseQuery(request: Request, schema: Schema)

Parse and validate the query string (search params) of a Request:

// Note: Types like ClientLoaderArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function loader({ request }: ClientLoaderArgs) {
  const { count, page } = zx.parseQuery(request, {
    // NumAsString parses a string number ("5") and returns a number (5)
    count: zx.NumAsString,
    page: zx.NumAsString,
  });
};

zx.parseParamsSafe() / zx.parseFormSafe() / zx.parseQuerySafe()

These work the same as the non-safe versions, but don't throw when validation fails. They use z.parseSafe() and always return an object with the parsed data or an error.

// Note: Types like ClientActionArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function action({ request }: ClientActionArgs) { // Changed args to { request }
  const results = await zx.parseFormSafe(request, { // Changed args.request to request
    email: z.string().email({ message: "Invalid email" }),
    password: z.string().min(8, { message: "Password must be at least 8 characters" }),
  });
  // Assuming 'json' is a helper from your React Router setup, like from 'react-router-dom'
  // For example: import { json } from "react-router-dom";
  return json({
    success: results.success,
    error: results.error, // Sending the whole ZodError object
  });
}

Check the login page example for a full example.

Error Handling

parseParams(), parseForm(), and parseQuery()

These functions throw a 400 Response when the parsing fails. This works nicely with React Router error elements/boundaries and should be used for parsing things that should rarely fail and don't require custom error handling. You can pass a custom error message or status code.

// Note: Types like ClientLoaderArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function loader({ params }: ClientLoaderArgs) {
  const { postId } = zx.parseParams(
    params,
    { postId: zx.NumAsString },
    { message: "Invalid postId parameter", status: 400 }
  );
  // const post = await getPost(postId); // Assuming getPost is defined
  // return { post };
  return { postId }; // Simplified for example
}

// In your route component file, you might have an ErrorBoundary
// (or configure a global one)
// import { isRouteErrorResponse, useRouteError } from "react-router-dom";
//
// export function ErrorBoundary() {
//   const error = useRouteError();
//   if (isRouteErrorResponse(error)) {
//     return <h1>Caught error: {error.status} {error.statusText}</h1>;
//   }
//   // Handle other error types if needed
//   return <h1>Something went wrong</h1>;
// }

Check the post page example for a full example. (Note: The original link was to $postId.tsx, updated to posts.tsx as per example file structure, adjust if $postId.tsx is the correct one and exists)

parseParamsSafe(), parseFormSafe(), and parseQuerySafe()

These functions are great for form validation because they don't throw when parsing fails. They always return an object with this shape:

{ success: boolean; error?: ZodError; data?: <parsed data>; }

You can then handle errors in the action and access them in the component using useActionData(). Check the login page example for a full example.

Helper Zod Schemas

Because FormData and URLSearchParams serialize all values to strings, you often end up with things like "5", "on" and "true". The helper schemas handle parsing and validating strings representing other data types and are meant to be used with the parse functions.

Available Helpers

zx.BoolAsString

  • "true"true
  • "false"false
  • "notboolean" → throws ZodError

zx.CheckboxAsString

  • "on"true
  • undefinedfalse
  • "anythingbuton" → throws ZodError

zx.IntAsString

  • "3"3
  • "3.14" → throws ZodError
  • "notanumber" → throws ZodError

zx.NumAsString

  • "3"3
  • "3.14"3.14
  • "notanumber" → throws ZodError

See the tests for more details.

Usage

const Schema = z.object({
  isAdmin: zx.BoolAsString,
  agreedToTerms: zx.CheckboxAsString,
  age: zx.IntAsString,
  cost: zx.NumAsString,
});

const parsed = Schema.parse({
  isAdmin: 'true',
  agreedToTerms: 'on',
  age: '38',
  cost: '10.99'
});

/*
parsed = {
  isAdmin: true,
  agreedToTerms: true,
  age: 38,
  cost: 10.99
}
*/

Extras

Custom URLSearchParams parsing

You may have URLs with query string that look like ?ids[]=1&ids[]=2 or ?ids=1,2 that aren't handled as desired by the built in URLSearchParams parsing.

You can pass a custom function, or use a library like query-string to parse them with Zodix.

// Create a custom parser function
type ParserFunction = (request: Request) => Record<string, unknown>; // Changed params to request and return type
const customParser: ParserFunction = (request) => {
  const url = new URL(request.url);
  // Example using query-string library
  // import queryString from 'query-string';
  // return queryString.parse(url.search, { arrayFormat: 'bracket' });
  // Replace with your custom parsing logic
  const params = new URLSearchParams(url.search);
  const ids = params.getAll('ids[]');
  return { ids };
};

// Parse non-standard search params
// const search = new URLSearchParams(`?ids[]=id1&ids[]=id2`); // This line is not needed if parser takes request
export async function loader({ request }: ClientLoaderArgs) { // Assuming this is in a loader
  const { ids } = zx.parseQuery(
    request,
    { ids: z.array(z.string()) },
    { parser: customParser } // Corrected: parser should be in the third options argument
  );
  // ids = ['id1', 'id2']
  return { ids };
}

Actions with Multiple Intents

Zod discriminated unions are great for helping with actions that handle multiple intents like this:

// This adds type narrowing by the intent property
const Schema = z.discriminatedUnion('intent', [
  z.object({ intent: z.literal('delete'), id: z.string() }),
  z.object({ intent: z.literal('create'), name: z.string() }),
]);

// Note: Types like ClientActionArgs are often generated by tools like @react-router/dev
// or imported from react-router. Adjust based on your project setup.
export async function action({ request }: ClientActionArgs) {
  const data = await zx.parseForm(request, Schema);
  switch (data.intent) {
    case 'delete':
      // data is now narrowed to { intent: 'delete', id: string }
      // perform delete operation
      return { ok: true }; // Example response
    case 'create':
      // data is now narrowed to { intent: 'create', name: string }
      // perform create operation
      return { ok: true }; // Example response
    default:
      // data is now narrowed to never. This will error if a case is missing.
      // However, with Zod discriminated unions, this path should ideally not be hit
      // if the schema covers all intents passed from the form.
      // Consider throwing an error or returning an error response.
      const _exhaustiveCheck: never = data;
      return { error: 'Invalid intent' }; // Or throw new Response("Invalid intent", { status: 400 });
  }
};

About

Zod utilities for React Router loaders and actions.

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • TypeScript 94.6%
  • JavaScript 5.4%