Archon
Archon copied to clipboard
Create reusable useAutoSave hook for inline editing components
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:
EditableTableCellusessetTimeoutwithout proper error handling for Select/ComboBoxKnowledgeCardTitlehas async save with basic error handlingKnowledgeCardTagsuses mutations with try/catchKnowledgeCardTypehas 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
- Consistency: Uniform behavior across all inline editing components
- Error Resilience: Built-in error handling and rollback
- Race Condition Prevention: Proper async handling and debouncing
- Code Reuse: DRY principle, single source of truth
- Testability: One place to test all auto-save logic
- Extensibility: Easy to add features like retry logic, optimistic updates
- Type Safety: Full TypeScript support with generics
- 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
- Unit tests for the hook itself
- Integration tests for each component using the hook
- Test error scenarios and rollback behavior
- Test debouncing and race condition handling
- 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