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> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly [...];
readonly message: string;
} | ... 9 more ... | {
...;
}>;
services: never;
}, "safe">
createPost = const action: <{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: Post;
errors: TrpcError<"UNAUTHORIZED", undefined> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly [...];
readonly message: string;
} | ... 9 more ... | {
...;
}>;
services: never;
}>(procedure: Procedure<...>) => Action<...>
action(const appRouter: {
post: {
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
};
}
appRouter.post: {
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
}
post.create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>
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> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"] | readonly ["tags"] | readonly ["tags", number];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | ... 2 more ... | readonly [...];
readonly message: string;
} | ... 9 more ... | {
...;
}>>>> (+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> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"] | readonly ["tags"] | readonly ["tags", number];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly ["title"] | readonly ["tags"] | readonly ["tags", number];
readonly message: string;
} | ... 9 more ... | {
...;
}>> | undefined
error] = await const createPost: Action
(input: FormData | {
title: string;
tags: string[];
}) => Promise<SafeResult<Post, ClientError<TrpcError<"UNAUTHORIZED", undefined> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: z.core.$ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"] | readonly ["tags"] | readonly ["tags", number];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | ... 2 more ... | readonly [...];
readonly message: string;
} | ... 9 more ... | {
...;
}>>>> (+1 overload)
createPost({ title: stringtitle, tags: string[]tags: [] });FormDatais parsed with bracket notation (tags[]=a,author[name]=ada) and coerced from the input's JSON Schema ('40'becomes40where 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 ofsafe()(Errors without try/catch).result: 'throw'returns the data and throwsTrpcError.- Results reach the browser, so unexpected errors are masked outside development, as in handlers.
onErrorsees 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 orctxare rethrown as they are, not returned as errors, so Next can act on them. Any error with a stringdigestcounts. The same holds forcall(),createRouterClient()andlocalLink(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: InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | ... 9 more ... | {
...;
}>;
services: never;
}, "safe">
addTitle } from './actions'; // action(appRouter.title.add)
function function Titles(): React.JSX.ElementTitles() {
const [const result: SafeResult<string[], ClientError<InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_small";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 8 more ... | {
...;
}>>> | undefined
result, const submit: (payload: {
title: string;
} | FormData) => void
submit, const isPending: booleanisPending] = useActionState<SafeResult<string[], ClientError<InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_small";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 8 more ... | {
...;
}>>> | undefined, {
...;
} | FormData>(action: (state: SafeResult<...> | undefined, payload: {
...;
} | FormData) => SafeResult<...> | ... 1 more ... | undefined, initialState: SafeResult<...> | undefined, permalink?: string): [state: ...] (+1 overload)
useActionState(const addTitle: Action<{
type: "mutation";
ctx: Context;
meta: {};
input: {
title: string;
};
output: string[];
errors: InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | ... 9 more ... | {
...;
}>;
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<InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_small";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 8 more ... | {
...;
}>>> | 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<InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_small";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 8 more ... | {
...;
}>>> | undefined
result?.error: ClientError<InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly ["title"];
readonly message: string;
} | {
readonly code: "too_small";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
... 4 more ...;
readonly message: string;
} | ... 8 more ... | {
...;
}>> | undefined
error?.TrpcError<TCode extends string = string, TData = unknown>.code: "BAD_REQUEST" | (string & {}) | "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" | "CLIENT_ERROR" | "STREAM_INTERRUPTED" | undefinedcode}
</React.JSX.IntrinsicElements.form: React.DetailedHTMLProps<React.FormHTMLAttributes<HTMLFormElement>, HTMLFormElement>form>
);
}- The state starts
undefinedand is then the last result ({ data, error }withresult: '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;
}> | InputValidationError<{
readonly code: "invalid_type";
readonly expected: $ZodInvalidTypeExpected;
readonly path: readonly [] | readonly ["id"];
readonly message: string;
} | {
readonly code: "too_big";
readonly origin: "number" | "int" | "bigint" | "date" | "string" | "array" | "set" | "file" | (string & {});
readonly maximum: string | number;
readonly inclusive?: boolean | undefined;
readonly exact?: boolean | undefined;
readonly path: readonly [] | readonly [...];
readonly message: string;
} | ... 9 more ... | {
...;
}>;
services: never;
}>(procedure: Procedure<...>) => Callable<...>
callable(const appRouter: {
post: {
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
};
}
appRouter.post: {
byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>;
list: BuildProcedure<RootDef<Context, {}>, "query", {
output: Post[];
errors: never;
services: never;
unmapped: never;
}>;
create: BuildProcedure<InputDef<With<RootDef<Context, {}>, {
ctx: Context;
ctxAdded: {
...;
};
inputOut: Unset;
errors: TrpcError<...>;
shorts: never;
}>, ZodObject<...>>, "mutation", {
...;
}>;
}
post.byId: BuildProcedure<InputDef<RootDef<Context, {}>, ZodObject<{
id: ZodString;
}, $strip>>, "query", {
output: Post;
errors: TrpcError<"NOT_FOUND", {
id: string;
}>;
services: never;
unmapped: never;
}>
byId)({ id: stringid: '1' });