Archon icon indicating copy to clipboard operation
Archon copied to clipboard

Create reusable useAutoSave hook for inline editing components

Open Wirasm opened this issue 3 months ago • 0 comments

Problem Statement

Multiple components across the codebase implement inline editing with auto-save functionality, but each has different patterns and varying levels of error handling:

  • EditableTableCell uses setTimeout without proper error handling for Select/ComboBox
  • KnowledgeCardTitle has async save with basic error handling
  • KnowledgeCardTags uses mutations with try/catch
  • KnowledgeCardType has inline editing functionality

This leads to:

  • Inconsistent UX across editable components
  • Code duplication
  • Potential race conditions and silent failures
  • Difficult to maintain and test

Proposed Solution

Create a shared useAutoSave hook that provides consistent inline editing behavior across all components.

Location

/src/features/shared/hooks/useAutoSave.ts

Proposed API Design

interface UseAutoSaveOptions<T> {
  initialValue: T;
  onSave: (value: T) => Promise<void>;
  onSuccess?: (value: T) => void;
  onError?: (error: Error, previousValue: T) => void;
  debounceMs?: number;
  validateBeforeSave?: (value: T) => boolean;
  revertOnError?: boolean; // default: true
}

interface UseAutoSaveReturn<T> {
  value: T;
  setValue: (value: T) => void;
  save: () => Promise<void>;
  saveImmediate: (value: T) => Promise<void>;
  isSaving: boolean;
  error: Error | null;
  isDirty: boolean;
  reset: () => void;
}

export function useAutoSave<T>(options: UseAutoSaveOptions<T>): UseAutoSaveReturn<T>;

Example Implementation

export function useAutoSave<T>({
  initialValue,
  onSave,
  onSuccess,
  onError,
  debounceMs = 0,
  validateBeforeSave,
  revertOnError = true
}: UseAutoSaveOptions<T>): UseAutoSaveReturn<T> {
  const [value, setValue] = useState<T>(initialValue);
  const [isSaving, setIsSaving] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const previousValueRef = useRef<T>(initialValue);
  const saveTimeoutRef = useRef<NodeJS.Timeout>();

  const isDirty = useMemo(() => {
    return JSON.stringify(value) !== JSON.stringify(initialValue);
  }, [value, initialValue]);

  const performSave = useCallback(async (valueToSave: T) => {
    if (validateBeforeSave && !validateBeforeSave(valueToSave)) {
      return;
    }

    setIsSaving(true);
    setError(null);
    
    try {
      await onSave(valueToSave);
      previousValueRef.current = valueToSave;
      onSuccess?.(valueToSave);
    } catch (err) {
      const error = err instanceof Error ? err : new Error(String(err));
      setError(error);
      
      if (revertOnError) {
        setValue(previousValueRef.current);
      }
      
      onError?.(error, previousValueRef.current);
    } finally {
      setIsSaving(false);
    }
  }, [onSave, onSuccess, onError, validateBeforeSave, revertOnError]);

  const save = useCallback(async () => {
    await performSave(value);
  }, [value, performSave]);

  const saveImmediate = useCallback(async (newValue: T) => {
    setValue(newValue);
    await performSave(newValue);
  }, [performSave]);

  const debouncedSave = useCallback((newValue: T) => {
    if (saveTimeoutRef.current) {
      clearTimeout(saveTimeoutRef.current);
    }

    if (debounceMs > 0) {
      saveTimeoutRef.current = setTimeout(() => {
        performSave(newValue);
      }, debounceMs);
    } else {
      performSave(newValue);
    }
  }, [debounceMs, performSave]);

  const reset = useCallback(() => {
    setValue(initialValue);
    setError(null);
    if (saveTimeoutRef.current) {
      clearTimeout(saveTimeoutRef.current);
    }
  }, [initialValue]);

  // Cleanup on unmount
  useEffect(() => {
    return () => {
      if (saveTimeoutRef.current) {
        clearTimeout(saveTimeoutRef.current);
      }
    };
  }, []);

  return {
    value,
    setValue,
    save,
    saveImmediate,
    isSaving,
    error,
    isDirty,
    reset
  };
}

Usage Examples

In EditableTableCell

const autoSave = useAutoSave({
  initialValue: value,
  onSave: async (newValue) => {
    await onSave(newValue);
  },
  onSuccess: () => {
    setIsEditing(false);
  },
  onError: (error) => {
    console.error("Failed to save:", error);
    showToast("Failed to save", "error");
  }
});

// In Select component
<Select
  value={autoSave.value}
  onValueChange={autoSave.saveImmediate}
  disabled={autoSave.isSaving}
/>

In KnowledgeCardTitle

const autoSave = useAutoSave({
  initialValue: title,
  onSave: async (newTitle) => {
    await updateMutation.mutateAsync({
      sourceId,
      updates: { title: newTitle }
    });
  },
  validateBeforeSave: (value) => value.trim().length > 0,
  debounceMs: 500, // Debounce for text input
  onSuccess: () => {
    setIsEditing(false);
  }
});

Benefits

  1. Consistency: Uniform behavior across all inline editing components
  2. Error Resilience: Built-in error handling and rollback
  3. Race Condition Prevention: Proper async handling and debouncing
  4. Code Reuse: DRY principle, single source of truth
  5. Testability: One place to test all auto-save logic
  6. Extensibility: Easy to add features like retry logic, optimistic updates
  7. Type Safety: Full TypeScript support with generics
  8. Loading States: Built-in isSaving state for UI feedback

Components to Migrate

  • [ ] /src/features/projects/tasks/components/EditableTableCell.tsx
  • [ ] /src/features/knowledge/components/KnowledgeCardTitle.tsx
  • [ ] /src/features/knowledge/components/KnowledgeCardTags.tsx
  • [ ] /src/features/knowledge/components/KnowledgeCardType.tsx
  • [ ] Any future inline editing components

Testing Strategy

  1. Unit tests for the hook itself
  2. Integration tests for each component using the hook
  3. Test error scenarios and rollback behavior
  4. Test debouncing and race condition handling
  5. Test validation and dirty state detection

Priority

Medium - This is a code quality and UX consistency improvement, not critical functionality. Should be addressed after core features are stable.

Related

  • Identified during Phase 2 Query Keys Standardization work
  • CodeRabbit review suggestion about EditableTableCell error handling

Wirasm avatar Sep 18 '25 06:09 Wirasm