프로젝트에서 일종의 에디터 역할을 하는 화면이 있어서 `contenteditable` 속성을 사용해 사용자가 텍스트를 편집할 수 있게 하고 있다. React에서 `contenteditable` 속성을 사용하는 것은 쉽지 않은데, 특정 요소에 `contenteditable` 속성을 사용하려고 하는 경우, 다음과 같은 경고를 한다.
Warning: A component is contentEditable and contains children managed by React. It is now your responsibility to guarantee that none of those nodes are unexpectedly modified or duplicated. This is probably not intentional.
`suppressContentEditableWarning` 속성을 추가하면 이러한 경고는 금새 사라지긴 한다. contenteditable을 사용하는 건 사용자에게 텍스트 편집을 허용하면서도 화면 내에서 텍스트 부분이 단순히 요소의 textContent 만으로써 동작하는 것이 아닌 다양한 타입의 NodeList를 가진 하위 요소를 컨트롤하기 위함이다. 그런데 이 과정에서 contenteditable 속성을 가진 요소에 innerHTML을 컨트롤하려고 하는 경우, 리액트의 상태로 리렌더링이 되면 타이핑을 할 때마다 캐럿이 영역의 맨앞으로 이동하는 캐럿 점핑(caret jumping) 이슈가 발생한다. innerHTML을 사용한다는 것은 브라우저가 html string을 파싱해서 새로운 DOM tree를 구축하는 과정을 포함하고 있는데, 이 과정에서 기존 DOM node에 있던 캐럿 위치를 소실하기 때문에 캐럿 위치를 저장했다가 복원하는 로직을 추가해줘야 한다.
캐럿 위치를 복원한다고 해도 이걸로 끝이 아니고, 한글 같은 경우는 '사과'를 입력하려고 할 때 'ㅅㅏㄱㅗㅏ'로 입력되는 자모음 분리 이슈까지 있다. 이러한 이슈 때문에 contenteditable을 사용하면서 ui를 그리는 리액트의 상태 데이터를 최신 데이터로 업데이트하는데 있어서 어려움을 겪고 있다.
지금 사용하고 있는 방법은 ui를 그리는 react의 상태와 사용자가 편집한 텍스트에 대한 최신 데이터를 이원화하여 관리하는 방식을 도입하였다. 이 과정에서 custom event를 사용해 contenteditable 요소에서 change 이벤트가 발생할 때 커스텀 이벤트를 dispatch 하고, 필요한 곳에서 이 커스텀 이벤트를 구독하여 data store에 저장한다.
구체적인 코드는 다음과 같다.
// utils/customEvent.ts
type CustomEventListener = (event: CustomEvent) => void;
type ValidEventNames = keyof DocumentEventMap | string;
function subscribe(eventName: ValidEventNames, listener: CustomEventListener) {
document.addEventListener(eventName, listener as EventListener);
}
function unsubscribe(eventName: ValidEventNames, listener: CustomEventListener) {
document.removeEventListener(eventName, listener as EventListener);
}
function publish(eventName: ValidEventNames, data: any) {
const event = new CustomEvent(eventName, { detail: data });
document.dispatchEvent(event);
}
export { publish, subscribe, unsubscribe };
텍스트 편집이 일어나는 컴포넌트에서 publish 함수를 import 해서 커스텀 이벤트를 생성하고, 데이터 가진 이벤트를 dispatch 한다.
import { publish } from '@/utils/customEvent';
const TextCell = forwardRef((props: Props, ref: ForwardedRef<HTMLDivElement>) => {
const originalText = useRef(props.text);
// 생략
const storeTextOnTheServer = useDebouncedCallback(() => {
// 서버에 데이터 저장을 요청하는 useMutation hook
saveEditedText(props.latestData);
}, 1_000);
const onInput = (event: React.ChangeEvent<HTMLDivElement>) => {
const newText = event.target.textContent;
// 수정된 텍스트가 기존 텍스트와 다를 경우에만 업데이트 진행
if (newText !== null && newText !== originalText.current) {
publish('textChange', { id: props.id, text: newText });
storeTextOnTheServer(newText);
originalText.current = newText;
}
};
return (
<div
ref={ref}
contentEditable
onInput={onInput}
/>
);
}
export default TextCell;
렌더링과 무관한 최신 데이터 저장을 위해 useRef를 사용해 데이터 저장소를 만들고, 해당 컴포넌트의 useEffect에서 subscribe와 unsubscribe를 호출한다.
import { subscribe, unsubscribe } from '@/utils/customEvent';
export default function DataStoreComponent(props: Props) {
const latestDataStore = useRef<{
[key: number]: DataStore;
}>({
[id]: {
text: props.text,
// other data
},
});
useEffect(() => {
const listener = (event: CustomEvent) => {
if (event.detail.id !== props.id) return;
const { id, text } = event.detail;
const existingContent = latestDataStore.current[id];
// text 값을 갱신
if (existingContent) existingContent.text = text;
latestDataStore.current[id] = existingContent;
};
subscribe('textChange', listener);
return () => {
unsubscribe('textChange', listener);
};
}, [props.id]);
return (
// 생략
);
}
데이터를 이원화해서 관리하는 방식이 일반적인 방식은 아니지만, 렌더링 사이클과 무관한 데이터 관리가 필요할 때 커스텀 이벤트를 생성하여 dispatch하고 구독하는 일종의 observer 패턴(이라고 할 수 있을까?)을 사용할 수 있다는 걸 알게 되었다.
📚 참고 자료
'돌멩이 하나 > 에러는 미래의 연봉' 카테고리의 다른 글
Image 컴포넌트의 src에 문자열 경로를 지정했을 때 이미지가 뜨지 않는 이슈 (0) | 2024.09.08 |
---|---|
next.js로 만든 토이 프로젝트 배포 (0) | 2024.09.01 |
무한 스크롤 이슈 디버깅 과정 (0) | 2024.07.27 |
Checkbox 컴포넌트를 만들면서 알게 된 것들 (0) | 2024.05.22 |
React Query를 활용한 테이블에서 데이터 정렬하기 (0) | 2024.03.21 |