diff --git a/apps/content/docs/integrations/next.mdx b/apps/content/docs/integrations/next.mdx index c1c3c94f1..25ffc0160 100644 --- a/apps/content/docs/integrations/next.mdx +++ b/apps/content/docs/integrations/next.mdx @@ -111,6 +111,27 @@ export const serverFunction = createServerFunction(procedure) +### `useActionState` + +Server functions can be passed directly to React's [`useActionState`](https://react.dev/reference/react/useActionState). The previous state is ignored, `FormData` is parsed with [Bracket Notation](/docs/openapi/bracket-notation), and the form works before JavaScript loads. + +```tsx +const [state, submit, isPending] = useActionState(serverFunction, null) +const [error, data] = state ?? [null, undefined] + +return ( +
+ + {getIssueMessage(error, 'title')} + +
+) +``` + +:::warning +Don't pass extra input with `.bind`, since the bound value is treated as the previous state. Use hidden inputs instead. +::: + ### `createServerFunctionable` If you reuse the same options across multiple server functions, `createServerFunctionable` creates a preconfigured helper. The helper takes a procedure and returns a value that works as both a server function and the original [procedure](/docs/procedure) on the server. diff --git a/packages/next/src/server-form-function.ts b/packages/next/src/server-form-function.ts index 4949c80dd..5c5ee75e6 100644 --- a/packages/next/src/server-form-function.ts +++ b/packages/next/src/server-form-function.ts @@ -1,6 +1,6 @@ import type { AnySchema, Context, ErrorMap, Lazyable, Procedure, ProcedureClientOptions } from '@orpc/server' import type { MaybeOptionalOptions } from '@orpc/shared' -import { BracketNotationSerializer } from '@orpc/openapi' +import { parseFormData } from '@orpc/openapi/helpers' import { createProcedureClient } from '@orpc/server' import { resolveMaybeOptionalOptions } from '@orpc/shared' @@ -32,14 +32,12 @@ export function createServerFormFunction< ): ServerFormFunction { const options = resolveMaybeOptionalOptions(rest) const client = createProcedureClient(procedure, options) - const serializer = new BracketNotationSerializer() return async (form) => { if (!(form instanceof FormData)) { throw new TypeError('Server form function expects FormData.') } - const input = serializer.deserialize([...form]) - await client(input as any) + await client(parseFormData(form)) } } diff --git a/packages/next/src/server-function.test-d.ts b/packages/next/src/server-function.test-d.ts index f07f82823..265437dbc 100644 --- a/packages/next/src/server-function.test-d.ts +++ b/packages/next/src/server-function.test-d.ts @@ -1,5 +1,6 @@ import type { ORPCErrorCode } from '@orpc/server' import { os } from '@orpc/server' +import { useActionState } from 'react' import { z } from 'zod' import { createServerFunction } from './server-function' @@ -51,4 +52,31 @@ describe('createServerFunction', () => { expectTypeOf(data).toEqualTypeOf<{ schema2: string }>() } }) + + it('plugs into useActionState', () => { + const fn = createServerFunction(procedure, { context: () => ({ auth: true }) }) + + const [state, dispatch] = useActionState(fn, null) + expectTypeOf(state).toEqualTypeOf> | null>() + dispatch({ schema1: 123 }) + dispatch(new FormData()) + // @ts-expect-error invalid input + dispatch({ schema1: 'invalid' }) + + const [initialized] = useActionState(fn, [null, { schema2: '123' }]) + expectTypeOf(initialized).toEqualTypeOf>>() + + // @ts-expect-error initial state must match the result + useActionState(fn, [null, { schema2: 123 }]) + }) + + it('plugs into useActionState for procedures without input', () => { + const fn = createServerFunction(os.handler(() => 'output')) + + const [state, dispatch] = useActionState(fn, null) + if (state && !state[0]) { + expectTypeOf(state[1]).toEqualTypeOf() + } + dispatch() + }) }) diff --git a/packages/next/src/server-function.test.ts b/packages/next/src/server-function.test.ts index ec06a2a12..e1576faa5 100644 --- a/packages/next/src/server-function.test.ts +++ b/packages/next/src/server-function.test.ts @@ -45,6 +45,19 @@ describe('createServerFunction', () => { await expect(serverFn({ input: 'ping' })).resolves.toEqual([new ORPCError('INTERNAL_SERVER_ERROR').toJSON(), undefined]) }) + it('passes form data as-is when called with a single argument', async () => { + const client = vi.fn().mockResolvedValue({ output: 'pong' }) + createProcedureClientSpy.mockReturnValueOnce(client) + + const serverFn = createServerFunction(procedure) + + const form = new FormData() + form.append('name', 'alice') + + await serverFn(form) + expect(client).toHaveBeenCalledWith(form) + }) + it.each([ [() => next.redirect('/foo')], [() => next.forbidden()], diff --git a/packages/next/src/server-function.test.tsx b/packages/next/src/server-function.test.tsx new file mode 100644 index 000000000..92cd3fcd4 --- /dev/null +++ b/packages/next/src/server-function.test.tsx @@ -0,0 +1,32 @@ +import { os } from '@orpc/server' +import { act, renderHook } from '@testing-library/react' +import { startTransition, useActionState } from 'react' +import { z } from 'zod' +import { createServerFunction } from './server-function' + +describe('createServerFunction with useActionState', () => { + const fn = createServerFunction( + os + .input(z.object({ name: z.string().min(3) })) + .handler(({ input }) => `Hello ${input.name}!`), + ) + + it('works as a useActionState action', async () => { + const { result } = renderHook(() => useActionState(fn, null)) + + const form = new FormData() + form.append('name', 'Alice') + + await act(async () => { + startTransition(() => result.current[1](form)) + }) + + expect(result.current[0]).toEqual([null, 'Hello Alice!']) + + await act(async () => { + startTransition(() => result.current[1]({ name: 'Bob' })) + }) + + expect(result.current[0]).toEqual([null, 'Hello Bob!']) + }) +}) diff --git a/packages/next/src/server-function.ts b/packages/next/src/server-function.ts index 37a89ea42..d7adef813 100644 --- a/packages/next/src/server-function.ts +++ b/packages/next/src/server-function.ts @@ -1,5 +1,6 @@ import type { AnyORPCErrorJSON, AnySchema, Context, ErrorMap, InferSchemaInput, InferSchemaOutput, Lazyable, ORPCError, ORPCErrorCode, ORPCErrorFromErrorMap, ORPCErrorJSON, Procedure, ProcedureClientOptions, ThrowableError } from '@orpc/server' import type { MaybeOptionalOptions } from '@orpc/shared' +import { parseFormData } from '@orpc/openapi/helpers' import { createProcedureClient, toORPCError } from '@orpc/server' import { resolveMaybeOptionalOptions } from '@orpc/shared' import { unstable_rethrow } from 'next/navigation' @@ -22,6 +23,11 @@ export type ServerFunctionResult = [error: null, data: TOutput] export interface ServerFunction { (...rest: ServerFunctionRest): Promise> + /** + * The [`useActionState`](https://react.dev/reference/react/useActionState) shape, picked when called with two arguments. + * The previous state is ignored, and `FormData` input is deserialized using bracket notation. + */ + (prevState: unknown, input: TInput | FormData): Promise> } export type ProcedureServerFunction< @@ -43,6 +49,9 @@ export type ProcedureServerFunction< * **Note**: Instead of throwing, the returned function resolves an `[error, data]` tuple * with errors serialized as plain JSON (`ORPCErrorJSON`). * + * **Note**: When called with two arguments, the returned function follows the `useActionState` + * shape `(prevState, input | FormData)`, so it can be passed to `useActionState` directly. + * * @see {@link https://orpc.dev/docs/integrations/next#server-functions | Next.js Integration - Server Functions} */ export function createServerFunction< @@ -70,7 +79,13 @@ export function createServerFunction< const options = resolveMaybeOptionalOptions(rest) const client = createProcedureClient(procedure, options) - return async (...[input]) => { + return async (...args: unknown[]) => { + let input = args[0] + + if (args.length > 1) { + input = args[1] instanceof FormData ? parseFormData(args[1]) : args[1] + } + try { return [null, await client(input as any)] }