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: [] });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: 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
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;
}> | 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.