위의 웹 페이지에서 왼쪽의 컴포넌트 목록에서 각 메뉴를 클릭하면 오른쪽의 화면의 내용이 전환된다.
웹 버전을 모바일 버전으로 변경을 하게 되면 아래처럼 변환되고 좌측 상단의 메뉴 버튼을 클릭하면 메뉴가 표출된다.
지금이야 잘 작동을 하지만 최초 버전에서 제목의 에러 메시지를 만나게 되었다.
Cannot update a component (`Main`) while rendering a different component (`MenuBar`).
To locate the bad setState() call inside `MenuBar`,
화면의 컴포넌트 구조는 단순하다.
웹 버전일 때는 아래와 같고
모바일 버전에서는 다음과 같다.
문제는 모바일 버전에서 목록을 클릭할 때 마다
Main 컴포넌트의 인덱스가 변하면서 화면이 전한되어야 하는데 모바일 버전의 메뉴바를 생성시키기 위해서 메뉴 버튼을 클릭하게 되면
Cannot update a component (`Main`) while rendering a different component (`MenuBar`).
To locate the bad setState() call inside `MenuBar`,
위와 같은 에러 메시지를 만난다. 잘 읽어보면 MenuBar 컴포넌트를 렌더링 하는 중에 Man 컴포넌트를 업데이트 할 수 없다는 뜻이다.
위의 에러에 관한 Facebook 팀 의견 보기 여기 클릭
Facebook 팀도 이러한 에러를 버그로 보고 수정하려고 하였으나 hook 도입 초기에 이 부분을 생각하지 못했다고 한다.
위와 같은 에러를 만나는 이유는
1) 전역 저장소로 상태를 관리하기 위해 리덕스를 적용하고
2) 부모-자식 관계가 아닌 컴포넌트에서 동일한 상태값을 변경해줄 때
3) 2개 이상의 컴포넌트가 1과 2의 상황에서
4) 2번의 컴포넌트를 생성시킬 때 초기 값이 렌더링되면서 전역 저장소의 상태값을 변경하면서
5) 1번의 컴포넌트를 업데이트 시켜야 하는데
4번과 5번과 같은 2개 컴포넌트를 한꺼번에 렌더링시키는 작업은 리액트에서 작동하지 않기 때문에 발생하고 있다.
그리고 단순히 4번과 5번이 버그가 아니라 1번과 2번 컴포넌트의 관계가 부모-자식도 아니고 데이터의 흐름이 단방향이어야 하는데 전역저장소의 상태값이 변경될 때 데이터의 흐름도 단방향이 아니기 때문에 발생하는 것으로 사료된다.
최초 에러의 상황을 좀 더 자세하게 표현해보도록 하자.
Main과 MenuBar 모두 useSelector로 전역저장소의 상태변수 idx를 참조한다.
그리고 두 개의 컴포넌트 모두 idx가 변할 때마다 dispatch와 리듀서로 전역저장소의 상태변수 idx를 변경시킨다.
문제는 모바일 버전에서 MenuBar 컴포넌트를 생성하면 초기에 렌더링 되면서 전역저장소 idx를 변경시키게 되면서
Main 컴포넌트가 영향을 받게 된다는 뜻이다. 코드로 설명하면 좋으련만 최초의 코드를 복원하면서 오히려 다양한 방법으로
문제를 해결하게 되버려서 구현이 안된다.
다른 사람의 유사한 문제로 대신한다. 여기 클릭
이 사람도 기술했듯이 useSelector가 2개 있어서 데이터가 단방향이어야 하는데 그렇지 못한 부분이 문제가 되는 거 같다.
그래서 나의 경우는 2가지의 방법으로 해결했다.
Betst Practice 1은 가장 간단하게 해결한 것이고 최초 에러 코드 복원 중에 발견한 방식이다.
Main 컴포넌트만 useSelector로 전역 저장소 상태변수 idx를 참조한다.
Main과 MenuBar 모두 dispatch로 상태변수 idx의 변경을 전역으로 보낸다.
상태변수 idx 데이터의 흐름은 단방향이다. Main을 기준으로 모두 전역저장소에서 받는다.
Main --> 전역저장소 --> Main
MenuBar --> 전역저장소 --> Main
코드를 잠시 보도록 하자.
Main.js
export function Main() {
const menuIdx = useSelector((state) => state.global.menuIdx);
// 메인 페이지 우측 사이드 메뉴(컴포넌트 리스트) 핸들러
const disptach = useDispatch();
const handleMenus = (idx) => {
disptach(updateIdx(idx));
};
return (
<MainContainer>
<div className="component-list-side">
<ul>
{dummySrc.menus.map((menu, idx) => {
return (
<li
role="presentation"
id={idx}
key={idx}
onClick={() => {
handleMenus(idx);
initailizeSubContainerBorder();
}}
>
{menu}
</li>
);
})}
</ul>
</div>
<div className="right-current-side">
<RightSideWrap>
<Title>{dummySrc.menus[menuIdx]}</Title>
<Docs>
<BiChevronRightCircle size="1.8rem" />
<Desc>{dummySrc.docs[menuIdx]}</Desc>
</Docs>
<SubContainer
className={`${subContainerBorder ? 'border--changed' : ''}`}
>
<RootComponent
idx={menuIdx}
handleSubContainerBorder={handleSubContainerBorder}
/>
</SubContainer>
</RightSideWrap>
</div>
</MainContainer>
);
}
useSelector를 활용하여 menuIdx참조한다.
menuIdx는 Main 컴포넌트의 콘텐츠를 변경한다.
이벤트 핸들러 handleMenu는 전역저장소 상태변수 idx를 업데이트 한다.
MenuBar.js
export function MenuBar() {
const disptach = useDispatch();
const handleMenuBar = (idx) => {
disptach(updateIdx(idx));
};
return (
<MenuBarContainer>
<ul>
{dummySrc.menus.map((menu, idx) => {
return (
<li
role="presentation"
key={idx}
onClick={() => handleMenuBar(idx)}
>
{menu}
</li>
);
})}
</ul>
</MenuBarContainer>
);
}
이벤트 핸들러 handleMenuBar는 전역저장소 상태변수 idx를 업데이트 한다.
작성하고 다시 복기하니 흔하게 볼 수 있는 구조의 코드이다.
전역저장소에 영향을 주는 (dispatch) 2개의 컴포넌트와 useSelector로 전역저장소를 참조하는 1개의 컴포넌트.
데이터의 흐름은 단방향. 어떤 컴포넌트가 전역저장소의 상태변수에 영향을 받을지를 잘 분석해서 적용하면
유사한 사례에 잘 적용할 수 있을 것으로 생각된다.
쓸쓸한 것은 최초의 에러 코드를 복원하던 중 발견한 Best Practice가 코드가 문제 해결을 위해서 일부러 짠 코드 보다
간결하다는 것이다. 그래도 감사하다.
오늘도 열정, 겸손, 감사!
참고로 다소 복잡한 구조의 코드이다. 리덕스 툴킷의 slice 파일도 함께 있다.
globalSlice.js
import { createSlice } from '@reduxjs/toolkit';
const initialState = {
soruce: '',
menuIdx: 0,
};
const globalSlice = createSlice({
name: 'global',
initialState,
reducers: {
updateIdx(state, action) {
state.menuIdx = action.payload;
},
},
});
export const { updateIdx } = globalSlice.actions;
export default globalSlice.reducer;
Main.js
export function Main() {
const { menuIdx } = useSelector((state) => state.global.menuIdxInfo);
const [subContainerBorder, setSubContainerBorder] = useState(false);
const disptach = useDispatch();
// 메인 페이지 우측 사이드 메뉴(컴포넌트 리스트) 핸들러
const handleMenus = (source, idx) => {
disptach(updateMenuIdx({ source, idx }));
};
// Toggle, Modal 등의 버튼을 누르면 박스 테두리 색상 변경 핸들러
const handleSubContainerBorder = () => {
setSubContainerBorder(!subContainerBorder);
};
// 우측 사이드 메뉴를 변경할 때마다 박스 테두리 색상 회색으로 초기화 핸들러
const initailizeSubContainerBorder = () => {
setSubContainerBorder(false);
};
return (
<MainContainer>
<div className="component-list-side">
<ul>
{dummySrc.menus.map((menu, idx) => {
return (
<li
role="presentation"
id={idx}
key={idx}
onClick={() => {
handleMenus('web', idx);
initailizeSubContainerBorder();
}}
>
{menu}
</li>
);
})}
</ul>
</div>
<div className="right-current-side">
<RightSideWrap>
<Title>{dummySrc.menus[menuIdx]}</Title>
<Docs>
<BiChevronRightCircle size="1.8rem" />
<Desc>{dummySrc.docs[menuIdx]}</Desc>
</Docs>
<SubContainer
className={`${subContainerBorder ? 'border--changed' : ''}`}
>
<RootComponent
idx={menuIdx}
handleSubContainerBorder={handleSubContainerBorder}
/>
</SubContainer>
</RightSideWrap>
</div>
</MainContainer>
);
}
MenuBar.js
export function MenuBar() {
const { source } = useSelector((state) => state.global.menuIdxInfo);
const [isChangeGlobal, setIsChangeGlbal] = useState(source === 'mobile');
const disptach = useDispatch();
// 모바일 메뉴바의 idx변화를 전역저장소에 적용할지 여부를 결정
const handleIsChangeGlobal = () => {
setIsChangeGlbal(true);
};
// 전역저장소에 source와 idx를 전달하여 변경
const handleMenuBar = (source, idx) => {
if (isChangeGlobal) disptach(updateMenuIdx({ source, idx }));
};
return (
<MenuBarContainer>
<ul>
{dummySrc.menus.map((menu, idx) => {
return (
<li
role="presentation"
key={idx}
onClick={() => {
handleIsChangeGlobal();
handleMenuBar('mobile', idx);
}}
>
{menu}
</li>
);
})}
</ul>
</MenuBarContainer>
);
}