
useState만으로는 Undo/Redo를 구현하기 어렵습니다. 이전 상태를 저장하고 현재 위치를 가리키는 인덱스 관리가 별도로 필요하기 때문입니다.useRef에 담아 리렌더링을 유발하지 않게 하고, 전체 로직을 useStateWithHistory 커스텀 훅으로 캡슐화했습니다.slice로 현재 인덱스까지만 남기고 새 값을 추가해 해결했습니다.React의 useState는 현재 상태를 관리하는 데 최적화되어 있습니다. 하지만 사용자가 입력한 내용을 취소(Undo)하거나, 취소한 내용을 다시 실행(Redo)하는 기능을 구현하려면 useState만으로는 부족합니다.
이전 상태값들을 별도 배열에 저장하고 현재 가리키는 위치(Index)를 관리하는 로직이 추가로 필요하기 때문입니다. 이 로직을 매번 컴포넌트 안에서 작성하면 코드가 복잡해지고 재사용하기 어렵습니다.
상태 이력을 관리하기 위해 두 가지 핵심 전략을 사용했습니다.
useRef로 메모리상에 유지합니다.useStateWithHistory 훅은 내부에 현재 상태를 위한 state, 전체 이력을 담는 history 배열, 현재 위치를 가리키는 historyIndex를 가집니다.
상태가 업데이트될 때마다 history 배열에 새 값이 추가되고, goBack이나 goForward를 호출하면 인덱스만 조절해 해당 시점의 상태를 setStateInternal로 반영하는 구조입니다.
TypeScript를 적용해 타입 안정성을 확보한 커스텀 훅입니다.
import { useState, useRef, useCallback } from 'react'; function useStateWithHistory<T>(initialState: T) { const [state, setStateInternal] = useState<T>(initialState); // 렌더링 사이에도 유지되어야 하지만, 변경 시 리렌더링을 유발하지 않아야 함 const history = useRef<T[]>([initialState]); const historyIndex = useRef(0); // 새로운 상태 설정 const setState = useCallback((newState: T) => { // 현재 인덱스 이후의 히스토리는 제거 (새로운 분기 시작) history.current = history.current.slice(0, historyIndex.current + 1); history.current.push(newState); historyIndex.current = history.current.length - 1; setStateInternal(newState); }, []); // 뒤로 가기 (Undo) const goBack = useCallback(() => { if (historyIndex.current === 0) return; historyIndex.current--; setStateInternal(history.current[historyIndex.current]); }, []); // 앞으로 가기 (Redo) const goForward = useCallback(() => { if (historyIndex.current >= history.current.length - 1) return; historyIndex.current++; setStateInternal(history.current[historyIndex.current]); }, []); return [state, setState, goBack, goForward, history.current] as const; }
단순히 push만 하면 문제가 생깁니다. 실제 서비스에서는 '뒤로 가기'를 한 상태에서 새 값을 set하면 그 이후의 미래 히스토리는 삭제되어야 합니다. 이를 위해 slice로 현재 인덱스까지만 남기고 새 값을 추가하도록 처리했습니다.
useStateWithHistory는 React의 기본 Hook들을 조합해 얼마나 강력한 기능을 만들 수 있는지 보여주는 예시입니다. 복잡한 폼 입력이나 그림판 같은 에디터 기능을 구현할 때 이 훅을 활용해 사용자에게 더 높은 자유도를 제공해 보세요.
참고 자료