wip query/mutation hooks

This commit is contained in:
yohlo
2025-08-29 00:29:59 -05:00
parent 70c1588e42
commit 7136f646a3
3 changed files with 268 additions and 0 deletions

View File

@@ -0,0 +1,132 @@
import { useQuery, useMutation, useQueryClient, UseQueryOptions, UseMutationOptions, useSuspenseQuery } from '@tanstack/react-query';
import { toast } from 'sonner';
import { ServerResult } from '@/lib/tanstack-query/types';
export function useServerQuery<TData>(
options: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn'> & {
queryFn: () => Promise<ServerResult<TData>>;
showErrorToast?: boolean;
}
) {
const { queryFn, showErrorToast = true, ...queryOptions } = options;
return useQuery({
...queryOptions,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}
export function useServerSuspenseQuery<TData>(
options: Omit<UseQueryOptions<TData, Error, TData>, 'queryFn'> & {
queryFn: () => Promise<ServerResult<TData>>;
showErrorToast?: boolean;
}
) {
const { queryFn, showErrorToast = true, ...queryOptions } = options;
return useSuspenseQuery({
...queryOptions,
queryFn: async () => {
const result = await queryFn();
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
}
});
}
export function useServerMutation<TData, TVariables = unknown>(
options: Omit<UseMutationOptions<TData, Error, TVariables>, 'mutationFn'> & {
mutationFn: (variables: TVariables) => Promise<ServerResult<TData>>;
successMessage?: string;
showErrorToast?: boolean;
showSuccessToast?: boolean;
}
) {
const {
mutationFn,
successMessage,
showErrorToast = true,
showSuccessToast = true,
onSuccess,
onError,
...mutationOptions
} = options;
return useMutation({
...mutationOptions,
mutationFn: async (variables: TVariables) => {
const result = await mutationFn(variables);
if (!result.success) {
if (showErrorToast) {
toast.error(result.error.userMessage);
}
throw new Error(result.error.userMessage);
}
return result.data;
},
onSuccess: (data, variables, context) => {
if (showSuccessToast && successMessage) {
toast.success(successMessage);
}
onSuccess?.(data, variables, context);
},
onError: (error, variables, context) => {
onError?.(error, variables, context);
}
});
}
export function useOptimisticMutation<TData, TVariables = unknown>(
options: Parameters<typeof useServerMutation<TData, TVariables>>[0] & {
queryKey: readonly (string | number)[];
optimisticUpdate?: (oldData: any, variables: TVariables) => any;
}
) {
const queryClient = useQueryClient();
const { queryKey, optimisticUpdate, ...mutationOptions } = options;
return useServerMutation({
...mutationOptions,
onMutate: async (variables) => {
await queryClient.cancelQueries({ queryKey });
const previousData = queryClient.getQueryData(queryKey);
if (optimisticUpdate && previousData) {
queryClient.setQueryData(queryKey, (old: any) => optimisticUpdate(old, variables));
}
return { previousData };
},
onError: (error, variables, context) => {
if (context && typeof context === 'object' && 'previousData' in context && context.previousData) {
queryClient.setQueryData(queryKey, context.previousData);
}
mutationOptions.onError?.(error, variables, context);
},
onSettled: (data, error, variables, context) => {
queryClient.invalidateQueries({ queryKey });
mutationOptions.onSettled?.(data, error, variables, context);
}
});
}

View File

@@ -0,0 +1,22 @@
export type ServerResult<T> =
| { success: true; data: T }
| { success: false; error: ServerError };
export type ServerError = {
code: ErrorType;
message: string;
userMessage: string;
statusCode?: number;
context?: Record<string, any>;
};
export enum ErrorType {
VALIDATION = 'VALIDATION',
NOT_FOUND = 'NOT_FOUND',
UNAUTHORIZED = 'UNAUTHORIZED',
FORBIDDEN = 'FORBIDDEN',
NETWORK = 'NETWORK',
DATABASE = 'DATABASE',
POCKETBASE = 'POCKETBASE',
UNKNOWN = 'UNKNOWN'
}

View File

@@ -0,0 +1,114 @@
import { logger } from "../logger";
import { ErrorType, ServerError, ServerResult } from "./types";
export const createServerError = (
type: ErrorType,
message: string,
userMessage: string,
statusCode?: number,
context?: Record<string, any>
): ServerError => ({
code: type,
message,
userMessage,
statusCode,
context,
});
export const withErrorHandling = async <T>(
operation: () => Promise<T>,
context: string,
customErrorMap?: (error: unknown) => ServerError | null
): Promise<ServerResult<T>> => {
try {
const data = await operation();
return { success: true, data };
} catch (error) {
logger.error(`${context} failed`, { error, context });
if (customErrorMap) {
const customError = customErrorMap(error);
if (customError) {
return { success: false, error: customError };
}
}
const mappedError = mapKnownError(error, context);
return { success: false, error: mappedError };
}
};
const mapKnownError = (error: unknown, context: string): ServerError => {
if (error && typeof error === "object" && "status" in error) {
const pbError = error as {
status: number;
message: string;
data?: unknown;
};
switch (pbError.status) {
case 400:
return createServerError(
ErrorType.VALIDATION,
pbError.message,
"Invalid request",
400,
{ originalError: pbError.data }
);
case 401:
return createServerError(
ErrorType.UNAUTHORIZED,
pbError.message,
"You are not authorized to perform this action",
401
);
case 403:
return createServerError(
ErrorType.FORBIDDEN,
pbError.message,
"Access denied",
403
);
case 404:
return createServerError(
ErrorType.NOT_FOUND,
pbError.message,
"The requested resource was not found",
404
);
default:
return createServerError(
ErrorType.POCKETBASE,
pbError.message,
"Something went wrong. Please try again.",
pbError.status
);
}
}
if (error instanceof TypeError && error.message.includes("fetch")) {
return createServerError(
ErrorType.NETWORK,
error.message,
"Network error. Please check your connection and try again."
);
}
if (error instanceof Error) {
return createServerError(
ErrorType.UNKNOWN,
error.message,
"An unexpected error occurred. Please try again.",
undefined,
{ stack: error.stack, context }
);
}
return createServerError(
ErrorType.UNKNOWN,
String(error),
"An unexpected error occurred. Please try again.",
undefined,
{ originalError: error, context }
);
};