React 의 Portal 을 통해서 여러 컴포넌트에서 modal view 와 같은 페이지 외부의 UI 를 보여주도록 하는 방식보다 페이지 외부의 UI 를 한 곳에서 관리하고 보여주는 방식을 사용하고 싶었습니다.
<aside> 💡 React Portal 을 쓰면 뭔가… Modal view 를 결정한다던가 Modal view 를 open 한다던가 렌더링을 트리거 한다던가 하는 부분이 개별 컴포넌트들 단위로 산개한다는 느낌을 받았습니다. 동일 기능이 산개되는 것 보다, 한 곳에서 주관하고 요청을 할 수 있는 수단들을 개별 컴포넌트에게 넘겨주는게 더 멋져 보였습니다!
</aside>
이를 위해 최초에는 Redux 를 통해 UI 의 상태를 관리하고 이를 컨트롤하는 상위 컴포넌트를 구현했습니다.
<aside> 💡 편의를 위해 modal 을 기준으로 설명합니다.
</aside>
<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 정보의 생성/수정/삭제를 반영 할 수 있게 됩니다.