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

@hirely/hooks

v1.1.0

Published

Type-safe React hooks for forms (Zod + React Query) and Next.js URL state sync.

Readme

@hirely/hooks

npm version Bundle Size License: MIT TypeScript React Next.js

The last form hook you'll ever need — plus a URL-state hook for good measure.

Type-safe React form management with Zod validation, built-in fetch, optional axios,
optional toast — bring your own HTTP client, bring your own notifications.
And usePageSync for keeping pagination state in the URL.


Why @hirely/hooks?

// ❌ Before — wiring everything manually, every time
const schema = z.object({ email: z.string().email() });
const { register, handleSubmit, formState } = useForm({ resolver: zodResolver(schema) });
const [loading, setLoading] = useState(false);
const onSubmit = handleSubmit(async (data) => {
  setLoading(true);
  try {
    await fetch("/api/login", { method: "POST", body: JSON.stringify(data),
      headers: { "Content-Type": "application/json" } });
    toast.success("Logged in!");
  } catch (e) {
    toast.error(e.message);
  } finally { setLoading(false); }
});

// ✅ After — one hook call
const { register, onSubmit, loading } = useFormHandler({
  schema,
  endpoint: "/api/login",                        // uses fetch automatically
  notify: (msg, type) => toast[type](msg),        // your own toast
});
// ❌ Before — pagination state scattered across useState + useEffect + router
const [page, setPage] = useState(1);
useEffect(() => { /* sync ?page= to state, handle back button... */ }, []);

// ✅ After — one hook call
const { currentPage, nextPage, prevPage, isFirstPage, isLastPage } =
  usePageSync({ maxPage: 20 });

Features

| | Feature | Detail | |---|---|---| | | End-to-end type safety | Zod schema → inferred form types, auto-complete, compile-time checks | | | Built-in fetch | Works out of the box with endpoint — no HTTP client setup needed | | | Bring your own client | Pass axiosInstance, service, or any async function | | | Bring your own toast | Pass notify with your toast library — no sonner auto-import | | | Zero bundler issues | No dynamic imports, no require(), no node:module — Turbopack-safe | | | TanStack Query v5 | useFormMutation merges form state + React Query mutations | | | Global defaults | createFormHandler factory and FormHandlerProvider context | | | URL state sync | usePageSync keeps pagination in ?page= with back/forward support | | | Dual ESM + CJS | ESM .mjs, CJS .cjs, TypeScript .d.ts, "use client" ready |


Installation

bun add @hirely/hooks        # Bun
npm install @hirely/hooks    # npm
pnpm add @hirely/hooks       # pnpm
yarn add @hirely/hooks       # Yarn

Required peer dependencies

bun add react react-hook-form zod

Optional peer dependencies

| Package | When you need it | |---|---| | @tanstack/react-query | When using useFormMutation | | next (≥ 13.4) | When using usePageSync |

No axios or sonner required. The library uses native fetch by default and accepts any toast function via notify.


Table of Contents


Quick Start

"use client";

import { useFormHandler } from "@hirely/hooks";
import { toast } from "sonner"; // or any toast library
import { z } from "zod";

const loginSchema = z.object({
  email: z.string().email("Invalid email address"),
  password: z.string().min(8, "Password must be at least 8 characters"),
});

export function LoginForm() {
  const { register, onSubmit, loading, error, formState } = useFormHandler({
    schema: loginSchema,
    endpoint: "/api/auth/login",              // ← uses fetch automatically
    notify: (msg, type) => toast[type](msg),  // ← your own toast
    onSuccess: () => router.push("/dashboard"),
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...register("email")} type="email" placeholder="Email" />
      {formState.errors.email && <p>{formState.errors.email.message}</p>}

      <input {...register("password")} type="password" placeholder="Password" />
      {formState.errors.password && <p>{formState.errors.password.message}</p>}

      {error && <p>{error.message}</p>}

      <button type="submit" disabled={loading}>
        {loading ? "Signing in..." : "Sign In"}
      </button>
    </form>
  );
}

HTTP Clients

Native fetch (default)

When you provide endpoint, the hook uses the browser's built-in fetch — no configuration needed:

useFormHandler({
  schema,
  endpoint: "/api/users",        // POST by default
  method: "patch",               // optional: post | patch | put | delete
});

Pass extra headers or credentials via axiosConfig:

useFormHandler({
  schema,
  endpoint: "/api/users",
  axiosConfig: {
    headers: { "X-Custom-Header": "value" },
    withCredentials: true,  // sends cookies with the request
  },
});

Axios

Pass your own configured axios instance to use it instead of fetch:

import axios from "axios";

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  withCredentials: true,
});

useFormHandler({
  schema,
  endpoint: "/users",       // resolves against axios baseURL
  axiosInstance: api,       // ← use your axios instance
});

Custom service

Pass any async function — axios, custom fetch wrapper, SDK, etc:

useFormHandler({
  schema,
  service: async (data) => {
    const res = await fetch("/api/users", {
      method: "POST",
      headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
      body: JSON.stringify(data),
    });
    if (!res.ok) throw new Error("Failed");
    return res.json();
  },
});

Next.js Server Actions

// app/actions/auth.ts
"use server";
import { z } from "zod";

const signUpSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
});

export async function signUpAction(data: z.infer<typeof signUpSchema>) {
  // database / external API call
  return { success: true, message: "Account created!", userId: "u_123" };
}
// components/SignUpForm.tsx
"use client";
import { useFormHandler } from "@hirely/hooks";
import { signUpAction } from "@/app/actions/auth";
import { toast } from "sonner";
import { z } from "zod";

const schema = z.object({ name: z.string().min(2), email: z.string().email() });

export function SignUpForm() {
  const { register, onSubmit, loading } = useFormHandler({
    schema,
    service: signUpAction,
    notify: (msg, type) => toast[type](msg),
    onSuccess: (data) => console.log("User ID:", data.userId),
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...register("name")} placeholder="Name" />
      <input {...register("email")} placeholder="Email" />
      <button type="submit" disabled={loading}>
        {loading ? "Creating..." : "Create Account"}
      </button>
    </form>
  );
}

Notifications

The library never imports a toast library. Pass your own notify function:

// Sonner
import { toast } from "sonner";
notify: (msg, type) => toast[type](msg)

// React Hot Toast
import toast from "react-hot-toast";
notify: (msg, type) => type === "success" ? toast.success(msg) : toast.error(msg)

// Shadcn/ui toast
import { toast } from "@/components/ui/use-toast";
notify: (msg, type) => toast({ title: msg, variant: type === "error" ? "destructive" : "default" })

// Any custom function
notify: (msg, type) => console.log(`[${type}] ${msg}`)

If notify is not provided, notifications are silently skipped.


Core API — useFormHandler

const form = useFormHandler(options);

Options

| Option | Type | Default | Description | |---|---|---|---| | schema | ZodType<FieldValues> | required | Zod schema for validation and type inference | | endpoint | string | undefined | API URL — uses native fetch unless axiosInstance is also provided | | method | 'post' \| 'patch' \| 'put' \| 'delete' | 'post' | HTTP method | | service | (data: TData) => Promise<any> | undefined | Custom async function — overrides endpoint | | axiosInstance | AxiosInstance | undefined | Your axios instance — used with endpoint instead of fetch | | defaultValues | DefaultValues<TData> | undefined | Initial field values | | values | TData | undefined | Reactive external values synced into the form | | transformData | (data: TData) => any | undefined | Transform payload before submission | | onMutate | (data: TData) => any \| Promise<any> | undefined | Pre-submission hook; return value becomes context | | onSuccess | (data: any, context?: any) => void | undefined | Called after a successful submission | | onError | (error: any, context?: any) => void | undefined | Called on submission failure | | onSubmitStart | () => void | undefined | Called when submission begins | | onSubmitEnd | () => void | undefined | Called when submission ends (success or error) | | notify | (message: string, type: 'success' \| 'error') => void | silent | Your toast/notification function | | resetOptions | { resetAfterSuccess?, keepDefaultValues?, keepDirty? } | { resetAfterSuccess: true, keepDefaultValues: true } | Controls form reset on success | | useFormData | boolean | false | Serialize payload as FormData (supports File, Blob) | | enableAbort | boolean | false | Enables abort() to cancel in-flight requests | | axiosConfig | { headers?, withCredentials?, ... } | {} | Extra options merged into fetch or axios requests | | mode | 'onSubmit' \| 'onBlur' \| 'onChange' \| 'onTouched' \| 'all' | 'onSubmit' | Validation trigger mode | | reValidateMode | 'onChange' \| 'onBlur' \| 'onSubmit' | 'onChange' | Re-validation mode after first submit | | parseResponse | (res: any) => { success: boolean, message?: string } | built-in | Custom response shape parser | | parseError | (err: any) => { message: string } | built-in | Custom error message extractor |

Return Value

Returns all of React Hook Form's useForm (register, watch, setValue, getValues, control, formState, reset, etc.) plus:

| Property | Type | Description | |---|---|---| | onSubmit | (e?: unknown) => Promise<void> | Attach directly to <form onSubmit={onSubmit}> | | loading | boolean | true while the request is in-flight | | error | Error \| null | Submission error, if any | | setError | React.Dispatch | Manually set or clear the error state | | abort | (() => void) \| undefined | Cancel the current request (only when enableAbort: true) |


Recipes

File Uploads

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

const { register, onSubmit, setValue } = useFormHandler({
  schema: uploadSchema,
  endpoint: "/api/upload",
  useFormData: true,  // auto-sets Content-Type: multipart/form-data
});

<input
  type="file"
  onChange={(e) => setValue("avatar", e.target.files?.[0])}
/>

Request Cancellation

const { onSubmit, abort, loading } = useFormHandler({
  schema: reportSchema,
  endpoint: "/api/generate",
  enableAbort: true,
});

<form onSubmit={onSubmit}>
  <button type="submit" disabled={loading}>Generate</button>
  {loading && <button type="button" onClick={abort}>✕ Cancel</button>}
</form>

Optimistic Updates

const { onSubmit } = useFormHandler({
  schema: taskSchema,
  endpoint: "/api/tasks/42",
  method: "patch",
  onMutate: async (newData) => {
    const previous = currentTask;
    setTask((prev) => ({ ...prev, ...newData }));
    return { previous };
  },
  onError: (_error, context) => {
    setTask(context.previous); // rollback
  },
});

Custom Response & Error Parsers

useFormHandler({
  schema: mySchema,
  endpoint: "/api/data",
  parseResponse: (res) => ({
    success: res.statusCode === 200,
    message: res.statusText,
    data: res.payload,
  }),
  parseError: (err) => ({
    message: err.response?.data?.errorDescription ?? err.message ?? "Something went wrong",
  }),
});

TanStack Query — useFormMutation

Combines form state with React Query's cache management, retry logic, and mutation lifecycle:

"use client";

import { useFormMutation } from "@hirely/hooks";
import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { z } from "zod";

const postSchema = z.object({
  title: z.string().min(1, "Title is required"),
  content: z.string().min(10, "Content too short"),
});

export function CreatePost() {
  const queryClient = useQueryClient();

  const {
    register,
    onSubmit,
    isPending,
    isError,
    error,
    formError,
    data,
    reset,         // resets both form AND mutation
    resetForm,     // resets only the form
    resetMutation,
    status,
  } = useFormMutation({
    schema: postSchema,
    endpoint: "/api/posts",           // uses fetch automatically
    notify: (msg, type) => toast[type](msg),
    mutationOptions: {
      retry: 2,
      onSuccess: () => queryClient.invalidateQueries({ queryKey: ["posts"] }),
    },
  });

  return (
    <form onSubmit={onSubmit}>
      <input {...register("title")} placeholder="Title" />
      <textarea {...register("content")} placeholder="Content" />
      {formError && <p>Form: {formError.message}</p>}
      {isError && <p>Error: {error?.message}</p>}
      <button type="submit" disabled={isPending}>
        {isPending ? "Publishing..." : "Publish"}
      </button>
      <button type="button" onClick={reset}>Reset</button>
    </form>
  );
}

useFormMutation extra return properties

| Property | Type | Description | |---|---|---| | mutate | UseMutateFunction | Fire the mutation imperatively | | mutateAsync | UseMutateAsyncFunction | Fire the mutation and await the result | | isPending | boolean | TanStack Query v5 pending state | | isLoading | boolean | Alias for isPending (backwards compatible) | | isError | boolean | Whether the mutation errored | | error | TError \| null | Mutation-level error | | formError | Error \| null | Form-level error (from useFormHandler) | | data | TResponse \| undefined | Mutation response data | | reset | () => void | Resets both form and mutation state | | resetForm | UseFormReturn["reset"] | Resets only the form | | resetMutation | () => void | Resets only the mutation | | status | 'idle' \| 'pending' \| 'success' \| 'error' | Current mutation status |


URL State — usePageSync

Syncs a page number with the ?page= query parameter. Handles browser back/forward navigation, clamping to bounds, scroll-to-top, and optional default-page omission from the URL.

Next.js App Router only. Requires next ≥ 13.4 as a peer dependency.

⚠️ Because usePageSync uses useSearchParams internally, any component that calls it must be wrapped in a <Suspense> boundary, per Next.js App Router rules.

"use client";

import { Suspense } from "react";
import { usePageSync } from "@hirely/hooks";

function Pagination() {
  const {
    currentPage,
    setPage,
    nextPage,
    prevPage,
    isFirstPage,
    isLastPage,
  } = usePageSync({ maxPage: 20 });

  return (
    <div>
      <button onClick={prevPage} disabled={isFirstPage}>← Prev</button>
      <span>Page {currentPage} / 20</span>
      <button onClick={nextPage} disabled={isLastPage}>Next →</button>
    </div>
  );
}

export default function Page() {
  return (
    <Suspense fallback={null}>
      <Pagination />
    </Suspense>
  );
}

usePageSync Options

| Option | Type | Default | Description | |---|---|---|---| | paramName | string | "page" | Query parameter name to sync with | | defaultPage | number | 1 | Fallback page when the param is missing or invalid | | minPage | number | 1 | Lower bound (inclusive) | | maxPage | number | undefined | Upper bound (inclusive) — useful when total pages are known | | scrollToTop | boolean | true | Scroll to top on page change | | scrollBehavior | ScrollBehavior | "smooth" | Passed to window.scrollTo | | replace | boolean | false | Use router.replace instead of router.push | | omitDefaultInUrl | boolean | true | Remove ?page= from the URL when on the default page | | onPageChange | (page: number, previous: number) => void | undefined | Fired whenever the page actually changes |

usePageSync Return Value

| Property | Type | Description | |---|---|---| | currentPage | number | The currently active page (clamped) | | setPage | (page: number \| ((prev: number) => number)) => void | Set the page; mirrors useState semantics | | nextPage | () => void | Go to the next page (no-op at maxPage) | | prevPage | () => void | Go to the previous page (no-op at minPage) | | resetPage | () => void | Reset to defaultPage | | isFirstPage | boolean | true when currentPage === minPage | | isLastPage | boolean | true when maxPage is set and currentPage === maxPage |


Global Configuration

Factory — createFormHandler

Create a pre-configured hook with your app-wide defaults:

// lib/form.ts
import { createFormHandler } from "@hirely/hooks";
import { toast } from "sonner";
import axios from "axios";

const api = axios.create({
  baseURL: process.env.NEXT_PUBLIC_API_URL,
  withCredentials: true,
});

export const useAppForm = createFormHandler({
  axiosInstance: api,
  notify: (msg, type) => toast[type](msg),
  mode: "onBlur",
  resetOptions: { resetAfterSuccess: true, keepDefaultValues: false },
});
// Any component — inherits baseURL, axiosInstance, notify, etc.
import { useAppForm } from "@/lib/form";

const { register, onSubmit } = useAppForm({
  schema: mySchema,
  endpoint: "/resource",
});

Context — FormHandlerProvider

Scope defaults to a sub-tree of your app:

import { FormHandlerProvider } from "@hirely/hooks";

export function AdminLayout({ children }: { children: React.ReactNode }) {
  return (
    <FormHandlerProvider
      defaultOptions={{
        axiosInstance: adminAxios,
        notify: (msg, type) => adminToast[type](msg),
        resetOptions: { resetAfterSuccess: false },
      }}
    >
      {children}
    </FormHandlerProvider>
  );
}

Higher-Order Component — withFormHandler

import React from "react";
import { withFormHandler } from "@hirely/hooks";
import type { useFormHandler } from "@hirely/hooks";
import { z } from "zod";

const feedbackSchema = z.object({
  rating: z.number().min(1).max(5),
  notes: z.string().optional(),
});

type Props = {
  formHandler: ReturnType<typeof useFormHandler<typeof feedbackSchema>>;
  category: string;
};

class FeedbackForm extends React.Component<Props> {
  render() {
    const { formHandler, category } = this.props;
    return (
      <form onSubmit={formHandler.onSubmit}>
        <h3>Category: {category}</h3>
        <input
          type="number"
          {...formHandler.register("rating", { valueAsNumber: true })}
        />
        <button type="submit" disabled={formHandler.loading}>Submit</button>
      </form>
    );
  }
}

export default withFormHandler(FeedbackForm, {
  schema: feedbackSchema,
  endpoint: "/api/feedback",
});

License

MIT © Hirely