feat: add optimistic updates for task and project creation

- Implement optimistic updates for useCreateTask mutation
  - Tasks now appear instantly with temporary ID
  - Replaced with real task from server on success
  - Rollback on error with proper error handling

- Implement optimistic updates for useCreateProject mutation
  - Projects appear immediately in the list
  - Temporary ID replaced with real one on success
  - Proper rollback on failure

- Both mutations follow existing patterns from update/delete operations
- Provides instant visual feedback improving perceived performance
- Eliminates 2-3 second delay before items appear in UI
This commit is contained in:
Rasmus Widing
2025-09-04 19:35:25 +03:00
parent ae592127f0
commit ca529e2620
2 changed files with 113 additions and 14 deletions

View File

@@ -49,22 +49,77 @@ export function useProjectFeatures(projectId: string | undefined) {
});
}
// Create project mutation
// Create project mutation with optimistic updates
export function useCreateProject() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (projectData: CreateProjectRequest) => projectService.createProject(projectData),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
showToast("Project created successfully!", "success");
onMutate: async (newProjectData) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: projectKeys.lists() });
// Snapshot the previous value
const previousProjects = queryClient.getQueryData(projectKeys.lists());
// Create optimistic project with temporary ID
const optimisticProject: Project = {
id: `temp-${Date.now()}`, // Temporary ID until real one comes back
title: newProjectData.title,
description: newProjectData.description,
github_repo: newProjectData.github_repo,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
prd: undefined,
features: [],
data: undefined,
docs: [],
pinned: false,
};
// Optimistically add the new project
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
if (!old) return [optimisticProject];
// Add new project at the beginning of the list
return [optimisticProject, ...old];
});
return { previousProjects };
},
onError: (error, variables) => {
onError: (error, variables, context) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to create project:", error, { variables });
// Rollback on error
if (context?.previousProjects) {
queryClient.setQueryData(projectKeys.lists(), context.previousProjects);
}
showToast(`Failed to create project: ${errorMessage}`, "error");
},
onSuccess: (response) => {
// Extract the actual project from the response
const newProject = response.project;
// Replace optimistic project with real one from server
queryClient.setQueryData(projectKeys.lists(), (old: Project[] | undefined) => {
if (!old) return [newProject];
// Replace temp project with real one
return old.map(project =>
project.id.startsWith('temp-') ? newProject : project
).filter((project, index, self) =>
// Remove any duplicates just in case
index === self.findIndex(p => p.id === project.id)
);
});
showToast("Project created successfully!", "success");
},
onSettled: () => {
// Always refetch to ensure consistency after operation completes
queryClient.invalidateQueries({ queryKey: projectKeys.lists() });
},
});
}

View File

@@ -23,25 +23,69 @@ export function useProjectTasks(projectId: string | undefined, enabled = true) {
});
}
// Create task mutation
// Create task mutation with optimistic updates
export function useCreateTask() {
const queryClient = useQueryClient();
const { showToast } = useToast();
return useMutation({
mutationFn: (taskData: CreateTaskRequest) => taskService.createTask(taskData),
onSuccess: (_data, variables) => {
// Invalidate tasks for the project
queryClient.invalidateQueries({
queryKey: taskKeys.all(variables.project_id),
onMutate: async (newTaskData) => {
// Cancel any outgoing refetches
await queryClient.cancelQueries({ queryKey: taskKeys.all(newTaskData.project_id) });
// Snapshot the previous value
const previousTasks = queryClient.getQueryData(taskKeys.all(newTaskData.project_id));
// Create optimistic task with temporary ID
const optimisticTask: Task = {
id: `temp-${Date.now()}`, // Temporary ID until real one comes back
...newTaskData,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
deleted_at: null,
subtasks: [],
// Ensure all required fields have defaults
task_order: newTaskData.task_order ?? 100,
status: newTaskData.status ?? "todo",
assignee: newTaskData.assignee ?? "User",
} as Task;
// Optimistically add the new task
queryClient.setQueryData(taskKeys.all(newTaskData.project_id), (old: Task[] | undefined) => {
if (!old) return [optimisticTask];
return [...old, optimisticTask];
});
return { previousTasks };
},
onError: (error, variables, context) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to create task:", error, { variables });
// Rollback on error
if (context?.previousTasks) {
queryClient.setQueryData(taskKeys.all(variables.project_id), context.previousTasks);
}
showToast(`Failed to create task: ${errorMessage}`, "error");
},
onSuccess: (data, variables) => {
// Replace optimistic task with real one from server
queryClient.setQueryData(taskKeys.all(variables.project_id), (old: Task[] | undefined) => {
if (!old) return [data];
// Remove temp task and add real one
return old.map(task =>
task.id.startsWith('temp-') ? data : task
).filter((task, index, self) =>
// Remove any duplicates just in case
index === self.findIndex(t => t.id === task.id)
);
});
queryClient.invalidateQueries({ queryKey: taskKeys.counts() });
showToast("Task created successfully", "success");
},
onError: (error, variables) => {
const errorMessage = error instanceof Error ? error.message : String(error);
console.error("Failed to create task:", error, { variables });
showToast(`Failed to create task: ${errorMessage}`, "error");
onSettled: (_data, _error, variables) => {
// Always refetch to ensure consistency after operation completes
queryClient.invalidateQueries({ queryKey: taskKeys.all(variables.project_id) });
},
});
}