@cfast/actions
Overview
Section titled “Overview”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.
Installation
Section titled “Installation”pnpm add @cfast/actionsPeer dependencies: @cfast/db, @cfast/permissions, react-router
Quick Setup
Section titled “Quick Setup”Start by creating a factory that provides context (database, user, grants) for all your actions:
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.
Core Concepts
Section titled “Core Concepts”Single Actions
Section titled “Single Actions”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).
Composed Actions
Section titled “Composed Actions”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 }.
Loader Integration
Section titled “Loader Integration”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.
Common Patterns
Section titled “Common Patterns”Permission-Aware Buttons on the Client
Section titled “Permission-Aware Buttons on the Client”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.
Dual Input Formats
Section titled “Dual Input Formats”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.
Integrating with @cfast/ui
Section titled “Integrating with @cfast/ui”@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>Type-Safe clientDescriptor()
Section titled “Type-Safe clientDescriptor()”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 errorThis 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.
cfastJson() and useCfastLoader()
Section titled “cfastJson() and useCfastLoader()”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 href and input props
Section titled “ActionButton href and input props”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 perwhenForbidden.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>