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

react-router-typed-actions

v0.1.0

Published

Type-safe, serializable actions for React Router.

Readme

react-router-typed-actions

Type-safe, serializable actions for React Router.

Note: The 0.x version does not mean this library is unstable or buggy — it is actively used in production. It signals that the API is open to community feedback before committing to a 1.0 contract. Issues, ideas, and pull requests are welcome.

The Problem

React Router's useFetcher takes untyped FormData. Every mutation means hand-rolling formData.append, parsing it back in the route action, and hoping the payload shape matches on both sides. Across 5, 10, 20+ mutations the pain compounds: inconsistent patterns, no compile-time safety, and tedious FormData plumbing on every route.

The Solution

Define each mutation once with defineAction, register it, and let the library handle serialization and type inference. useActionFetcher wraps useFetcher with full type safety on payloads, responses, pending state, and lifecycle callbacks — no useEffect boilerplate, no manual FormData.

Works with both client actions and server actions.

Install

npm install react-router-typed-actions

Peer dependencies: react >= 18.0.0, react-router >= 7.0.0.

Quick Start

1. Define actions

// domain/campaign/actions.ts
import { defineAction } from "react-router-typed-actions";

export const createCampaign = defineAction({
  type: "campaign/create",
  resolve: (payload: { name: string; budget: number }) =>
    api.campaigns.create(payload),
});

export const deleteCampaign = defineAction({
  type: "campaign/delete",
  method: "DELETE",
  resolve: (payload: { id: string }) =>
    api.campaigns.delete(payload.id),
});

2. Register actions

// app/actions.ts
import { registerActions } from "react-router-typed-actions";
import { createCampaign, deleteCampaign } from "~/domain/campaign/actions";
import { inviteCreator } from "~/domain/creator/actions";

registerActions([createCampaign, deleteCampaign, inviteCreator]);

Import this module from your root route (or any entry point) so registration runs before the first action is dispatched.

3. Write the route handler

// lib/handle-action.ts
import {
  resolveFormData,
  actionSuccess,
  actionFailure,
} from "react-router-typed-actions";

export async function handleAction({ request }: { request: Request }) {
  const formData = await request.formData();
  const action = resolveFormData(formData);

  try {
    const response = await action.resolve();
    return actionSuccess(action, response);
  } catch (error) {
    return actionFailure(action, error);
  }
}

Then every route is a one-liner:

import { handleAction } from "~/lib/handle-action";

export async function clientAction(args: Route.ClientActionArgs) {
  return handleAction(args);
}

4. Use in components

import { useActionFetcher } from "react-router-typed-actions";
import { createCampaign } from "~/domain/campaign/actions";

function CreateCampaignButton() {
  const [submit, { state, data }] = useActionFetcher(createCampaign, {
    onSuccess: (result) => navigate(`/campaigns/${result.id}`),
    onError: (error) => toast.error(String(error)),
  });

  return (
    <button
      onClick={() => submit({ name: "Summer", budget: 5000 })}
      disabled={state !== "idle"}
    >
      {state === "submitting" ? "Creating..." : "Create Campaign"}
    </button>
  );
}

Recipes

Optimistic UI

pendingPayload is deserialized from fetcher.formData and auto-clears on settlement. On error, the removed item reappears automatically.

const [submit, { pendingPayload }] = useActionFetcher(deleteCampaign);
const pendingId = pendingPayload?.id ?? null;

const visible = pendingId
  ? items.filter((item) => item.id !== pendingId)
  : items;

Success / Error Callbacks

Wire toasts inline — no useEffect needed:

const [submit] = useActionFetcher(createCampaign, {
  onSuccess: () => toast.success("Campaign created!"),
  onError: (err) => toast.error(String(err)),
});

Meta and Dynamic Overrides

Attach static metadata to an action via the meta field:

interface ToastMeta {
  successMessage: string;
  errorMessage: string;
}

const createCampaign = defineAction<
  "campaign/create",
  { name: string },
  { id: string },
  void,
  ToastMeta
>({
  type: "campaign/create",
  resolve: (payload) => api.campaigns.create(payload),
  meta: {
    successMessage: "Campaign created",
    errorMessage: "Failed to create campaign",
  },
});

Override meta dynamically from within resolve using withMetaOverrides:

import { defineAction, withMetaOverrides } from "react-router-typed-actions";

const createCampaign = defineAction<
  "campaign/create",
  { name: string },
  { id: string },
  void,
  ToastMeta
>({
  type: "campaign/create",
  resolve: async (payload) => {
    const result = await api.campaigns.create(payload);
    if (result.requiresApproval) {
      return withMetaOverrides(result, {
        successMessage: `"${payload.name}" created — pending approval`,
      });
    }
    return result;
  },
  meta: {
    successMessage: "Campaign created",
    errorMessage: "Failed to create campaign",
  },
});

Read meta in the handler for centralized toast logic:

const response = await action.resolve(token);
toast.success(action.meta.successMessage);
return actionSuccess(action, response);

Multiple Actions on a Single Route

Define as many actions as you need — the route handler resolves the correct one by type:

registerActions([createCampaign, deleteCampaign, updateCampaign]);

All share the same clientAction handler. No switch statement, no manual dispatch.

File Uploads

File and Blob values are extracted from the payload and appended as native FormData entries — no base64 encoding:

const uploadAvatar = defineAction({
  type: "user/uploadAvatar",
  resolve: (payload: { userId: string; avatar: File }) =>
    api.users.uploadAvatar(payload.userId, payload.avatar),
});

Auth and Context

The TContext generic controls what resolve receives as its second argument:

const authed = defineAction({
  type: "authed",
  resolve: (payload: { id: string }, token: string) =>
    api.fetch(payload.id, { token }),
});

The handler passes context when calling action.resolve(context):

const token = await auth.getTokenSilently();
const response = await action.resolve(token);

Server Actions

The same action definitions work in React Router server action exports:

export async function action({ request }: Route.ActionArgs) {
  const formData = await request.formData();
  const action = resolveFormData(formData);
  const result = await action.resolve(getServerContext());
  return actionSuccess(action, result);
}

TanStack Query

Call the action creator directly — it returns an ActionObject for use outside the fetcher path:

import { createCampaign } from "~/domain/campaign/actions";
import { useMutation, useQueryClient } from "@tanstack/react-query";

function useCreateCampaign() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: async (payload: { name: string; budget: number }) => {
      const action = createCampaign(payload);
      return action.resolve();
    },
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ["campaigns"] });
    },
  });
}

Serialization

Payloads are serialized with SuperJSON (~2 KB), preserving types that JSON.stringify drops: Date, Map, Set, BigInt, undefined, RegExp, NaN, Infinity.

File and Blob values are extracted from the payload tree and appended as native FormData entries, so they travel over the wire without base64 encoding. On deserialization they are reinserted at their original paths.

API Reference

Functions

| Export | Description | | --- | --- | | defineAction(config) | Define a callable action creator from a config object | | registerActions(actions) | Register an array of actions for lookup by type | | useActionFetcher(action, options?) | Typed hook wrapping useFetcher — returns [submit, state] tuple | | resolveFormData(formData) | Deserialize FormData into an ActionObject | | createFormData(creator, payload) | Serialize an action creator + payload into FormData | | actionSuccess(action, response) | Create a success ActionResult envelope | | actionFailure(action, error) | Create a failure ActionResult envelope | | withMetaOverrides(data, overrides) | Wrap a resolve return value with dynamic meta overrides | | isMetaOverride(value) | Type guard — returns true if the value is a MetaOverrideResult |

Types

| Export | Description | | --- | --- | | Action<TType, TPayload, TResult, TContext, TMeta> | Callable action creator with identity properties | | ActionDefinition | Shape of an action definition (post-defaults) | | ActionMethod | "GET" \| "POST" \| "PUT" \| "PATCH" \| "DELETE" | | ActionObject | Runtime action instance with resolve, payload, meta | | ActionResult | Discriminated union: { success: true, response } \| { success: false, error } | | ActionSuccess | Success branch of ActionResult | | ActionFailure | Failure branch of ActionResult | | ActionResultOf<T> | Extract the resolved return type from an action creator | | MetaOverrideResult<T, TMeta> | Tagged wrapper returned by withMetaOverrides | | UseActionFetcherOptions<TResult> | Options: fetcherOptions?, onSuccess?, onError? | | UseActionFetcherState<...> | State object: state, data, pendingPayload |

License

MIT