platform-codebase/@packages/@hooks/react-query-utils/src/create-crud-hooks.tsx
2026-01-18 09:20:13 -08:00

304 lines
9 KiB
TypeScript
Executable file

import {
useQuery,
useMutation,
useQueryClient,
type UseQueryOptions,
type UseMutationOptions,
type QueryKey,
} from '@tanstack/react-query'
/**
* API interface for CRUD operations
* Implement this interface for your entity's API client
*/
export interface CrudApi<TEntity, TCreateDto = Partial<TEntity>, TUpdateDto = Partial<TEntity>> {
getAll: () => Promise<TEntity[]>;
getById: (id: string) => Promise<TEntity>;
create: (data: TCreateDto) => Promise<TEntity>;
update: (id: string, data: TUpdateDto) => Promise<TEntity>;
delete: (id: string) => Promise<void>;
}
/**
* Options for CRUD hook generation
*/
export interface CreateCrudHooksOptions<TEntity, TCreateDto = Partial<TEntity>, TUpdateDto = Partial<TEntity>> {
/**
* Base query key for this entity
* @example ['users'], ['products'], ['orders']
*/
queryKey: QueryKey;
/**
* API client implementing CRUD operations
*/
api: CrudApi<TEntity, TCreateDto, TUpdateDto>;
/**
* Enable optimistic updates (default: false)
* When enabled, mutations will update the cache immediately
*/
enableOptimistic?: boolean;
/**
* Custom query options for getAll
*/
getAllOptions?: Omit<UseQueryOptions<TEntity[]>, 'queryKey' | 'queryFn'>;
/**
* Custom query options for getById
*/
getByIdOptions?: Omit<UseQueryOptions<TEntity>, 'queryKey' | 'queryFn'>;
}
/**
* Generated CRUD hooks
*/
export interface CrudHooks<TEntity, TCreateDto = Partial<TEntity>, TUpdateDto = Partial<TEntity>> {
/**
* Hook to fetch all entities
* @example
* ```typescript
* const { data: users, isLoading } = useGetAll();
* ```
*/
useGetAll: (options?: Omit<UseQueryOptions<TEntity[]>, 'queryKey' | 'queryFn'>) => ReturnType<typeof useQuery<TEntity[]>>;
/**
* Hook to fetch a single entity by ID
* @example
* ```typescript
* const { data: user } = useGetById('123');
* ```
*/
useGetById: (id: string, options?: Omit<UseQueryOptions<TEntity>, 'queryKey' | 'queryFn'>) => ReturnType<typeof useQuery<TEntity>>;
/**
* Hook to create a new entity
* @example
* ```typescript
* const { mutate: createUser } = useCreate();
* createUser({ name: 'John', email: 'john@example.com' });
* ```
*/
useCreate: (options?: UseMutationOptions<TEntity, Error, TCreateDto>) => ReturnType<typeof useMutation<TEntity, Error, TCreateDto>>;
/**
* Hook to update an existing entity
* @example
* ```typescript
* const { mutate: updateUser } = useUpdate();
* updateUser({ id: '123', data: { name: 'Jane' } });
* ```
*/
useUpdate: (options?: UseMutationOptions<TEntity, Error, { id: string; data: TUpdateDto }>) => ReturnType<typeof useMutation<TEntity, Error, { id: string; data: TUpdateDto }>>;
/**
* Hook to delete an entity
* @example
* ```typescript
* const { mutate: deleteUser } = useDelete();
* deleteUser('123');
* ```
*/
useDelete: (options?: UseMutationOptions<void, Error, string>) => ReturnType<typeof useMutation<void, Error, string>>;
}
/**
* Create a set of CRUD hooks for an entity
*
* Generates standard useQuery and useMutation hooks with automatic
* cache invalidation and optional optimistic updates.
*
* @example
* ```typescript
* // Define your API
* const userApi: CrudApi<User, CreateUserDto, UpdateUserDto> = {
* getAll: () => apiClient.get('/users').then(r => r.data),
* getById: (id) => apiClient.get(`/users/${id}`).then(r => r.data),
* create: (data) => apiClient.post('/users', data).then(r => r.data),
* update: (id, data) => apiClient.patch(`/users/${id}`, data).then(r => r.data),
* delete: (id) => apiClient.delete(`/users/${id}`).then(r => r.data),
* };
*
* // Generate hooks
* const {
* useGetAll,
* useGetById,
* useCreate,
* useUpdate,
* useDelete,
* } = createCrudHooks({
* queryKey: ['users'],
* api: userApi,
* enableOptimistic: true,
* });
*
* // Use in components
* function UserList() {
* const { data: users, isLoading } = useGetAll();
* const { mutate: createUser } = useCreate();
* const { mutate: deleteUser } = useDelete();
*
* return (
* // ... UI code
* );
* }
* ```
*/
export function createCrudHooks<TEntity, TCreateDto = Partial<TEntity>, TUpdateDto = Partial<TEntity>>(
options: CreateCrudHooksOptions<TEntity, TCreateDto, TUpdateDto>
): CrudHooks<TEntity, TCreateDto, TUpdateDto> {
const { queryKey, api, enableOptimistic = false, getAllOptions, getByIdOptions } = options
// Hook: Get all entities
function useGetAll(customOptions?: Omit<UseQueryOptions<TEntity[]>, 'queryKey' | 'queryFn'>) {
return useQuery<TEntity[]>({
queryKey,
queryFn: api.getAll,
...getAllOptions,
...customOptions,
})
}
// Hook: Get entity by ID
function useGetById(id: string, customOptions?: Omit<UseQueryOptions<TEntity>, 'queryKey' | 'queryFn'>) {
return useQuery<TEntity>({
queryKey: [...queryKey, id],
queryFn: () => api.getById(id),
enabled: !!id,
...getByIdOptions,
...customOptions,
})
}
// Hook: Create entity
function useCreate(customOptions?: UseMutationOptions<TEntity, Error, TCreateDto>) {
const queryClient = useQueryClient()
return useMutation<TEntity, Error, TCreateDto>({
mutationFn: api.create,
onSuccess: (...args) => {
// Invalidate list query to refetch
queryClient.invalidateQueries({ queryKey })
// Call custom onSuccess if provided
if (customOptions?.onSuccess) {
customOptions.onSuccess(...args)
}
},
...customOptions,
})
}
// Hook: Update entity
function useUpdate(customOptions?: UseMutationOptions<TEntity, Error, { id: string; data: TUpdateDto }>) {
const queryClient = useQueryClient()
return useMutation<TEntity, Error, { id: string; data: TUpdateDto }>({
mutationFn: ({ id, data }) => api.update(id, data),
onMutate: enableOptimistic
? async ({ id, data }) => {
// Cancel outgoing refetches
await queryClient.cancelQueries({ queryKey: [...queryKey, id] })
// Snapshot previous value
const previous = queryClient.getQueryData<TEntity>([...queryKey, id])
// Optimistically update
if (previous) {
queryClient.setQueryData<TEntity>([...queryKey, id], {
...previous,
...data,
} as TEntity)
}
return { previous }
}
: customOptions?.onMutate,
onError: enableOptimistic
? (...args) => {
// Rollback on error
const [, variables] = args
const context = { previous: queryClient.getQueryData<TEntity>([...queryKey, variables.id]) }
if (context?.previous) {
queryClient.setQueryData([...queryKey, variables.id], context.previous)
}
if (customOptions?.onError) {
customOptions.onError(...args)
}
}
: customOptions?.onError,
onSuccess: (...args) => {
// Invalidate queries
const [, variables] = args
queryClient.invalidateQueries({ queryKey })
queryClient.invalidateQueries({ queryKey: [...queryKey, variables.id] })
if (customOptions?.onSuccess) {
customOptions.onSuccess(...args)
}
},
...customOptions,
})
}
// Hook: Delete entity
function useDelete(customOptions?: UseMutationOptions<void, Error, string>) {
const queryClient = useQueryClient()
return useMutation<void, Error, string>({
mutationFn: api.delete,
onMutate: enableOptimistic
? async (id) => {
// Cancel queries
await queryClient.cancelQueries({ queryKey })
// Snapshot previous value
const previous = queryClient.getQueryData<TEntity[]>(queryKey)
// Optimistically remove from list
if (previous) {
queryClient.setQueryData<TEntity[]>(
queryKey,
previous.filter((item) => (item as { id: string }).id !== id)
)
}
return { previous }
}
: customOptions?.onMutate,
onError: enableOptimistic
? (...args) => {
// Rollback on error
const context = { previous: queryClient.getQueryData<TEntity[]>(queryKey) }
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous)
}
if (customOptions?.onError) {
customOptions.onError(...args)
}
}
: customOptions?.onError,
onSuccess: (...args) => {
// Invalidate queries
const [, id] = args
queryClient.invalidateQueries({ queryKey })
queryClient.invalidateQueries({ queryKey: [...queryKey, id] })
if (customOptions?.onSuccess) {
customOptions.onSuccess(...args)
}
},
...customOptions,
})
}
return {
useGetAll,
useGetById,
useCreate,
useUpdate,
useDelete,
}
}