Skip to content

@cfast/actions

Most React Router routes need more than one action: a blog post page might handle publish, unpublish, delete, and add-comment all from the same route. @cfast/actions solves this with a single factory that provides type-safe, permission-aware action definitions. You define operations once, and the package handles routing, permission checking, and client-side submission controls.

Actions integrate deeply with @cfast/db operations and @cfast/permissions grants. The server checks permissions automatically, and the client gets permitted/invisible/pending flags per action without any extra work.

Terminal window
pnpm add @cfast/actions

Peer dependencies: @cfast/db, @cfast/permissions, react-router

Start by creating a factory that provides context (database, user, grants) for all your actions:

app/actions.server.ts
import { createActions } from "@cfast/actions";
export const { createAction, composeActions } = createActions({
getContext: async ({ request }) => {
const ctx = await requireAuthContext(request);
const db = createCfDb(env.DB, ctx);
return { db, user: ctx.user, grants: ctx.grants };
},
});

createActions returns two functions scoped to your context provider: createAction for individual actions, and composeActions for combining multiple actions into a single route handler.

createAction takes an operations function that receives (db, input, ctx) and returns a @cfast/db Operation. The operation defines both the database work and the permission requirements:

import { compose } from "@cfast/db";
import { eq } from "drizzle-orm";
import { createAction } from "~/actions.server";
import { posts, auditLogs } from "~/db/schema";
export const deletePost = createAction<{ postId: string }, Response>(
(db, input, ctx) =>
compose(
[
db.delete(posts).where(eq(posts.id, input.postId)),
db.insert(auditLogs).values({
id: crypto.randomUUID(),
userId: ctx.user.id,
action: "post.deleted",
targetId: input.postId,
}),
],
async (runDelete, runAudit) => {
await runDelete({});
await runAudit({});
return redirect("/");
},
),
);

Each action definition exposes four facets: .action (the route handler), .loader() (permission-injecting loader wrapper), .client (descriptor for hooks), and .buildOperation() (for advanced composition).

When a route needs multiple actions, composeActions merges them with a discriminator field:

import { composeActions } from "~/actions.server";
import { deletePost, publishPost, unpublishPost } from "~/actions/posts";
const composed = composeActions({ deletePost, publishPost, unpublishPost });
export const action = composed.action;

Forms include <input type="hidden" name="_action" value="deletePost" /> to route to the correct handler. JSON requests use { _action: "deletePost", ...input }.

Wrap your loader with .loader() to inject permission metadata into loader data. The wrapper checks each action’s permissions against the user’s grants and merges _actionPermissions into the response:

export const loader = composed.loader(async ({ request, params }) => {
const post = await getPost(params.slug);
return { post };
});

The client never receives raw permission descriptors — only boolean flags indicating what the user can do.

The useActions hook reads _actionPermissions from loader data and returns submission controls per action:

import { useActions } from "@cfast/actions/client";
function PostActions({ postId }: { postId: string }) {
const actions = useActions(composed.client);
const remove = actions.deletePost({ postId });
const publish = actions.publishPost({ postId });
return (
<>
<button
onClick={publish.submit}
disabled={!publish.permitted || publish.pending}
hidden={publish.invisible}
>
Publish
</button>
<button
onClick={remove.submit}
disabled={!remove.permitted || remove.pending}
>
Delete
</button>
</>
);
}

Each action returns permitted, invisible, reason, submit, pending, data, and error — everything you need to build responsive, permission-aware UIs.

Actions accept input from both FormData and JSON. The _action discriminator is stripped automatically:

  • FormData: <input name="_action" value="deletePost" /> plus other fields
  • JSON: { _action: "deletePost", postId: "123" }

This lets you use standard forms or programmatic fetch calls interchangeably.

Server-Side Sub-Action Calls with dispatch()

Section titled “Server-Side Sub-Action Calls with dispatch()”

When one action needs to call another action on the server, use dispatch() instead of constructing a Request. It bypasses HTTP entirely — no cookie forwarding or URL construction needed:

import { parentAction, childAction } from "~/actions/posts";
const parent = createAction((db, input, ctx) =>
compose(
[parentAction.buildOperation(db, input, ctx)],
async (runParent) => {
await runParent({});
// Call child action directly on the server
await childAction.dispatch({ ctx, input: { postId: input.postId } });
return redirect("/posts");
},
),
);

dispatch() runs the action’s operation function directly with the provided context, skipping request parsing and cookie handling. Use it whenever server code needs to trigger another action programmatically.

@cfast/actions pairs naturally with @cfast/ui components. ActionButton and PermissionGate consume action descriptors directly, so you get permission-aware rendering without manual checks:

import { ActionButton } from "@cfast/joy";
<ActionButton
action={publishPost}
input={{ postId }}
confirmation="Publish this post?"
>
Publish
</ActionButton>

When you need useActions() in client code without importing server modules, use clientDescriptor() to create a type-safe descriptor. Pass a readonly tuple to get compile-time checking of action names:

import { clientDescriptor } from "@cfast/actions/client";
const client = clientDescriptor(["create", "delete"] as const);
// client is ClientDescriptor<readonly ["create", "delete"]>

useActions(client) then returns a mapped type where each key is a known action name with full ActionHookResult typing:

const actions = useActions(client);
// actions.create: (input?) => ActionHookResult
// actions.delete: (input?) => ActionHookResult
// actions.typo -- compile error

This replaces the pattern of importing composed.client from a .server module, which breaks React Router’s server/client code splitting. See the server/client boundary notes for why this matters.

For routes that use @cfast/db queries with _can annotations, cfastJson() and useCfastLoader() provide a higher-level abstraction that eliminates manual can() calls and permission boolean prop-drilling.

Server (loader):

import { cfastJson } from "@cfast/actions";
import * as schema from "../db/schema";
export const loader = async ({ context }) => {
const documents = await db.query(documentsTable).findMany().run();
return cfastJson(ctx.auth.grants, schema, { documents });
};

cfastJson() embeds _tablePerms (a map of table-level CRUD permissions) into the response and serializes dates. Each array whose key matches a schema table is annotated so the client hook can resolve canAdd().

Client (component):

import { useCfastLoader } from "@cfast/actions/client";
import { ActionButton } from "@cfast/joy";
function DocumentList() {
const { documents } = useCfastLoader<typeof loader>();
return (
<>
<ActionButton action={documents.canAdd()} href="/documents/new">
New Document
</ActionButton>
{documents.map((doc) => (
<div key={doc.id}>
<span>{doc.title}</span>
<ActionButton
action={doc.canEdit()}
href={`/documents/${doc.id}/edit`}
whenForbidden="hide"
>
Edit
</ActionButton>
<ActionButton
action={doc.canDelete()}
input={{ _action: "delete", id: doc.id }}
whenForbidden="hide"
>
Delete
</ActionButton>
</div>
))}
</>
);
}

Row fields are directly accessible (doc.title), and permission methods (doc.canEdit()) return ActionHookResult. No .data wrapper needed.

ActionButton now supports two additional props:

  • href: Renders as a permission-gated link instead of a form button. When the action is not permitted, the link is disabled or hidden per whenForbidden.
  • input: Submits a form with the provided key-value pairs as hidden fields via a fetcher. Eliminates manual <Form> + <input type="hidden"> boilerplate.
// Navigation link
<ActionButton action={posts.canAdd()} href="/posts/new">New Post</ActionButton>
// Form data submission
<ActionButton action={post.canDelete()} input={{ _action: "deletePost", id: post.id }}>
Delete
</ActionButton>