Skip to content

tRPC v12 isn’t on npm yet. The @trpc12/* packages on this site can’t be installed until it’s released. Use v11 today

tRPC

Server Actions

Turn procedures into React Server Actions that take typed input or FormData, with useActionState and optimistic updates.

On this page

Nothing here is Next-specific: it works with any React Server Components setup.

experimental_createAction from @trpc12/trpc/actions makes a factory that sets the ctx, layer and result mode once. action(procedure) is then an async function that takes the input or a form's FormData:

'use server';
import { function experimental_createAction<TCtx extends object = {}, TServices = never, TMode extends ActionResultMode = "safe">(opts?: CreateActionOptions<TCtx, TServices, TMode>): <TDef extends ProcedureDef>(procedure: Procedure<TDef>, ...fits: Fits<TDef, TCtx, TServices>) => Action<TDef, TMode>experimental_createAction } from '@trpc12/trpc/actions';

const 
const action: <TDef extends ProcedureDef>(procedure: Procedure<TDef>, ...fits: Fits<TDef, {
    user: User | null;
}, never>) => Action<TDef, "safe">
action
=
experimental_createAction<{
    user: User | null;
}, never, "safe">(opts?: CreateActionOptions<{
    user: User | null;
}, never, "safe">): <TDef>(procedure: Procedure<TDef>, ...fits: Fits<TDef, {
    user: User | null;
}, never>) => Action<TDef, "safe">
experimental_createAction
({
CreateActionOptions<{ user: User | null; }, never, "safe">.ctx?: {
    user: User | null;
} | (() => MaybePromise<{
    user: User | null;
}>) | undefined
ctx
: async () => ({ user: User | nulluser: await function auth(): Promise<User | null>auth() }),
}); export const
const createPost: Action<{
    type: "mutation";
    ctx: Context;
    meta: {};
    input: {
        title: string;
    };
    output: Post;
    errors: TrpcError<"UNAUTHORIZED", undefined> | SchemaValidationError<ZodObject<{
        title: ZodString;
    }, $strip>>;
    services: never;
}, "safe">
createPost
=
const action: <{
    type: "mutation";
    ctx: Context;
    meta: {};
    input: {
        title: string;
    };
    output: Post;
    errors: TrpcError<"UNAUTHORIZED", undefined> | SchemaValidationError<ZodObject<{
        title: ZodString;
    }, $strip>>;
    services: never;
}>(procedure: Procedure<{
    type: "mutation";
    ctx: Context;
    meta: {};
    input: {
        title: string;
    };
    output: Post;
    errors: TrpcError<"UNAUTHORIZED", undefined> | SchemaValidationError<ZodObject<{
        title: ZodString;
    }, $strip>>;
    services: never;
}>) => Action<...>
action
(
const appRouter: {
    post: {
        byId: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: {
                id: string;
            };
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }> | SchemaValidationError<ZodObject<{
                id: ZodString;
            }, $strip>>;
            services: never;
        }>;
        list: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: undefined;
            output: Post[];
            errors: never;
            services: never;
        }>;
        create: Procedure<{
            type: "mutation";
            ctx: Context;
            meta: {};
            input: {
                title: string;
            };
            output: Post;
            errors: TrpcError<...> | SchemaValidationError<...>;
            services: never;
        }>;
    };
}
appRouter
.
post: {
    byId: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: {
            id: string;
        };
        output: Post;
        errors: TrpcError<"NOT_FOUND", {
            id: string;
        }> | SchemaValidationError<ZodObject<{
            id: ZodString;
        }, $strip>>;
        services: never;
    }>;
    list: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: Post[];
        errors: never;
        services: never;
    }>;
    create: Procedure<{
        type: "mutation";
        ctx: Context;
        meta: {};
        input: {
            title: string;
        };
        output: Post;
        errors: TrpcError<...> | SchemaValidationError<...>;
        services: never;
    }>;
}
post
.
create: Procedure<{
    type: "mutation";
    ctx: Context;
    meta: {};
    input: {
        title: string;
    };
    output: Post;
    errors: TrpcError<"UNAUTHORIZED", undefined> | SchemaValidationError<ZodObject<{
        title: ZodString;
    }, $strip>>;
    services: never;
}>
create
);
<React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form
  React.FormHTMLAttributes<HTMLFormElement>.action?: string | ((formData: FormData) => void | Promise<void>) | undefinedaction={async (form: FormDataform) => {
    await 
const createPost: Action
(input: FormData | {
    title: string;
    tags: string[];
}) => Promise<SafeResult<Post, ClientError<TrpcError<"UNAUTHORIZED", undefined> | SchemaValidationError<z.ZodObject<{
    title: z.ZodString;
    tags: z.ZodArray<z.ZodString>;
}, z.core.$strip>>>>> (+1 overload)
createPost
(form: FormDataform);
}} > <React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>input React.InputHTMLAttributes<HTMLInputElement>.name?: string | undefinedname="title" /> <React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>input React.InputHTMLAttributes<HTMLInputElement>.name?: string | undefinedname="tags[]" React.InputHTMLAttributes<HTMLInputElement>.value?: string | number | readonly string[] | undefinedvalue="news" /> </React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form>; const [const post: Post | undefinedpost,
const error: ClientError<TrpcError<"UNAUTHORIZED", undefined> | SchemaValidationError<z.ZodObject<{
    title: z.ZodString;
    tags: z.ZodArray<z.ZodString>;
}, z.core.$strip>>> | undefined
error
] = await
const createPost: Action
(input: FormData | {
    title: string;
    tags: string[];
}) => Promise<SafeResult<Post, ClientError<TrpcError<"UNAUTHORIZED", undefined> | SchemaValidationError<z.ZodObject<{
    title: z.ZodString;
    tags: z.ZodArray<z.ZodString>;
}, z.core.$strip>>>>> (+1 overload)
createPost
({ title: stringtitle, tags: string[]tags: [] });
  • FormData is parsed with bracket notation (tags[]=a, author[name]=ada) and coerced from the input's JSON Schema ('40' becomes 40 where the schema wants a number). Framework fields such as Next's $ACTION_ID_… are dropped.
  • result: 'safe' (the default) returns the [data, error] / { data, error } result of safe() (Errors without try/catch). result: 'throw' returns the data and throws TrpcError.
  • Results reach the browser, so unexpected errors are masked outside development, as in handlers. onError sees them first.
  • A factory whose ctx or layer can't satisfy a procedure is a type error, and so is wrapping a subscription.
  • Next's redirect(), notFound() and dynamic-API errors thrown in a procedure or ctx are rethrown as they are, not returned as errors, so Next can act on them. Any error with a string digest counts. The same holds for call(), createRouterClient() and localLink (Server-side calls).

Form state and optimistic updates

An action also takes React's useActionState call (the previous state, then the input or FormData), so React's own hooks give pending state and optimistic UI; there is no useServerAction:

('use client');
import { function useActionState<State>(action: (state: Awaited<State>) => State | Promise<State>, initialState: Awaited<State>, permalink?: string): [state: Awaited<State>, dispatch: () => void, isPending: boolean] (+1 overload)useActionState, function useOptimistic<State>(passthrough: State): [State, (action: State | ((pendingState: State) => State)) => void] (+1 overload)useOptimistic } from 'react';
import { 
const addTitle: Action<{
    type: "mutation";
    ctx: Context;
    meta: {};
    input: {
        title: string;
    };
    output: string[];
    errors: SchemaValidationError<ZodObject<{
        title: ZodString;
    }, $strip>>;
    services: never;
}, "safe">
addTitle
} from './actions'; // action(appRouter.title.add)
function function Titles(): React.JSX.ElementTitles() { const [
const result: SafeResult<string[], ClientError<SchemaValidationError<ZodObject<{
    title: ZodString;
}, $strip>>>> | undefined
result
,
const submit: (payload: {
    title: string;
} | FormData) => void
submit
, const isPending: booleanisPending] =
useActionState<SafeResult<string[], ClientError<SchemaValidationError<ZodObject<{
    title: ZodString;
}, $strip>>>> | undefined, {
    title: string;
} | FormData>(action: (state: SafeResult<string[], ClientError<SchemaValidationError<ZodObject<{
    title: ZodString;
}, $strip>>>> | undefined, payload: {
    title: string;
} | FormData) => SafeResult<string[], ClientError<SchemaValidationError<ZodObject<{
    title: ZodString;
}, $strip>>>> | Promise<...> | undefined, initialState: SafeResult<...> | undefined, permalink?: string): [state: ...] (+1 overload)
useActionState
(
const addTitle: Action<{
    type: "mutation";
    ctx: Context;
    meta: {};
    input: {
        title: string;
    };
    output: string[];
    errors: SchemaValidationError<ZodObject<{
        title: ZodString;
    }, $strip>>;
    services: never;
}, "safe">
addTitle
, var undefinedundefined);
const [const shown: string[]shown, const addShown: (action: string) => voidaddShown] = useOptimistic<string[], string>(passthrough: string[], reducer: (state: string[], action: string) => string[]): [string[], (action: string) => void] (+1 overload)useOptimistic(
const result: SafeResult<string[], ClientError<SchemaValidationError<ZodObject<{
    title: ZodString;
}, $strip>>>> | undefined
result
?.data: string[] | undefineddata ?? [],
(list: string[]list: string[], title: stringtitle: string) => [...list: string[]list, title: stringtitle], ); return ( <React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form React.FormHTMLAttributes<HTMLFormElement>.action?: string | ((formData: FormData) => void | Promise<void>) | undefinedaction={(form: FormDataform) => { const addShown: (action: string) => voidaddShown(form: FormDataform.FormData.get(name: string): FormDataEntryValue | nullget('title') as string);
const submit: (payload: {
    title: string;
} | FormData) => void
submit
(form: FormDataform);
}} > <React.JSX.IntrinsicElements.input: React.DetailedHTMLProps<React.InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>input React.InputHTMLAttributes<HTMLInputElement>.name?: string | undefinedname="title" /> {const shown: string[]shown.Array<string>.map<React.JSX.Element>(callbackfn: (value: string, index: number, array: string[]) => React.JSX.Element, thisArg?: any): React.JSX.Element[]map((title: stringtitle) => ( <React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p React.Attributes.key?: React.Key | null | undefinedkey={title: stringtitle}>{title: stringtitle}</React.JSX.IntrinsicElements.p: React.DetailedHTMLProps<React.HTMLAttributes<HTMLParagraphElement>, HTMLParagraphElement>p> ))} {const isPending: booleanisPending ? 'Saving…' :
const result: SafeResult<string[], ClientError<SchemaValidationError<ZodObject<{
    title: ZodString;
}, $strip>>>> | undefined
result
?.
error: ClientError<SchemaValidationError<ZodObject<{
    title: ZodString;
}, $strip>>> | undefined
error
?.TrpcError<TCode extends string = string, TData = unknown>.code: "BAD_REQUEST" | "PARSE_ERROR" | "UNSUPPORTED_PROTOCOL" | "UNAUTHORIZED" | "PAYMENT_REQUIRED" | "FORBIDDEN" | "NOT_FOUND" | "METHOD_NOT_SUPPORTED" | "TIMEOUT" | "CONFLICT" | "PRECONDITION_FAILED" | "PAYLOAD_TOO_LARGE" | "UNSUPPORTED_MEDIA_TYPE" | "UNPROCESSABLE_CONTENT" | "TOO_MANY_REQUESTS" | "CLIENT_CLOSED_REQUEST" | "INTERNAL_SERVER_ERROR" | "NOT_IMPLEMENTED" | "BAD_GATEWAY" | "SERVICE_UNAVAILABLE" | "GATEWAY_TIMEOUT" | "NETWORK_ERROR" | "BAD_RESPONSE" | "CLIENT_ERROR" | "STREAM_INTERRUPTED" | undefinedcode}
</React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form> ); }
  • The state starts undefined and is then the last result ({ data, error } with result: 'safe'). The previous state isn't sent to the procedure.
  • The optimistic value shows until the action settles, then falls back to result.

Direct calls

experimental_createCallable is the same factory for direct calls with typed input (no FormData), from server components, loaders or scripts:

const const callable: <TDef extends ProcedureDef>(procedure: Procedure<TDef>, ...fits: Fits<TDef, Context, never>) => Callable<TDef, "throw">callable = experimental_createCallable<Context, never, "throw">(opts?: CreateActionOptions<Context, never, "throw">): <TDef>(procedure: Procedure<TDef>, ...fits: Fits<TDef, Context, never>) => Callable<TDef, "throw">experimental_createCallable({
  CreateActionOptions<Context, never, "throw">.ctx?: Context | (() => MaybePromise<Context>) | undefinedctx: function createContext(): Promise<Context>createContext,
  CreateActionOptions<Context, never, "throw">.result?: "throw" | undefinedresult: 'throw',
});
const const post: Postpost = await 
const callable: <{
    type: "query";
    ctx: Context;
    meta: {};
    input: {
        id: string;
    };
    output: Post;
    errors: TrpcError<"NOT_FOUND", {
        id: string;
    }> | SchemaValidationError<ZodObject<{
        id: ZodString;
    }, $strip>>;
    services: never;
}>(procedure: Procedure<{
    type: "query";
    ctx: Context;
    meta: {};
    input: {
        id: string;
    };
    output: Post;
    errors: TrpcError<"NOT_FOUND", {
        id: string;
    }> | SchemaValidationError<ZodObject<{
        id: ZodString;
    }, $strip>>;
    services: never;
}>) => Callable<...>
callable
(
const appRouter: {
    post: {
        byId: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: {
                id: string;
            };
            output: Post;
            errors: TrpcError<"NOT_FOUND", {
                id: string;
            }> | SchemaValidationError<ZodObject<{
                id: ZodString;
            }, $strip>>;
            services: never;
        }>;
        list: Procedure<{
            type: "query";
            ctx: Context;
            meta: {};
            input: undefined;
            output: Post[];
            errors: never;
            services: never;
        }>;
        create: Procedure<{
            type: "mutation";
            ctx: Context;
            meta: {};
            input: {
                title: string;
            };
            output: Post;
            errors: TrpcError<...> | SchemaValidationError<...>;
            services: never;
        }>;
    };
}
appRouter
.
post: {
    byId: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: {
            id: string;
        };
        output: Post;
        errors: TrpcError<"NOT_FOUND", {
            id: string;
        }> | SchemaValidationError<ZodObject<{
            id: ZodString;
        }, $strip>>;
        services: never;
    }>;
    list: Procedure<{
        type: "query";
        ctx: Context;
        meta: {};
        input: undefined;
        output: Post[];
        errors: never;
        services: never;
    }>;
    create: Procedure<{
        type: "mutation";
        ctx: Context;
        meta: {};
        input: {
            title: string;
        };
        output: Post;
        errors: TrpcError<...> | SchemaValidationError<...>;
        services: never;
    }>;
}
post
.
byId: Procedure<{
    type: "query";
    ctx: Context;
    meta: {};
    input: {
        id: string;
    };
    output: Post;
    errors: TrpcError<"NOT_FOUND", {
        id: string;
    }> | SchemaValidationError<ZodObject<{
        id: ZodString;
    }, $strip>>;
    services: never;
}>
byId
)({ id: stringid: '1' });

Credits

Actions that take FormData follow oRPC's form actions.