돌멩이 하나/에러는 미래의 연봉

custom event로 렌더링과 무관한 데이터 추적하기

미래에서 온 개발자 2024. 8. 11. 16:21

프로젝트에서 일종의 에디터 역할을 하는 화면이 있어서 `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 패턴(이라고 할 수 있을까?)을 사용할 수 있다는 걸 알게 되었다. 

 

 

 

📚 참고 자료

 

Using custom events in React - LogRocket Blog

Learn how to build your own custom events in React apps, an essential skill for frontend devs of all levels, in this complete tutorial.

blog.logrocket.com

 

pub/sub 패턴으로 프론트엔드 데이터 태깅 관리하기

프론트엔드에서는 사용자가 화면을 움직이는 방식과 데이터를 함께 다루게 됩니다. 그렇기에 코드의 복잡도가 늘어나는 경우를 많이 접하게 됩니다. 코드의 복잡도가 늘어나는 문제를 해결하

naamukim.tistory.com