React 의 Portal 을 통해서 여러 컴포넌트에서 modal view 와 같은 페이지 외부의 UI 를 보여주도록 하는 방식보다 페이지 외부의 UI 를 한 곳에서 관리하고 보여주는 방식을 사용하고 싶었습니다.

<aside> 💡 React Portal 을 쓰면 뭔가… Modal view 를 결정한다던가 Modal view 를 open 한다던가 렌더링을 트리거 한다던가 하는 부분이 개별 컴포넌트들 단위로 산개한다는 느낌을 받았습니다. 동일 기능이 산개되는 것 보다, 한 곳에서 주관하고 요청을 할 수 있는 수단들을 개별 컴포넌트에게 넘겨주는게 더 멋져 보였습니다!

</aside>

이를 위해 최초에는 Redux 를 통해 UI 의 상태를 관리하고 이를 컨트롤하는 상위 컴포넌트를 구현했습니다.

<aside> 💡 편의를 위해 modal 을 기준으로 설명합니다.

</aside>

Untitled

<aside> 🙋🏻‍♂️ “modal 이 닫힐 때 modal 을 호출한 컴포넌트에 어떤 이벤트나 액션이 동작했으면 좋겠어요.”

</aside>

와 같은 팀원분들의 요청이 있었습니다. 이를 해결하기 위해서 redux store 에 modal props 와 같은 상태를 추가했습니다. 이를 통해 컴포넌트는

openModal({ name: "kim", age: 29 })

와 같은 형태로 modal 에 props 를 전달 할 수 있게 되었습니다. 하지만 문제가 발생합니다. 바로 함수를 props 로 전달할 수 없다는 것입니다. redux 는 함수와 같이 직렬화 할 수 없는 값들을 상태로 가질 수 없습니다. 그렇기 때문에 컴포넌트가 변경을 감지하고 이벤트나 액션을 수행할 수 있도록 redux 내부에 여러 상태를 둘 수 밖에 없고 컴포넌트와 modal 간에 통신이 필요한 modal view 수 만큼 redux store 를 만들어야 한다는 다소 지저분한 문제도 발생하게 됩니다.

포스트 생성 페이지와 관련된 modal view 가 있다고 가정해 보겠습니다. 해당 modal 은 포스트의 사진을 클릭하면 해당 사진 좌표에 tag 를 생성/수정/삭제 하는 역할을 합니다. 이를 위해 좌표값이나 tag Id 등을 페이지로부터 받아야 합니다. 또한 modal 을 통해 태그를 생성/수정/삭제 하면 포스트 생성 페이지는 이를 반영해야 합니다. 이를 위해 newPostReducer 를 별도로 생성해야 합니다.

// newPostReducer

const initialState: TagsState = {
  tags: [],
};

const newPostSlice = createSlice({
  name: "newPost",
  initialState,
  reducers: {
    newPostReducer: (state, action: PayloadAction<NEW_POST_ACTION>) => {
      switch (action.payload.type) {
        case "SET_TAGS": {
          return {
            ...state,
            tags: action.payload.tags,
          };
        }
        case "ADD_TAG": {
          const newTag = action.payload.tag;
          const newTags = [...state.tags];
          const existTag = newTags.findIndex(tag => tag.id === newTag.id);

          if (existTag !== 1) {
            newTags.push(newTag);
            return { ...state, tags: newTags };
          } else {
            return { ...state };
          }
        }
        case "DELETE_TAG": {
          const deletedTagId = action.payload.tagId;
          const deletedTagIndex = state.tags.findIndex(
            tag => tag.id === deletedTagId
          );

          if (deletedTagIndex !== -1) {
            const newTags = [...state.tags];
            newTags.splice(deletedTagIndex, 1);
            return {
              ...state,
              tags: newTags,
            };
          } else {
            return {
              ...state,
            };
          }
        }
        case "INIT": {
          return {
            tags: [],
          };
        }
      }
    },
  },
});

그리고 우리는 reducer 의 state, action 을 컴포넌트에서 직접 select, dispatch 하는 방식을 사용하지 않고 별도의 Hook 을 통해 제공하는 방식을 사용하기 때문에 useNewPost 라는 Hook 을 작성해야 합니다.

export const useNewPost = () => {
  const dispatch = useDispatch();

  const { tags, channelId, channelName } = useSelector(
    ({ newPost }: RootState) => newPost
  );

  const setTags = useCallback(
    (tags: Tag[]) => {
      dispatch(newPostActions.newPostReducer({ type: "SET_TAGS", tags }));
    },
    [dispatch]
  );

  const addTag = useCallback(
    (tag: Tag) => {
      dispatch(newPostActions.newPostReducer({ type: "ADD_TAG", tag }));
    },
    [dispatch]
  );

  const deleteTag = useCallback(
    (tagId: string) => {
      dispatch(newPostActions.newPostReducer({ type: "DELETE_TAG", tagId }));
    },
    [dispatch]
  );

  const init = useCallback(() => {
    dispatch(newPostActions.newPostReducer({ type: "INIT" }));
  }, [dispatch]);

  const context = {
    tags,
    setTags: (tags: Tag[]) => setTags(tags),
    addTag: (tag: Tag) => addTag(tag),
    deleteTag: (tagId: string) => deleteTag(tagId),
    init: () => init(),
  };

  return context;
};

드디어 post 생성 페이지와 tag 생성 modal 이 서로 통신할 수 있게 되었습니다.

const CreatePostPageView = () => {
  const { openModal, setModalView } = useUI();
  const { tags, ... } = useNewPost();

  ...

	const postFileClickHandler = (
	    event: React.MouseEvent<HTMLDivElement, MouseEvent>
	  ) => {
	    const nativeEvent = event.nativeEvent;
	
	    const objectWidth = tagWrapperRef.current?.offsetWidth;
	    const objectHeight = tagWrapperRef.current?.offsetHeight;
	
	    setModalView("TAG_CREATE_VIEW");
	    if (objectHeight && objectWidth) {
	      openModal({
	        x: (nativeEvent.offsetX / objectWidth) * 100,
	        y: (nativeEvent.offsetY / objectHeight) * 100,
	      });
	    }
	  };

  const tagClickHandler = (id: string, x?: number, y?: number) => {
    setModalView("TAG_CREATE_VIEW");
    openModal({
      isEdit: true,
      id: id,
      x,
      y,
    });
  };

  ...
};

export default CreatePostPageView;

CretePostPageView 는 newPostReducer 의 tags state 의 변경을 감지하는 방식으로 tag 정보의 생성/수정/삭제를 반영 할 수 있게 됩니다.