refactor(hooks): useActionQuery/useActionMutation 接入 QueryClient + 向后兼容

- useActionQuery 新增 queryKey 入参模式:传入 queryKey 走 useQuery 跨页共享缓存;不传回退旧 useEffect + useState 模式。Hook 内部始终声明 useQuery/useState/useEffect 以遵守 React Hooks 规则,通过 isCacheMode 切换启用状态

- useActionMutation 新增 mutationFn + actionId 模式:成功后按 CLIENT_INVALIDATION_MAP[actionId] 自动 invalidateQueries;mutate(action?) 参数可选

- toast 替换为 notify(行为等价,便于未来替换 toast 库)

- 同步更新 004/005 架构文档签名说明
This commit is contained in:
SpecialX
2026-07-05 18:15:58 +08:00
parent 0f9d8825e7
commit dd7a49504f
4 changed files with 235 additions and 54 deletions

View File

@@ -691,8 +691,8 @@ src/auth.ts ──▶ import { ... } from "@/shared/lib/permissions"
| Hook | 文件 | 签名 | 用途 | 消费方 |
|------|------|------|------|--------|
| `useActionMutation` | `hooks/use-action-mutation.ts` | `useActionMutation<T>(options?): { isWorking, mutate }` | 通用 Server Action mutation Hook替代 50+ 文件中重复的 setIsWorking + try/catch/finally + toast 模式 | 1 个示范P1-4: schools-view潜在影响 50+ 文件 |
| `useActionQuery` | `hooks/use-action-query.ts` | `useActionQuery<T>(action, options?): { data, loading, error, refetch }` | 通用 Server Action 查询 Hook替代 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式,内置竞态防护 | 1 个示范P1-4: create-question-dialog潜在影响 11 个文件 |
| `useActionMutation` | `hooks/use-action-mutation.ts` | `useActionMutation<T>(options?): { isWorking, mutate }`。Options 联合类型:旧版 `{ successMessage?, errorMessage?, onSuccess?, onError? }` 或新版 `{ mutationFn?, actionId?, params?, invalidateQueryKeys?, ... }`。新版接入 QueryClient成功后按 `CLIENT_INVALIDATION_MAP[actionId]` 自动 invalidateQueries。`mutate(action?)` 参数可选:传 action 走旧模式,不传则使用 options.mutationFn | 通用 Server Action mutation Hook替代 50+ 文件中重复的 setIsWorking + try/catch/finally + toast 模式。V5 缓存策略重构新增 QueryClient + actionId 自动失效,向后兼容旧调用方 | 1 个示范P1-4: schools-view潜在影响 50+ 文件 |
| `useActionQuery` | `hooks/use-action-query.ts` | `useActionQuery<T>(action, options?): { data, loading, error, refetch }`。Options 联合类型:旧版 `{ deps?, enabled?, errorMessage? }` 或新版 `UseActionQueryWithCacheOptions`(含 `queryKey` + `UseQueryOptions`)。传入 `queryKey` 走 useQuery 跨页共享缓存;不传走旧 useEffect + useState。Hook 内部始终声明 useQuery + useState/useEffect 以遵守 React Hooks 规则 | 通用 Server Action 查询 Hook替代 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式,内置竞态防护。V5 缓存策略重构新增 queryKey 入参走 QueryClient向后兼容旧调用方 | 1 个示范P1-4: create-question-dialog潜在影响 11 个文件 |
**V5 状态管理统一专项基础设施**(阶段 1 新增,零业务行为变更):

View File

@@ -1495,8 +1495,8 @@
{
"name": "useActionMutation",
"file": "hooks/use-action-mutation.ts",
"signature": "useActionMutation<T>(options?: { successMessage?, errorMessage?, onSuccess?, onError? }): { isWorking: boolean; mutate: (action: () => Promise<ActionState<T>>) => Promise<ActionState<T> | undefined> }",
"purpose": "通用 Server Action mutation HookP1-4 重构从 50+ 个文件中重复的 setIsWorking(true) + try/catch/finally + toast 模式抽取。支持 successMessage/errorMessage/onSuccess/onError 配置",
"signature": "useActionMutation<T>(options?: UseActionMutationOptions<T>): { isWorking: boolean; mutate: (action?: () => Promise<ActionState<T>>) => Promise<ActionState<T> | undefined> }。Options 为联合类型:旧版 { successMessage?, errorMessage?, onSuccess?, onError? } 或新版 { mutationFn?, actionId?, params?, invalidateQueryKeys?, successMessage?, errorMessage?, onSuccess?, onError? }。新版接入 QueryClient成功后按 CLIENT_INVALIDATION_MAP[actionId] 自动 invalidateQueries。mutate 参数可选:传 action 走旧模式,不传则使用 options.mutationFn",
"purpose": "通用 Server Action mutation HookP1-4 重构从 50+ 个文件中重复的 setIsWorking(true) + try/catch/finally + toast 模式抽取。V5 缓存策略重构:接入 QueryClient + actionId 自动 invalidate向后兼容旧调用方mutate(action) 仍可用)",
"usedBy": [
"school/components/school-form-dialog.tsx",
"school/components/school-delete-dialog.tsx"
@@ -1505,8 +1505,8 @@
{
"name": "useActionQuery",
"file": "hooks/use-action-query.ts",
"signature": "useActionQuery<T>(action: () => Promise<ActionState<T>>, options?: { deps?, enabled?, errorMessage? }): { data, loading, error, refetch }",
"purpose": "通用 Server Action 查询 HookP1-4 重构从 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式抽取。内置竞态防护cancelled flag",
"signature": "useActionQuery<T>(action: () => Promise<ActionState<T>>, options?: UseActionQueryOptions<T>): { data, loading, error, refetch }。Options 为联合类型:旧版 { deps?, enabled?, errorMessage? } 或新版 UseActionQueryWithCacheOptions含 queryKey + UseQueryOptions。传入 queryKey 走 useQuery 跨页共享缓存;不传走旧 useEffect + useState。Hook 内部始终声明 useQuery + useState/useEffect 以遵守 React Hooks 规则",
"purpose": "通用 Server Action 查询 HookP1-4 重构从 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式抽取。V5 缓存策略重构:新增 queryKey 入参走 QueryClient向后兼容旧调用方。内置竞态防护requestId ref",
"usedBy": [
"questions/components/create-question-dialog.tsx"
]

View File

@@ -1,10 +1,13 @@
"use client"
import { useCallback, useState } from "react"
import { toast } from "sonner"
import { useQueryClient } from "@tanstack/react-query"
import { notify } from "@/shared/lib/notify"
import { CLIENT_INVALIDATION_MAP } from "@/shared/lib/cache/client-invalidation-map"
import type { ActionState } from "@/shared/types/action-state"
export interface UseActionMutationOptions<T> {
/** 旧版选项(向后兼容,无 mutationFn / actionId */
export interface UseActionMutationLegacyOptions<T> {
/** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
successMessage?: string | false
/** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
@@ -15,51 +18,146 @@ export interface UseActionMutationOptions<T> {
onError?: (error: unknown) => void
}
/** 新版选项(含 mutationFn + actionId 自动 invalidate */
export interface UseActionMutationWithCacheOptions<T> {
/** mutation 函数(新模式必填) */
mutationFn?: () => Promise<ActionState<T>>
/** 关联的 INVALIDATION_MAP actionId成功后自动失效相关 queryKey */
actionId?: string
/** 模板参数,如 { id: "cls_123" } */
params?: Record<string, string>
/** 显式额外失效的 queryKey不通过 actionId 时使用) */
invalidateQueryKeys?: readonly (readonly (string | number | object)[])[]
/** 成功时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
successMessage?: string | false
/** 失败时显示的 toast 文案。不传则使用 result.message。传 false 则不显示。 */
errorMessage?: string | false
/** 成功回调 */
onSuccess?: (data: T | undefined) => void
/** 失败回调result.success === false 或抛出异常时) */
onError?: (error: unknown) => void
}
export type UseActionMutationOptions<T> =
| UseActionMutationLegacyOptions<T>
| UseActionMutationWithCacheOptions<T>
export interface UseActionMutationResult<T> {
/** 是否正在执行try/catch/finally 期间为 true */
/** 是否正在执行 */
isWorking: boolean
/** 执行 mutation。返回 ActionState 以便调用方进一步处理。 */
mutate: (action: () => Promise<ActionState<T>>) => Promise<ActionState<T> | undefined>
/** 执行 mutation。
* - 旧模式:传入 action 函数
* - 新模式options.mutationFn 已设):可不传参数
*/
mutate: (
action?: () => Promise<ActionState<T>>,
) => Promise<ActionState<T> | undefined>
}
function isWithCacheOptions<T>(
opts: UseActionMutationOptions<T>,
): opts is UseActionMutationWithCacheOptions<T> {
return (
opts !== null &&
typeof opts === "object" &&
("mutationFn" in opts || "actionId" in opts || "invalidateQueryKeys" in opts)
)
}
/**
* 通用 Server Action mutation Hook。
*
* 用于替代各组件中重复的 `setIsWorking(true) + try/catch/finally + toast` 模式
* 两种模式
* - 旧模式mutate(action),手动传入 action 函数toast + onSuccess/onError
* - 新模式options.mutationFn + actionId自动 invalidateQueries
*
* @example
* const { mutate, isWorking } = useActionMutation({
* onSuccess: () => { setOpen(false); router.refresh() }
* @example 旧模式
* const { mutate } = useActionMutation({ onSuccess: () => setOpen(false) })
* const handleDelete = () => mutate(() => deleteAction(id))
*
* @example 新模式
* const { mutate } = useActionMutation({
* mutationFn: () => updateClassAction(input),
* actionId: "classes.update",
* params: { id: input.classId },
* })
*
* const handleDelete = () => mutate(() => deleteSchoolAction(id))
* const handleSubmit = () => mutate()
*/
export function useActionMutation<T = unknown>(
options: UseActionMutationOptions<T> = {}
options: UseActionMutationOptions<T> = {},
): UseActionMutationResult<T> {
const { successMessage, errorMessage, onSuccess, onError } = options
const queryClient = useQueryClient()
const [isWorking, setIsWorking] = useState(false)
const {
successMessage,
errorMessage,
onSuccess,
onError,
} = options as UseActionMutationLegacyOptions<T>
const runInvalidations = useCallback(
async (opts: UseActionMutationWithCacheOptions<T>) => {
const keysToInvalidate: (string | number | object)[][] = []
if (opts.actionId) {
const rule = (
CLIENT_INVALIDATION_MAP as Record<
string,
{ queryKeys: readonly (readonly (string | number)[])[] }
>
)[opts.actionId]
if (rule) {
for (const qk of rule.queryKeys) {
keysToInvalidate.push([...qk])
}
}
}
if (opts.invalidateQueryKeys) {
for (const qk of opts.invalidateQueryKeys) {
keysToInvalidate.push([...qk])
}
}
await Promise.all(
keysToInvalidate.map((qk) =>
queryClient.invalidateQueries({ queryKey: qk }),
),
)
},
[queryClient],
)
const mutate = useCallback(
async (action: () => Promise<ActionState<T>>): Promise<ActionState<T> | undefined> => {
async (
action?: () => Promise<ActionState<T>>,
): Promise<ActionState<T> | undefined> => {
const cacheOpts = isWithCacheOptions(options) ? options : null
const actionToRun = action ?? cacheOpts?.mutationFn
if (!actionToRun) {
throw new Error(
"useActionMutation: must provide action to mutate() or set options.mutationFn",
)
}
setIsWorking(true)
try {
const result = await action()
const result = await actionToRun()
if (result.success) {
if (successMessage !== false) {
toast.success(successMessage ?? result.message ?? "Operation succeeded")
notify.success(successMessage ?? result.message ?? "Operation succeeded")
}
if (cacheOpts) {
await runInvalidations(cacheOpts)
}
onSuccess?.(result.data)
} else {
if (errorMessage !== false) {
toast.error(errorMessage ?? result.message ?? "Operation failed")
notify.error(errorMessage ?? result.message ?? "Operation failed")
}
onError?.(new Error(result.message ?? "Action returned failure"))
}
return result
} catch (error) {
if (errorMessage !== false) {
toast.error(errorMessage ?? "Operation failed")
notify.error(errorMessage ?? "Operation failed")
}
onError?.(error)
return undefined
@@ -67,7 +165,14 @@ export function useActionMutation<T = unknown>(
setIsWorking(false)
}
},
[successMessage, errorMessage, onSuccess, onError]
[
options,
successMessage,
errorMessage,
onSuccess,
onError,
runInvalidations,
],
)
return { isWorking, mutate }

View File

@@ -1,10 +1,12 @@
"use client"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { useEffect, useRef, useState } from "react"
import { useQuery, type UseQueryOptions } from "@tanstack/react-query"
import { notify } from "@/shared/lib/notify"
import type { ActionState } from "@/shared/types/action-state"
export interface UseActionQueryOptions {
/** 旧版选项(向后兼容,无 queryKey */
export interface UseActionQueryLegacyOptions {
/** 依赖数组,默认 []。当依赖变化时重新获取。 */
deps?: ReadonlyArray<unknown>
/** 是否启用查询,默认 true。为 false 时不发起请求。 */
@@ -13,6 +15,19 @@ export interface UseActionQueryOptions {
errorMessage?: string | false
}
/** 新版选项(传入 queryKey 走 QueryClient */
export interface UseActionQueryWithCacheOptions<T>
extends Omit<UseQueryOptions<ActionState<T>>, "queryKey" | "queryFn"> {
/** queryKey 工厂返回的元组,如 queryKeys.classes.detail(id)。传入后走 useQuery跨页共享缓存 */
queryKey: readonly (string | number | object)[]
/** 是否启用,默认 true */
enabled?: boolean
}
export type UseActionQueryOptions<T> =
| UseActionQueryLegacyOptions
| UseActionQueryWithCacheOptions<T>
export interface UseActionQueryResult<T> {
data: T | undefined
loading: boolean
@@ -20,65 +35,126 @@ export interface UseActionQueryResult<T> {
refetch: () => void
}
function isWithCacheOptions<T>(
opts: UseActionQueryOptions<T>,
): opts is UseActionQueryWithCacheOptions<T> {
return opts !== null && typeof opts === "object" && "queryKey" in opts
}
/**
* 通用 Server Action 查询 Hook。
*
* 用于替代各组件中重复的 `useEffect + useState(loading) + Action().then().catch().finally()` 模式
* 内置竞态防护(通过 ref 跟踪最新请求)。
* 两种模式
* - 旧模式(不传 queryKeyuseEffect + useState请求内去重不跨页共享
* - 新模式(传 queryKeyuseQuery跨页共享缓存支持自动 invalidate
*
* @example
* const { data, loading } = useActionQuery(
* () => getKnowledgePointOptionsAction(),
* { deps: [open], enabled: open }
* 实现说明:为遵守 React Hooks 规则useQuery 与 legacy state/effect 始终被声明,
* 通过 `isCacheMode` 切换启用状态,二选一返回结果。
*
* @example 旧模式
* const { data } = useActionQuery(() => getXAction(), { deps: [id] })
*
* @example 新模式
* const { data } = useActionQuery(
* () => getClassDetailAction({ classId }),
* { queryKey: queryKeys.classes.detail(classId) }
* )
*/
export function useActionQuery<T>(
action: () => Promise<ActionState<T>>,
options: UseActionQueryOptions = {}
options: UseActionQueryOptions<T> = {},
): UseActionQueryResult<T> {
const { deps = [], enabled = true, errorMessage = "Failed to load data" } = options
const isCacheMode = isWithCacheOptions(options)
const cacheOpts = (isCacheMode ? options : {}) as Partial<UseActionQueryWithCacheOptions<T>>
const legacyOpts = (isCacheMode ? {} : options) as UseActionQueryLegacyOptions
const { deps = [], enabled: legacyEnabled = true, errorMessage = "Failed to load data" } = legacyOpts
const { queryKey: cacheQueryKey, enabled: cacheEnabledFlag, ...restCache } = cacheOpts
// useQuery 始终声明legacy 模式下 enabled:false 避免发起请求
const query = useQuery<ActionState<T>>({
...restCache,
queryKey: cacheQueryKey ?? ["__useActionQueryLegacy__"],
queryFn: action,
enabled: isCacheMode ? (cacheEnabledFlag ?? true) : false,
})
// legacy state 始终声明cache 模式下 effect 直接 return
const [data, setData] = useState<T | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(enabled)
const [error, setError] = useState<Error | null>(null)
const [refetchCount, setRefetchCount] = useState(0)
const [loading, setLoading] = useState(false)
const requestIdRef = useRef(0)
useEffect(() => {
if (!enabled) return
let cancelled = false
if (isCacheMode || !legacyEnabled) return
const requestId = ++requestIdRef.current
setLoading(true)
setError(null)
action()
.then((result) => {
if (cancelled) return
if (requestId !== requestIdRef.current) return
if (result.success) {
setData(result.data)
} else {
setError(new Error(result.message ?? "Action failed"))
if (errorMessage !== false) {
toast.error(errorMessage)
notify.error(errorMessage)
}
}
})
.catch((err: unknown) => {
if (cancelled) return
if (requestId !== requestIdRef.current) return
setError(err instanceof Error ? err : new Error(String(err)))
if (errorMessage !== false) {
toast.error(errorMessage)
notify.error(errorMessage)
}
})
.finally(() => {
if (!cancelled) setLoading(false)
if (requestId === requestIdRef.current) setLoading(false)
})
return () => {
cancelled = true
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...deps, refetchCount])
}, [isCacheMode, legacyEnabled, errorMessage, ...deps])
const refetch = () => setRefetchCount((c) => c + 1)
const legacyRefetch = () => {
if (isCacheMode) return
const requestId = ++requestIdRef.current
setLoading(true)
action()
.then((result) => {
if (requestId !== requestIdRef.current) return
if (result.success) {
setData(result.data)
} else {
setError(new Error(result.message ?? "Action failed"))
if (errorMessage !== false) notify.error(errorMessage)
}
})
.catch((err: unknown) => {
if (requestId !== requestIdRef.current) return
setError(err instanceof Error ? err : new Error(String(err)))
if (errorMessage !== false) notify.error(errorMessage)
})
.finally(() => {
if (requestId === requestIdRef.current) setLoading(false)
})
}
return { data, loading, error, refetch }
if (isCacheMode) {
const cacheData = query.data?.success ? query.data.data : undefined
const cacheError =
query.error ??
(query.data && !query.data.success
? new Error(query.data.message ?? "Action failed")
: null)
return {
data: cacheData,
loading: query.isLoading,
error: cacheError,
refetch: () => {
void query.refetch()
},
}
}
return { data, loading, error, refetch: legacyRefetch }
}