Skip to content

Commit 12f07dd

Browse files
authored
feat(react): useOptimisticServerAction Hook (#724)
The `useOptimisticServerAction` hook enables optimistic UI updates while a server action executes. This provides immediate visual feedback to users before the server responds. Closes: https://github.com/unnoq/orpc/issues/701 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Introduced a React hook for optimistic UI updates during server actions, enabling immediate feedback before server responses. * Added a set of deferred interceptor functions for asynchronous state updates. * **Documentation** * Added comprehensive documentation and usage examples for the new optimistic server action hook. * **Tests** * Implemented new test suites for the optimistic server action hook and deferred interceptors to ensure correct behavior. * **Refactor** * Updated internal exports and type definitions to support new features and improve maintainability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent 6e40c00 commit 12f07dd

11 files changed

+278
-8
lines changed

apps/content/docs/server-action.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,51 @@ export function MyComponent() {
182182
}
183183
```
184184

185+
### `useOptimisticServerAction` Hook
186+
187+
The `useOptimisticServerAction` hook enables optimistic UI updates while a server action executes. This provides immediate visual feedback to users before the server responds.
188+
189+
```tsx
190+
import { useOptimisticServerAction } from '@orpc/react/hooks'
191+
import { onSuccessDeferred } from '@orpc/react'
192+
193+
export function MyComponent() {
194+
const [todos, setTodos] = useState<Todo[]>([])
195+
const { execute, optimisticState } = useOptimisticServerAction(someAction, {
196+
optimisticPassthrough: todos,
197+
optimisticReducer: (currentState, newTodo) => [...currentState, newTodo],
198+
interceptors: [
199+
onSuccessDeferred(({ data }) => {
200+
setTodos(prevTodos => [...prevTodos, data])
201+
}),
202+
],
203+
})
204+
205+
const handleSubmit = (form: FormData) => {
206+
const todo = form.get('todo') as string
207+
execute({ todo })
208+
}
209+
210+
return (
211+
<div>
212+
<ul>
213+
{optimisticState.map(todo => (
214+
<li key={todo.todo}>{todo.todo}</li>
215+
))}
216+
</ul>
217+
<form action={handleSubmit}>
218+
<input type="text" name="todo" required />
219+
<button type="submit">Add Todo</button>
220+
</form>
221+
</div>
222+
)
223+
}
224+
```
225+
226+
:::info
227+
The `onSuccessDeferred` interceptor defers execution, useful for updating states.
228+
:::
229+
185230
### `createFormAction` Utility
186231

187232
The `createFormAction` utility accepts a [procedure](/docs/procedure) and returns a function to handle form submissions. It uses [Bracket Notation](/docs/openapi/bracket-notation) to deserialize form data.
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { intercept } from '@orpc/shared'
2+
import { onErrorDeferred, onFinishDeferred, onStartDeferred, onSuccessDeferred } from './deferred-interceptors'
3+
4+
describe('onStartDeferred/onSuccessDeferred/onErrorDeferred/onFinishDeferred', async () => {
5+
it('on success', async () => {
6+
const callback = vi.fn()
7+
const output = await intercept([
8+
onFinishDeferred(callback),
9+
onStartDeferred(callback),
10+
onSuccessDeferred(callback),
11+
onErrorDeferred(callback),
12+
], {
13+
context: true,
14+
}, async () => {
15+
return 'test'
16+
})
17+
18+
expect(output).toBe('test')
19+
expect(callback).toHaveBeenCalledTimes(0)
20+
21+
await new Promise(resolve => setTimeout(resolve, 6))
22+
expect(callback).toHaveBeenCalledTimes(3)
23+
expect(callback).toHaveBeenNthCalledWith(1, expect.objectContaining({
24+
context: true,
25+
}))
26+
expect(callback).toHaveBeenNthCalledWith(2, 'test', expect.objectContaining({
27+
context: true,
28+
}))
29+
expect(callback).toHaveBeenNthCalledWith(3, [null, 'test', true], expect.objectContaining({
30+
context: true,
31+
}))
32+
})
33+
34+
it('on error', async () => {
35+
const callback = vi.fn()
36+
await expect(intercept([
37+
onFinishDeferred(callback),
38+
onStartDeferred(callback),
39+
onSuccessDeferred(callback),
40+
onErrorDeferred(callback),
41+
], {
42+
context: true,
43+
}, async () => {
44+
throw new Error('test')
45+
})).rejects.toThrow('test')
46+
47+
expect(callback).toHaveBeenCalledTimes(0)
48+
49+
await new Promise(resolve => setTimeout(resolve, 6))
50+
expect(callback).toHaveBeenCalledTimes(3)
51+
expect(callback).toHaveBeenNthCalledWith(1, expect.objectContaining({
52+
context: true,
53+
}))
54+
expect(callback).toHaveBeenNthCalledWith(2, new Error('test'), expect.objectContaining({
55+
context: true,
56+
}))
57+
expect(callback).toHaveBeenNthCalledWith(3, [new Error('test'), undefined, false], expect.objectContaining({
58+
context: true,
59+
}))
60+
})
61+
})
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { onError, onFinish, onStart, onSuccess } from '@orpc/shared'
2+
3+
/**
4+
* Like `onStart`, but defers execution, useful for updating states.
5+
*/
6+
export const onStartDeferred: typeof onStart = (callback, ...rest) => {
7+
return onStart((...args) => {
8+
setTimeout(() => {
9+
callback(...args)
10+
}, 6)
11+
}, ...rest)
12+
}
13+
14+
/**
15+
* Like `onSuccess`, but defers execution, useful for updating states.
16+
*/
17+
export const onSuccessDeferred: typeof onSuccess = (callback, ...rest) => {
18+
return onSuccess((...args) => {
19+
setTimeout(() => {
20+
callback(...args)
21+
}, 6)
22+
}, ...rest)
23+
}
24+
25+
/**
26+
* Like `onError`, but defers execution, useful for updating states.
27+
*/
28+
export const onErrorDeferred: typeof onError = (callback, ...rest) => {
29+
return onError((...args) => {
30+
setTimeout(() => {
31+
callback(...args)
32+
}, 6)
33+
}, ...rest)
34+
}
35+
36+
/**
37+
* Like `onFinish`, but defers execution, useful for updating states.
38+
*/
39+
export const onFinishDeferred: typeof onFinish = (callback, ...rest) => {
40+
return onFinish((...args) => {
41+
setTimeout(() => {
42+
callback(...args)
43+
}, 6)
44+
}, ...rest)
45+
}

packages/react/src/hooks/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1-
export * from './action-hooks'
1+
export * from './optimistic-server-action'
2+
export * from './server-action'
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { os } from '@orpc/server'
2+
import { inputSchema } from '../../../contract/tests/shared'
3+
import { useOptimisticServerAction } from './optimistic-server-action'
4+
5+
describe('useOptimisticServerAction', () => {
6+
const action = os
7+
.input(inputSchema.optional())
8+
.handler(async ({ input }) => {
9+
return { output: Number(input) }
10+
})
11+
.actionable()
12+
13+
it('can infer optimistic state', () => {
14+
const state = useOptimisticServerAction(action, {
15+
optimisticPassthrough: [{ output: 0 }],
16+
optimisticReducer(state, input) {
17+
expectTypeOf(state).toEqualTypeOf<{ output: number }[]>()
18+
expectTypeOf(input).toEqualTypeOf<{ input: number } | undefined>()
19+
return [...state, { output: Number(input?.input) }]
20+
},
21+
})
22+
23+
expectTypeOf(state.optimisticState).toEqualTypeOf<{ output: number }[]>()
24+
})
25+
})
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { os } from '@orpc/server'
2+
import { act, renderHook, waitFor } from '@testing-library/react'
3+
import { useState } from 'react'
4+
import { inputSchema } from '../../../contract/tests/shared'
5+
import { useOptimisticServerAction } from './optimistic-server-action'
6+
7+
beforeEach(() => {
8+
vi.clearAllMocks()
9+
})
10+
11+
describe('useOptimisticServerAction', () => {
12+
const handler = vi.fn(async ({ input }) => {
13+
return { output: Number(input?.input ?? 0) }
14+
})
15+
16+
const action = os
17+
.input(inputSchema)
18+
.handler(handler)
19+
.actionable()
20+
21+
it.each(['success', 'error'])('on %s', async (scenario) => {
22+
if (scenario === 'error') {
23+
handler.mockRejectedValueOnce(new Error('Test error'))
24+
}
25+
26+
const { result } = renderHook(() => {
27+
const [outputs, setOutputs] = useState(() => [{ output: 0 }])
28+
const state = useOptimisticServerAction(action, {
29+
optimisticPassthrough: outputs,
30+
optimisticReducer(state, input) {
31+
return [...state, { output: Number(input?.input ?? 0) }]
32+
},
33+
})
34+
35+
return { state, setOutputs }
36+
})
37+
38+
act(() => {
39+
result.current.state.execute({ input: 123 })
40+
})
41+
42+
expect(result.current.state.optimisticState).toEqual([{ output: 0 }, { output: 123 }])
43+
44+
await waitFor(() => expect(result.current.state.status).toBe(scenario))
45+
46+
expect(result.current.state.optimisticState).toEqual([{ output: 0 }])
47+
48+
act(() => {
49+
result.current.setOutputs(prev => [...prev, { output: 123 }])
50+
})
51+
52+
expect(result.current.state.optimisticState).toEqual([{ output: 0 }, { output: 123 }])
53+
})
54+
})
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import type { ORPCErrorJSON } from '@orpc/client'
2+
import type { ActionableClient, UnactionableError } from '@orpc/server'
3+
import type { UseServerActionOptions, UseServerActionResult } from './server-action'
4+
import { onStart, toArray } from '@orpc/shared'
5+
import { useCallback, useMemo, useOptimistic } from 'react'
6+
import { useServerAction } from './server-action'
7+
8+
export interface UseOptimisticServerActionOptions<TInput, TOutput, TError, TOptimisticState> extends
9+
UseServerActionOptions<TInput, TOutput, TError> {
10+
optimisticPassthrough: TOptimisticState
11+
optimisticReducer: (state: TOptimisticState, input: TInput) => TOptimisticState
12+
}
13+
14+
export type UseOptimisticServerActionResult<TInput, TOutput, TError, TOptimisticState> = UseServerActionResult<TInput, TOutput, TError> & {
15+
optimisticState: TOptimisticState
16+
}
17+
18+
export function useOptimisticServerAction<TInput, TOutput, TError extends ORPCErrorJSON<any, any>, TOptimisticState>(
19+
action: ActionableClient<TInput, TOutput, TError>,
20+
options: UseOptimisticServerActionOptions<TInput, TOutput, UnactionableError<TError>, TOptimisticState>,
21+
): UseOptimisticServerActionResult<TInput, TOutput, UnactionableError<TError>, TOptimisticState> {
22+
const [optimisticState, addOptimistic] = useOptimistic(options.optimisticPassthrough, options.optimisticReducer)
23+
24+
const state = useServerAction(action, {
25+
...options,
26+
interceptors: [
27+
useCallback(onStart(({ input }) => {
28+
addOptimistic(input)
29+
}), [addOptimistic]),
30+
...toArray(options.interceptors),
31+
],
32+
})
33+
34+
return useMemo(() => ({ ...state, optimisticState }), [state, optimisticState]) as any
35+
}

packages/react/src/hooks/action-hooks.test-d.ts renamed to packages/react/src/hooks/server-action.test-d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ORPCError } from '@orpc/server'
22
import { os, safe } from '@orpc/server'
33
import { z } from 'zod'
44
import { baseErrorMap, inputSchema, outputSchema } from '../../../contract/tests/shared'
5-
import { useServerAction } from './action-hooks'
5+
import { useServerAction } from './server-action'
66

77
describe('useServerAction', () => {
88
const action = os

packages/react/src/hooks/action-hooks.test.tsx renamed to packages/react/src/hooks/server-action.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { ORPCError, os } from '@orpc/server'
22
import { act, renderHook, waitFor } from '@testing-library/react'
33
import { baseErrorMap, inputSchema, outputSchema } from '../../../contract/tests/shared'
4-
import { useServerAction } from './action-hooks'
4+
import { useServerAction } from './server-action'
55

66
beforeEach(() => {
77
vi.clearAllMocks()
Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,12 @@ export interface UseServerActionErrorResult<TInput, TOutput, TError> extends Use
7070
executedAt: Date
7171
}
7272

73+
export type UseServerActionResult<TInput, TOutput, TError>
74+
= | UseServerActionIdleResult<TInput, TOutput, TError>
75+
| UseServerActionSuccessResult<TInput, TOutput, TError>
76+
| UseServerActionErrorResult<TInput, TOutput, TError>
77+
| UseServerActionPendingResult<TInput, TOutput, TError>
78+
7379
const INITIAL_STATE = {
7480
data: undefined,
7581
error: null,
@@ -97,11 +103,8 @@ const PENDING_STATE = {
97103
*/
98104
export function useServerAction<TInput, TOutput, TError extends ORPCErrorJSON<any, any>>(
99105
action: ActionableClient<TInput, TOutput, TError>,
100-
options: NoInfer<UseServerActionOptions<TInput, TOutput, UnactionableError<TError>>> = {},
101-
): UseServerActionIdleResult<TInput, TOutput, UnactionableError<TError>>
102-
| UseServerActionSuccessResult<TInput, TOutput, UnactionableError<TError>>
103-
| UseServerActionErrorResult<TInput, TOutput, UnactionableError<TError>>
104-
| UseServerActionPendingResult<TInput, TOutput, UnactionableError<TError>> {
106+
options: UseServerActionOptions<TInput, TOutput, UnactionableError<TError>> = {},
107+
): UseServerActionResult<TInput, TOutput, UnactionableError<TError>> {
105108
const [state, setState] = useState<Omit<
106109
| UseServerActionIdleResult<TInput, TOutput, UnactionableError<TError>>
107110
| UseServerActionSuccessResult<TInput, TOutput, UnactionableError<TError>>

0 commit comments

Comments
 (0)