/** * Optimistic Updates Utility * FE-API-018: Utilities for optimistic UI updates * * Provides helpers for implementing optimistic updates in React components * with automatic rollback on error */ import { QueryClient } from '@tanstack/react-query'; /** * Options for optimistic update */ export interface OptimisticUpdateOptions { /** QueryClient instance */ queryClient: QueryClient; /** Query keys to update optimistically */ queryKeys: (string | number)[][]; /** Function to generate optimistic data from variables */ optimisticData: (variables: TVariables) => TData; /** Function to update query data */ updateQueryData?: ( oldData: any, variables: TVariables, queryClient: QueryClient, ) => any; /** Function to rollback on error */ rollback?: (variables: TVariables, queryClient: QueryClient) => void; } /** * Creates an optimistic update configuration for useMutation * * @example * ```typescript * const queryClient = useQueryClient(); * const mutation = useMutation({ * mutationFn: updatePlaylist, * ...createOptimisticUpdate({ * queryClient, * queryKeys: [['playlist', playlistId], ['playlists']], * optimisticData: (variables) => ({ * ...currentPlaylist, * ...variables, * }), * }), * }); * ``` */ export function createOptimisticUpdate( options: OptimisticUpdateOptions & { queryClient: QueryClient }, ) { const { queryClient, queryKeys, optimisticData, updateQueryData, rollback, } = options; return { onMutate: async (variables: TVariables) => { // Cancel outgoing refetches to avoid overwriting optimistic update // Snapshot previous values for rollback const previousValues: Array<{ queryKey: (string | number)[]; data: any }> = []; // Cancel queries and snapshot previous values for (const queryKey of queryKeys) { await queryClient.cancelQueries({ queryKey }); const previousData = queryClient.getQueryData(queryKey); previousValues.push({ queryKey, data: previousData }); // Apply optimistic update if (updateQueryData) { queryClient.setQueryData(queryKey, (old: any) => updateQueryData(old, variables, queryClient), ); } else { // Default: replace with optimistic data queryClient.setQueryData(queryKey, optimisticData(variables)); } } // Return context with previous values for rollback return { previousValues }; }, onError: (error: any, variables: TVariables, context: any) => { if (context?.previousValues) { // Restore previous values for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } // Call custom rollback if provided if (rollback) { rollback(variables, queryClient); } }, onSettled: () => { // Always refetch after error or success to ensure consistency for (const queryKey of queryKeys) { queryClient.invalidateQueries({ queryKey }); } }, }; } /** * Helper for optimistic updates with array operations (add, remove, update) */ export interface ArrayOptimisticUpdateOptions { /** QueryClient instance */ queryClient: QueryClient; /** Query keys to update */ queryKeys: (string | number)[][]; /** Query key that contains the array */ arrayQueryKey: (string | number)[]; /** Function to get the array from query data */ getArray?: (data: any) => TItem[]; /** Operation type */ operation: 'add' | 'remove' | 'update'; /** Function to generate optimistic item */ optimisticItem?: (variables: TVariables) => TItem; /** Function to match item for remove/update */ matchItem?: (item: TItem, variables: TVariables) => boolean; /** Function to update item */ updateItem?: (item: TItem, variables: TVariables) => TItem; } /** * Creates optimistic update for array operations (add, remove, update) * * @example * ```typescript * // Add item * const addMutation = useMutation({ * mutationFn: addTrackToPlaylist, * ...createArrayOptimisticUpdate({ * queryKeys: [['playlist', playlistId]], * arrayQueryKey: ['playlist', playlistId, 'tracks'], * operation: 'add', * optimisticItem: (variables) => ({ * id: `temp-${Date.now()}`, * track_id: variables.trackId, * ...variables, * }), * }), * }); * * // Remove item * const removeMutation = useMutation({ * mutationFn: removeTrackFromPlaylist, * ...createArrayOptimisticUpdate({ * queryKeys: [['playlist', playlistId]], * arrayQueryKey: ['playlist', playlistId, 'tracks'], * operation: 'remove', * matchItem: (item, variables) => item.id === variables.trackId, * }), * }); * ``` */ export function createArrayOptimisticUpdate( options: ArrayOptimisticUpdateOptions, ) { const { queryClient, queryKeys, arrayQueryKey, getArray = (data: any) => (Array.isArray(data) ? data : data?.items || []), operation, optimisticItem, matchItem, updateItem, } = options; return { onMutate: async (variables: TVariables) => { const previousValues: Array<{ queryKey: (string | number)[]; data: any }> = []; // Cancel queries and snapshot for (const queryKey of queryKeys) { await queryClient.cancelQueries({ queryKey }); const previousData = queryClient.getQueryData(queryKey); previousValues.push({ queryKey, data: previousData }); } // Apply optimistic update to array for (const queryKey of queryKeys) { queryClient.setQueryData(queryKey, (old: any) => { if (!old) return old; const array = getArray(old); let newArray: TItem[]; switch (operation) { case 'add': if (optimisticItem) { newArray = [...array, optimisticItem(variables)]; } else { newArray = array; } break; case 'remove': if (matchItem) { newArray = array.filter((item) => !matchItem(item, variables)); } else { newArray = array; } break; case 'update': if (matchItem && updateItem) { newArray = array.map((item) => matchItem(item, variables) ? updateItem(item, variables) : item, ); } else { newArray = array; } break; default: newArray = array; } // Update the array in the data structure if (Array.isArray(old)) { return newArray; } else if (old.items) { return { ...old, items: newArray }; } else if (old.tracks) { return { ...old, tracks: newArray }; } else { return { ...old, [arrayQueryKey[arrayQueryKey.length - 1] as string]: newArray }; } }); } return { previousValues }; }, onError: (error: any, variables: TVariables, context: any) => { if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } }, onSettled: () => { for (const queryKey of queryKeys) { queryClient.invalidateQueries({ queryKey }); } }, }; } /** * Helper for toggle operations (like/unlike, follow/unfollow) */ export interface ToggleOptimisticUpdateOptions { /** QueryClient instance */ queryClient: QueryClient; /** Query keys to update */ queryKeys: (string | number)[][]; /** Current state value */ currentValue: boolean; /** Function to get the value from query data */ getValue?: (data: any) => boolean; /** Function to get the count from query data */ getCount?: (data: any) => number; /** New state value (opposite of currentValue) */ newValue?: boolean; /** Whether to update count */ updateCount?: boolean; /** Count increment/decrement */ countDelta?: number; } /** * Creates optimistic update for toggle operations * * @example * ```typescript * const likeMutation = useMutation({ * mutationFn: () => likeTrack(trackId), * ...createToggleOptimisticUpdate({ * queryKeys: [['track', trackId], ['tracks']], * currentValue: isLiked, * updateCount: true, * countDelta: 1, * }), * }); * ``` */ export function createToggleOptimisticUpdate( options: ToggleOptimisticUpdateOptions, ) { const { queryClient, queryKeys, currentValue, getValue = (data: any) => data?.is_liked || data?.isLiked || false, getCount = (data: any) => data?.like_count || data?.likeCount || 0, newValue = !currentValue, updateCount = true, countDelta = 1, } = options; return { onMutate: async (variables: TVariables) => { const previousValues: Array<{ queryKey: (string | number)[]; data: any }> = []; for (const queryKey of queryKeys) { await queryClient.cancelQueries({ queryKey }); const previousData = queryClient.getQueryData(queryKey); previousValues.push({ queryKey, data: previousData }); queryClient.setQueryData(queryKey, (old: any) => { if (!old) return old; const updated = { ...old }; // Update boolean value if ('is_liked' in updated) { updated.is_liked = newValue; } if ('isLiked' in updated) { updated.isLiked = newValue; } if ('is_following' in updated) { updated.is_following = newValue; } if ('isFollowing' in updated) { updated.isFollowing = newValue; } // Update count if (updateCount) { if ('like_count' in updated) { updated.like_count = Math.max(0, (updated.like_count || 0) + countDelta); } if ('likeCount' in updated) { updated.likeCount = Math.max(0, (updated.likeCount || 0) + countDelta); } if ('follower_count' in updated) { updated.follower_count = Math.max(0, (updated.follower_count || 0) + countDelta); } if ('followerCount' in updated) { updated.followerCount = Math.max(0, (updated.followerCount || 0) + countDelta); } } return updated; }); } return { previousValues, newValue }; }, onError: (error: any, variables: TVariables, context: any) => { if (context?.previousValues) { for (const { queryKey, data } of context.previousValues) { queryClient.setQueryData(queryKey, data); } } }, onSettled: () => { for (const queryKey of queryKeys) { queryClient.invalidateQueries({ queryKey }); } }, }; }