
지출 내역 페이지에는 삭제 버튼이 있다.
기존에는 confirm() 팝업으로 한 번 더 물어보는 방식이었다.
하지만 실제로 써보면 이런 문제가 있다.
그래서
“삭제됨 → 실행 취소 가능”
이 UX를 프론트엔드만으로 구현해봤다.
삭제 버튼을 눌러도 API를 바로 호출하지 않는다.
대신 아래 흐름으로 동작한다:
setTimeout)type PendingDelete = {
expense: Expense;
snapshot: Expense[];
totalSnapshot: number;
};
const [pendingDelete, setPendingDelete] = useState<PendingDelete | null>(null);
const [undoOpen, setUndoOpen] = useState(false);
const deleteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
타이머 ID는 렌더링과 무관한 값이다.
useState를 쓰면 불필요한 리렌더가 발생한다.
const handleDelete = (id: number) => {
const target = expenses.find((e) => e.id === id);
if (!target) return;
// 기존 대기 삭제 확정
if (deleteTimerRef.current) {
clearTimeout(deleteTimerRef.current);
deleteTimerRef.current = null;
if (pendingDelete) deleteExpense(pendingDelete.expense.id);
}
const snapshot = [...expenses];
const totalSnapshot = totalElements;
// Optimistic Update
setExpenses((prev) => prev.filter((e) => e.id !== id));
setTotalElements((prev) => prev - 1);
setPendingDelete({ expense: target, snapshot, totalSnapshot });
setUndoOpen(true);
// 5초 후 실제 삭제
deleteTimerRef.current = setTimeout(async () => {
try {
await deleteExpense(id);
fetchExpenses();
fetchMonthlyTotal();
} catch {
// 실패 시 롤백
setExpenses(snapshot);
setTotalElements(totalSnapshot);
} finally {
setPendingDelete(null);
setUndoOpen(false);
deleteTimerRef.current = null;
}
}, 5000);
};
const handleUndo = () => {
if (!pendingDelete || !deleteTimerRef.current) return;
clearTimeout(deleteTimerRef.current);
deleteTimerRef.current = null;
setExpenses(pendingDelete.snapshot);
setTotalElements(pendingDelete.totalSnapshot);
setPendingDelete(null);
setUndoOpen(false);
};
useEffect(() => {
return () => {
if (deleteTimerRef.current) {
clearTimeout(deleteTimerRef.current);
}
};
}, []);
페이지 이동 시 타이머 누수 방지
<Snackbar
open={undoOpen}
anchorOrigin={{ vertical: "bottom", horizontal: "center" }}
message={`"${pendingDelete?.expense.categoryName}" 지출이 삭제됩니다`}
action={
<Button onClick={handleUndo} color="warning" size="small">
실행 취소
</Button>
}
/>
autoHideDuration 사용XsetTimeout 하나로 통제이 로직은 여러 페이지에서 재사용 가능하다.
DashboardPage ← 삭제 로직
└─ RecentExpenseSection ← UI만 담당
function useUndoDelete(onConfirm: (id: number) => Promise<void>) {
const [pendingDelete, setPendingDelete] = useState(null);
const [undoOpen, setUndoOpen] = useState(false);
const deleteTimerRef = useRef(null);
// handleDelete, handleUndo 구현
return { handleDelete, handleUndo, undoOpen, pendingDelete };
}
하지만 상태 구조가 복잡하면 굳이 분리 안 해도 괜찮다
fetch 재호출useRef 사용
예시로 등록한 지출 내역이다.

삭제 아이콘을 누르면 지출 내역에서 즉시 사라지고 메세지가 뜬다.

이 때 실행취소를 누르지 않으면 내역이 없어진 것이 확정되며 그래프도 이를 반영해 다시 계산된다.
UX는 크게 개선되지만 구현 비용은 낮다