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:
@@ -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 新增,零业务行为变更):
|
||||
|
||||
|
||||
@@ -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 Hook,P1-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 Hook,P1-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 查询 Hook,P1-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 查询 Hook,P1-4 重构从 11 个文件中重复的 useEffect + useState(loading) + Action().then().catch().finally() 模式抽取。V5 缓存策略重构:新增 queryKey 入参走 QueryClient,向后兼容旧调用方。内置竞态防护(requestId ref)",
|
||||
"usedBy": [
|
||||
"questions/components/create-question-dialog.tsx"
|
||||
]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 跟踪最新请求)。
|
||||
* 两种模式:
|
||||
* - 旧模式(不传 queryKey):useEffect + useState,请求内去重,不跨页共享
|
||||
* - 新模式(传 queryKey):useQuery,跨页共享缓存,支持自动 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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user