stackoverflow에 올린 질문글
https://stackoverflow.com/questions/77269867/react-router-v6-navigate-changes-path-but-not-view/77271250#77271250
내가 원했던 동작은 아래와 같다.
/trip/:tripId
)에서 '여행 삭제(onDelete)' 버튼을 누른다/home
)로 돌아간다. (리액트 라우터의 useNavigate
사용)1번과 2번 과정은 잘 구현되었으나, 막상 홈 페이지로 돌아가는 데에서 문제가 생겼다.
여행을 삭제한 후 주소창의 url이 /home
으로 바뀌기까지 하는데 막상 화면이 바뀌지 않고 여행 페이지 그대로인 것이다.
이것을 해결하려고 구글에 'react router navigate only change url' 등의 키워드로 많은 자료를 찾아봤지만 내 상황을 해결하지는 못했다.
삭제 버튼의 클릭이벤트 핸들러인 onDelete 함수를 보자.
const onDelete = async () => {
if (authCtx.state === "loaded" && authCtx.user && tripId) {
await dispatch(deleteTrip({ uid: authCtx.user.uid, id: tripId }));
navigate("/home");
}
};
주소창 url이 변경되는 걸 봐선 삭제 액션 디스패치가 완료된 후 navigate 함수가 호출되는 것까지 문제없이 코드가 진행됐다는 것이다. 그러니까 onDelete 함수는 잘못이 없다.
그래서 삭제 액션을 수행한 다음 리덕스가 관리하는 여행목록을 업데이트하는 extraReducer에 가 보았다.
triplistReducer.ts
// 여행을 삭제하는 비동기 작업을 수행하는 thunk
export const deleteTrip = createAsyncThunk(
"DELETE_TRIP",
async (args: { uid: string; id: string }, thunkAPI) => {
return TripAPI.delete(args.uid, args.id); // DB에서 id가 id인 여행 제거
}
);
// 여행 목록 상태 slice
export const triplistSlice = createSlice({
name: "triplist",
initialState,
reducers: {},
extraReducers: (builder) => {
// ...
builder.addCase(deleteTrip.fulfilled, (state, action) => {
const deleted_id = action.payload;
// 삭제한 id의 여행 데이터를 상태에서 제거
state.state = state.state?.filter((trip) => trip.id !== deleted_id);
});
},
});
나는 혹시나 싶어 위 코드에서 아래 코드를 주석처리한 후 다시 시도해 보았다.
state.state = state.state?.filter((trip) => trip.id !== deleted_id);
그랬더니 onDelete
함수의 navigate(/home)
이 화면을 홈페이지 화면으로 제대로 바꾸었다.
그러니까 리덕스 스토어의 여행목록 상태를 변경하는 작업이 navigate 동작에 문제를 일으킨 모양이다.
다시 주석처리한 것을 해제하고, 삭제 버튼이 렌더링되는 컴포넌트에 console.log를 찍어서 리렌더링 여부를 확인하였다.
그랬더니 삭제 버튼을 누르고 리덕스 상태가 변경될 때 해당 컴포넌트가 리렌더링된다는 것을 깨달았다.
해당 컴포넌트 내에서는 useSelecter()로 리덕스 상태를 구독하지도 않는데 왜 리렌더링되는가?
답은 App.tsx에 있었다. App.tsx에서 여행목록 상태를 구독하고 있었던 것이다.
App.tsx
import ...
function App() {
const authCtx = useAuthState();
const dispatch = useAppDispatch();
const profile = useAppSelector((state) => state.profileReducer);
const triplist = useAppSelector((state) => state.triplistReducer);
// loaders...
// Router
const router = createBrowserRouter(
createRoutesFromElements(
<>
<Route
index
path="/"
element={<RootPage />}
errorElement={<ErrorPage />}
/>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<NotFound />} />
{/* PrivateRoutes를 적용할 Route끼리 모은다 */}
<Route element={<PrivateRoutes />}>
<Route
path="/profile/edit"
element={<ProfileEditPage />}
loader={profileLoader}
/>
<Route
path="/home"
element={<HomePage />}
loader={triplistLoader}
/>
<Route path="/create" element={<TripEditPage />} />
<Route element={<BottomNav />}>
<Route path="/trip/:tripId" element={<MainPage />} />
</Route>
</Route>
{/* Not Found Page */}
</>
),
{ basename: process.env.PUBLIC_URL }
);
return <RouterProvider router={router} />;
}
export default App;
App이 여행목록 상태가 업데이트될때마다 새로 렌더링되기 때문에 router 역시 새로 만들어지는것이 문제였던 것으로 판단된다.
그래서 router를 정의하는 코드에 useMemo()
처리하여 loader 함수들이 재평가되지 않는 이상 router는 새로 변경되지 않게 하였다. 다만 종속성 배열에 아무것도 넣지 않으면 loader가 제대로 작동하지 않는다. 프로필이나 여행목록 로드 status에 대한 업데이트를 감지하지 못하기 때문이다. 대신 로더의 재평가가 여행목록 상태(state)에 의해 이루어져서도 안 된다. 그러면 router도 재평가되면서 동일한 문제가 다시 발생할거니까.
function App() {
const authCtx = useAuthState();
const dispatch = useAppDispatch();
const profile = useAppSelector((state) => state.profileReducer);
const triplist = useAppSelector((state) => state.triplistReducer);
// 프로필 정보 로더
const profileLoader = useCallback(async () => {
// 로그인 상태이고 프로필 정보가 확인되지 않은 경우 디스패치
if (authCtx.user && profile.status !== "loaded") {
await dispatch(getProfileInfo(authCtx.user.uid));
return null;
}
return null;
}, [authCtx.user, profile.status]);
// 여행일정목록 로더
const triplistLoader = useCallback(async () => {
// 로그인 상태이고 프로필 정보가 확인되지 않은 경우 디스패치
if (authCtx.user && triplist.status !== "loaded") {
await dispatch(getTriplist(authCtx.user.uid));
return null;
}
return null;
}, [authCtx.user, triplist.status]);
// Router
const router = useMemo(() => {
return createBrowserRouter(
createRoutesFromElements(
<>
<Route
index
path="/"
element={<RootPage />}
errorElement={<ErrorPage />}
/>
<Route path="/login" element={<LoginPage />} />
<Route path="*" element={<NotFound />} />
{/* PrivateRoutes를 적용할 Route끼리 모은다 */}
<Route element={<PrivateRoutes />}>
<Route
path="/profile/edit"
element={<ProfileEditPage />}
loader={profileLoader}
/>
<Route
path="/home"
element={<HomePage />}
loader={triplistLoader}
/>
<Route path="/create" element={<TripEditPage />} />
<Route element={<BottomNav />}>
<Route path="/trip/:tripId" element={<MainPage />} />
</Route>
</Route>
{/* Not Found Page */}
</>
),
{ basename: process.env.PUBLIC_URL }
);
}, [profileLoader, triplistLoader]);
return <RouterProvider router={router} />;
}
export default App;
그랬더니 내가 처음에 원했던대로 동작하였다!
삭제 버튼을 누르면 DB와 리덕스 스토어에서 여행이 삭제되고 홈페이지로 돌아가서 (내가 삭제한 여행은 제거된) 여행 목록을 표시하는 것을 확인하였다.
이 문제를 해결하느라 하루종일을 헤맸다. 원인이 예상하지 못했던 router의 최적화에 있었다는것도 놀랍다..앞으로 최적화할만한건 미리미리 해가면서 프로젝트를 진행해야겠다.
로더 메서드들을 App 밖에 정의하는게 사실 베스트일텐데, 내부적으로 dispatch를 사용하기 때문에 리액트 컴포넌트 밖에서 정의할 수 없다. 만약 로더를 사용할 페이지 컴포넌트 내에 정의하더라도 이렇게 하면 export하여 App에서 사용할 수가 없었다.. react router loader에서는 주로 axios 요청을 통해 데이터를 fetch하는데 redux와 함께 사용하려니 이런 문제가 생겼다. 좀더 찾아보고 고민해봐야겠다