Skip to content

Commit 95b67ec

Browse files
authored
feat(react): use useTransition within useServerAction (#459)
Closes: #451 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Improved responsiveness of server action hooks by integrating asynchronous state management for pending states. - **Refactor** - Enhanced internal handling of action state updates for smoother user experience during server actions. - **Tests** - Added tests covering error handling, concurrent executions, and reset behavior during server actions. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
1 parent a2fc015 commit 95b67ec

File tree

2 files changed

+229
-50
lines changed

2 files changed

+229
-50
lines changed

packages/react/src/hooks/action-hooks.test.tsx

+163-3
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,20 @@ import { act, renderHook, waitFor } from '@testing-library/react'
33
import { baseErrorMap, inputSchema, outputSchema } from '../../../contract/tests/shared'
44
import { useServerAction } from './action-hooks'
55

6+
beforeEach(() => {
7+
vi.clearAllMocks()
8+
})
9+
610
describe('useServerAction', () => {
11+
const handler = vi.fn(async ({ input }) => {
12+
return { output: Number(input?.input ?? 0) }
13+
})
14+
715
const action = os
816
.input(inputSchema.optional())
917
.errors(baseErrorMap)
1018
.output(outputSchema)
11-
.handler(async ({ input }) => {
12-
return { output: Number(input?.input ?? 0) }
13-
})
19+
.handler(handler)
1420
.actionable()
1521

1622
it('on success', async () => {
@@ -110,6 +116,57 @@ describe('useServerAction', () => {
110116
expect(result.current.error).toBe(null)
111117
})
112118

119+
it('on action calling error', async () => {
120+
const { result } = renderHook(() => useServerAction(() => {
121+
throw new Error('failed to call')
122+
}))
123+
124+
expect(result.current.status).toBe('idle')
125+
expect(result.current.isIdle).toBe(true)
126+
expect(result.current.isPending).toBe(false)
127+
expect(result.current.isSuccess).toBe(false)
128+
expect(result.current.isError).toBe(false)
129+
expect(result.current.input).toBe(undefined)
130+
expect(result.current.data).toBe(undefined)
131+
expect(result.current.error).toBe(null)
132+
133+
act(() => {
134+
result.current.execute({ input: 123 })
135+
})
136+
137+
expect(result.current.status).toBe('pending')
138+
expect(result.current.isIdle).toBe(false)
139+
expect(result.current.isPending).toBe(true)
140+
expect(result.current.isSuccess).toBe(false)
141+
expect(result.current.isError).toBe(false)
142+
expect(result.current.input).toEqual({ input: 123 })
143+
expect(result.current.data).toBe(undefined)
144+
expect(result.current.error).toBe(null)
145+
146+
await waitFor(() => expect(result.current.status).toBe('error'))
147+
expect(result.current.isIdle).toBe(false)
148+
expect(result.current.isPending).toBe(false)
149+
expect(result.current.isSuccess).toBe(false)
150+
expect(result.current.isError).toBe(true)
151+
expect(result.current.input).toEqual({ input: 123 })
152+
expect(result.current.data).toBe(undefined)
153+
expect(result.current.error).toBeInstanceOf(Error)
154+
expect(result.current.error!.message).toBe('failed to call')
155+
156+
act(() => {
157+
result.current.reset()
158+
})
159+
160+
expect(result.current.status).toBe('idle')
161+
expect(result.current.isIdle).toBe(true)
162+
expect(result.current.isPending).toBe(false)
163+
expect(result.current.isSuccess).toBe(false)
164+
expect(result.current.isError).toBe(false)
165+
expect(result.current.input).toBe(undefined)
166+
expect(result.current.data).toBe(undefined)
167+
expect(result.current.error).toBe(null)
168+
})
169+
113170
it('interceptors', async () => {
114171
const interceptor = vi.fn(({ next }) => next())
115172
const executeInterceptor = vi.fn(({ next }) => next())
@@ -150,4 +207,107 @@ describe('useServerAction', () => {
150207
expect(await executeInterceptor.mock.results[0]!.value).toEqual({ output: '123' })
151208
})
152209
})
210+
211+
it('multiple execute calls', async () => {
212+
const { result } = renderHook(() => useServerAction(action))
213+
214+
expect(result.current.status).toBe('idle')
215+
216+
handler.mockImplementationOnce(async () => {
217+
await new Promise(resolve => setTimeout(resolve, 20))
218+
return { output: 123 }
219+
})
220+
221+
let promise: Promise<any>
222+
223+
act(() => {
224+
promise = result.current.execute({ input: 123 })
225+
})
226+
227+
expect(result.current.status).toBe('pending')
228+
expect(result.current.executedAt).toBeDefined()
229+
expect(result.current.input).toEqual({ input: 123 })
230+
expect(result.current.data).toBeUndefined()
231+
expect(result.current.error).toBeNull()
232+
233+
handler.mockImplementationOnce(async () => {
234+
await new Promise(resolve => setTimeout(resolve, 40))
235+
return { output: 456 }
236+
})
237+
238+
let promise2: Promise<any>
239+
240+
act(() => {
241+
promise2 = result.current.execute({ input: 456 })
242+
})
243+
244+
expect(result.current.status).toBe('pending')
245+
expect(result.current.executedAt).toBeDefined()
246+
expect(result.current.input).toEqual({ input: 456 })
247+
expect(result.current.data).toBeUndefined()
248+
expect(result.current.error).toBeNull()
249+
250+
await act(async () => {
251+
expect((await promise!)[1]).toEqual({ output: '123' })
252+
})
253+
254+
expect(result.current.status).toBe('pending')
255+
expect(result.current.executedAt).toBeDefined()
256+
expect(result.current.input).toEqual({ input: 456 })
257+
expect(result.current.data).toBeUndefined()
258+
expect(result.current.error).toBeNull()
259+
260+
await act(async () => {
261+
expect((await promise2!)[1]).toEqual({ output: '456' })
262+
})
263+
264+
expect(result.current.status).toBe('success')
265+
expect(result.current.executedAt).toBeDefined()
266+
expect(result.current.input).toEqual({ input: 456 })
267+
expect(result.current.data).toEqual({ output: '456' })
268+
expect(result.current.error).toBeNull()
269+
})
270+
271+
it('reset while executing', async () => {
272+
const { result } = renderHook(() => useServerAction(action))
273+
274+
expect(result.current.status).toBe('idle')
275+
276+
handler.mockImplementationOnce(async () => {
277+
await new Promise(resolve => setTimeout(resolve, 20))
278+
return { output: 123 }
279+
})
280+
281+
let promise: Promise<any>
282+
283+
act(() => {
284+
promise = result.current.execute({ input: 123 })
285+
})
286+
287+
expect(result.current.status).toBe('pending')
288+
expect(result.current.executedAt).toBeDefined()
289+
expect(result.current.input).toEqual({ input: 123 })
290+
expect(result.current.data).toBeUndefined()
291+
expect(result.current.error).toBeNull()
292+
293+
act(() => {
294+
result.current.reset()
295+
})
296+
297+
expect(result.current.status).toBe('idle')
298+
expect(result.current.executedAt).toBeUndefined()
299+
expect(result.current.input).toBeUndefined()
300+
expect(result.current.data).toBeUndefined()
301+
expect(result.current.error).toBeNull()
302+
303+
await act(async () => {
304+
expect((await promise!)[1]).toEqual({ output: '123' })
305+
})
306+
307+
expect(result.current.status).toBe('idle')
308+
expect(result.current.executedAt).toBeUndefined()
309+
expect(result.current.input).toBeUndefined()
310+
expect(result.current.data).toBeUndefined()
311+
expect(result.current.error).toBeNull()
312+
})
153313
})

packages/react/src/hooks/action-hooks.ts

+66-47
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import type { ActionableClient, UnactionableError } from '@orpc/server'
33
import type { Interceptor } from '@orpc/shared'
44
import { createORPCErrorFromJson, safe } from '@orpc/client'
55
import { intercept, toArray } from '@orpc/shared'
6-
import { useCallback, useMemo, useState } from 'react'
6+
import { useCallback, useMemo, useRef, useState, useTransition } from 'react'
77

88
export interface UseServerActionOptions<TInput, TOutput, TError> {
99
interceptors?: Interceptor<{ input: TInput }, TOutput, TError>[]
@@ -78,10 +78,18 @@ const INITIAL_STATE = {
7878
isSuccess: false,
7979
isError: false,
8080
status: 'idle',
81-
executedAt: undefined,
82-
input: undefined,
8381
} as const
8482

83+
const PENDING_STATE = {
84+
data: undefined,
85+
error: null,
86+
isIdle: false,
87+
isPending: true,
88+
isSuccess: false,
89+
isError: false,
90+
status: 'pending',
91+
}
92+
8593
/**
8694
* Use a Server Action Hook
8795
*
@@ -97,62 +105,73 @@ export function useServerAction<TInput, TOutput, TError extends ORPCErrorJSON<an
97105
const [state, setState] = useState<Omit<
98106
| UseServerActionIdleResult<TInput, TOutput, UnactionableError<TError>>
99107
| UseServerActionSuccessResult<TInput, TOutput, UnactionableError<TError>>
100-
| UseServerActionErrorResult<TInput, TOutput, UnactionableError<TError>>
101-
| UseServerActionPendingResult<TInput, TOutput, UnactionableError<TError>>,
102-
keyof UseServerActionResultBase<TInput, TOutput, UnactionableError<TError>>
108+
| UseServerActionErrorResult<TInput, TOutput, UnactionableError<TError>>,
109+
keyof UseServerActionResultBase<TInput, TOutput, UnactionableError<TError>> | 'executedAt' | 'input'
103110
>>(INITIAL_STATE)
104111

112+
const executedAtRef = useRef<Date | undefined>(undefined)
113+
const [input, setInput] = useState<TInput | undefined>(undefined)
114+
const [isPending, startTransition] = useTransition()
115+
105116
const reset = useCallback(() => {
106-
setState(INITIAL_STATE)
117+
executedAtRef.current = undefined
118+
setInput(undefined)
119+
setState({ ...INITIAL_STATE })
107120
}, [])
108121

109122
const execute = useCallback(async (input: TInput, executeOptions: UseServerActionExecuteOptions<TInput, TOutput, UnactionableError<TError>> = {}) => {
110123
const executedAt = new Date()
111-
112-
setState({
113-
data: undefined,
114-
error: null,
115-
isIdle: false,
116-
isPending: true,
117-
isSuccess: false,
118-
isError: false,
119-
status: 'pending',
120-
executedAt,
121-
input,
122-
})
123-
124-
const result = await safe(intercept(
125-
[...toArray(options.interceptors), ...toArray(executeOptions.interceptors)],
126-
{ input: input as TInput },
127-
({ input }) => action(input).then(([error, data]) => {
128-
if (error) {
129-
throw createORPCErrorFromJson(error)
124+
executedAtRef.current = executedAt
125+
126+
setInput(input)
127+
128+
return new Promise((resolve) => {
129+
startTransition(async () => {
130+
const result = await safe(intercept(
131+
[...toArray(options.interceptors), ...toArray(executeOptions.interceptors)],
132+
{ input: input as TInput },
133+
({ input }) => action(input).then(([error, data]) => {
134+
if (error) {
135+
throw createORPCErrorFromJson(error)
136+
}
137+
138+
return data as TOutput
139+
}),
140+
))
141+
142+
/**
143+
* If multiple execute calls are made in parallel, only the last one will be effective.
144+
*/
145+
if (executedAtRef.current === executedAt) {
146+
setState({
147+
data: result.data,
148+
error: result.error as any,
149+
isIdle: false,
150+
isPending: false,
151+
isSuccess: !result.error,
152+
isError: !!result.error,
153+
status: !result.error ? 'success' : 'error',
154+
})
130155
}
131156

132-
return data as TOutput
133-
}),
134-
))
135-
136-
setState({
137-
data: result.data,
138-
error: result.error as any,
139-
isIdle: false,
140-
isPending: false,
141-
isSuccess: !result.error,
142-
isError: !!result.error,
143-
status: !result.error ? 'success' : 'error',
144-
executedAt,
145-
input,
157+
resolve(result)
158+
})
146159
})
147-
148-
return result
149160
}, [action, ...toArray(options.interceptors)])
150161

151-
const result = useMemo(() => ({
152-
...state,
153-
reset,
154-
execute,
155-
}), [state, reset, execute])
162+
const result = useMemo(() => {
163+
const currentState = isPending && executedAtRef.current !== undefined
164+
? PENDING_STATE
165+
: state
166+
167+
return {
168+
...currentState,
169+
executedAt: executedAtRef.current,
170+
input,
171+
reset,
172+
execute,
173+
}
174+
}, [isPending, state, input, reset, execute])
156175

157176
return result as any
158177
}

0 commit comments

Comments
 (0)