Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions apps/content/docs/integrations/next.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ export const serverFunction = createServerFunction(procedure)

</CodeGroup>

### `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 (
<form action={submit}>
<input name="title" />
<span>{getIssueMessage(error, 'title')}</span>
<button type="submit" disabled={isPending}>Submit</button>
</form>
)
```

:::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.
Expand Down
6 changes: 2 additions & 4 deletions packages/next/src/server-form-function.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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))
}
}
28 changes: 28 additions & 0 deletions packages/next/src/server-function.test-d.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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<Awaited<ReturnType<typeof fn>> | 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<Awaited<ReturnType<typeof fn>>>()

// @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<string>()
}
dispatch()
})
})
13 changes: 13 additions & 0 deletions packages/next/src/server-function.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()],
Expand Down
32 changes: 32 additions & 0 deletions packages/next/src/server-function.test.tsx
Original file line number Diff line number Diff line change
@@ -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!'])
})
})
17 changes: 16 additions & 1 deletion packages/next/src/server-function.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -22,6 +23,11 @@ export type ServerFunctionResult<TOutput, TError> = [error: null, data: TOutput]

export interface ServerFunction<TInput, TOutput, TError extends AnyORPCErrorJSON> {
(...rest: ServerFunctionRest<TInput>): Promise<ServerFunctionResult<TOutput, TError>>
/**
* 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<ServerFunctionResult<TOutput, TError>>
}

export type ProcedureServerFunction<
Expand All @@ -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<
Expand Down Expand Up @@ -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)]
}
Expand Down
Loading