React context 성능 챙겨 잘 써보기
저는 전역상태관리 라이브러리를 별로 즐겨 사용하지는 않습니다.
그래서인지 Context를 잘 써보고 싶은 마음이 있었는데요.
리액트 Context 하면 보통 하위 자식 컴포넌트들이 전부 리랜더링 된다는 단점을 꼽습니다.
자식 컴포넌트가 전부 리랜더링 된다는 단점만 없다면.. 모든 상태를 전역에서 관리하는 전역상태 라이브러리보다
훨씬 문맥에 맞게 코딩할 수 있는 Context가 좋을 것 같습니다.
Context사용에 따른 리랜더링을 방지하기 위해 모든 자식 컴포넌트에 React.memo를 사용하자는 말이 아닙니다.
바로 커스텀 프로바이더 훅을 만들어서 리랜더링을 방지하는 것입니다.
제 앱입니다.
<app>
<FirstContainer>
<SecondContainer>
<ThirdContainer>
<LastChildContainer>
<button>Change Theme</button>
</LastChildContainer>
</ThirdContainer>
</SecondContainer>
</FirstContainer>
</app>
간단하게 이런 구조로 생긴 앱입니다.
이제부터 app 컴포넌트에서 상태를 공유하기 위해 context를 만들어서 LastChildContainer에서 상태를 변경하도록 하겠습니다.
일반적인 Context 사용법입니다.
import { createContext, useState } from "react";
import FirstContainer from "./components/FirstContainer";
export const ThemeContext = createContext(null);
function App() {
const [theme, setTheme] = useState("white");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<div className="App">
<FirstContainer />
</div>
</ThemeContext.Provider>
);
}
export default App;
import React, { useContext } from "react";
import { ThemeContext } from "../App";
const LastChildContainer = () => {
console.log("last");
const { setTheme } = useContext(ThemeContext);
const handleChangeTheme = () => {
setTheme((prev) => (prev === "white" ? "dark" : "white"));
};
return (
<div>
LastChildContainer
<button onClick={handleChangeTheme}>changeTheme</button>
</div>
);
};
export default LastChildContainer;
가장 아래쪽에 있는 자식컴포넌트인 LastChildContainer에서 컨텍스트를 사용해서 상태를 변경했습니다.
두 번 버튼을 눌러서 테마를 dark로 한번 바꿨다가 white로 다시 바꿨습니다.
first, second, third, last가 콘솔에 찍혔습니다. 중첩된 모든 자식컴포넌트가 리랜더링 되었습니다.
중첩된 자식컴포넌트가 리랜더링 된 것은 context api 때문이 아닙니다. 그냥 부모의 상태가 변경되어서 자식이 리랜더링 되는
아주 당연한 리액트의 리랜더링 규칙입니다.
import { createContext, useState } from "react";
import FirstContainer from "./components/FirstContainer";
export const ThemeContext = createContext(null);
function App() {
const [theme, setTheme] = useState("white");
const [count, setCount] = useState(0);
console.log(count);
const changeLocalState = () => {
setCount((prev) => prev + 1);
};
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
<div className="App">
<FirstContainer />
</div>
<button onClick={changeLocalState}>app local state change</button>
</ThemeContext.Provider>
);
}
export default App;
위처럼 그냥 useState로 만든 상태만 변경해도 자식들이 리랜더링 되는 것처럼요
그럼 커스텀 프로바이더를 만들어서 수정해 보겠습니다.
// ThemeProvider.js
import { createContext, useContext, useState } from "react";
export const ThemeContext = createContext(null);
export default function ThemeProvider({ children }) {
const [theme, setTheme] = useState("white");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
import FirstContainer from "./components/FirstContainer";
import ThemeProvider from "./hooks/ThemeProvider";
function App() {
return (
<ThemeProvider>
<div className="App">
<FirstContainer />
</div>
</ThemeProvider>
);
}
export default App;
ThemeContext.Provider로 감싸고 자식컴포넌트를 children props로 전달받아서 내부에 랜더링 하는 커스텀 프로바이더를 만들었습니다. 전혀 달라진 게 없어 보이지만 큰 차이가 있습니다.
이번에는 change Theme버튼을 눌러서 테마를 바꿨는데 LastChildContainer만 리랜더링 되고 있습니다.
커스텀 프로바이더로 나누는 게 관심사를 분리하고 코드를 정리하는 것뿐만 아니라 리랜더링에도 영향을 미치고 있습니다.
change Theme 버튼을 누르면 ThemeProvider 컴포넌트가 관리하는 상태가 바뀌면서 리랜더링 됩니다.
하지만 해당 컴포넌트의 상태가 바뀐다고 props가 변경되는 것은 아닙니다.
이번에는 자식컴포넌트를 children props로 받고 있기 때문에 상태가 변경된다고 children이 새로 생성되거나 하진 않습니다.
그래서 상태를 사용하지 않는 Second, ThirdContainer는 리랜더링이 되지 않았습니다.
context의 리랜더링 문제를 해결했네요. 하지만 아직 문제가 조금 있습니다.
import { createContext, useContext, useState } from "react";
export const ThemeContext = createContext(null);
export default function ThemeProvider({ children }) {
const [theme, setTheme] = useState("white");
const [count, setCount] = useState(0);
return (
<ThemeContext.Provider value={{ theme, setTheme, count, setCount }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
말이 안 되긴 하지만 예를 들어 커스텀프로바이더에서 관리하는 상태가 더 많아졌다고 생각할게요.
count라는 상태도 커스텀프로바이더 value로 내려주겠습니다. 그리고 count는 ThirdContainer에서만 사용할게요.
import React from "react";
import LastChildContainer from "./LastChildContainer";
import { useTheme } from "../hooks/ThemeProvider";
const ThirdContainer = () => {
const { count } = useTheme();
console.log("third");
return (
<div>
ThirdContainer
{count}
<LastChildContainer />
</div>
);
};
export default ThirdContainer;
단순히 count만 보여줍니다. setCount는 어디에서도 호출하지 않았어요.
이렇게만 하고 change Theme 버튼을 클릭해 볼게요.
theme 상태만 변경했는데 이번엔 thirdContainer도 리랜더링 되고 있습니다.
이왕 한 거 진짜 변경하는 상태를 사용하는 컴포넌트만 리랜더링 하고 싶습니다.
위의 예에서는 LastChildContainer만 리랜더링이 됐어야 해요.
원인은 커스텀 프로바이더가 제공하는 객체값 때문입니다.
import { createContext, useContext, useState } from "react";
export const ThemeContext = createContext(null);
export default function ThemeProvider({ children }) {
const [theme, setTheme] = useState("white");
const [count, setCount] = useState(0);
return (
<ThemeContext.Provider value={{ theme, setTheme, count, setCount }}>
{children}
</ThemeContext.Provider>
);
}
export const useTheme = () => useContext(ThemeContext);
LastChild가 setTheme를 호출해서 theme를 변경하면 ThemeProvider의 상태가 변경되어 리랜더링 됩니다.
그리고 객체는 참조값이기 때문에 리랜더링이 될 때마다 새로운 객체로 재생성됩니다.
value로 제공하는 객체가 새로운 객체라고 볼 수 있습니다. value에 객체를 통으로 넣어주면
실제로 변경되지 않은 값을 사용하는 컴포넌트까지 모두 리랜더링 되겠습니다.
위 상황을 해결하려면 context를 나눠서 제공하면 됩니다.
import { createContext, useState } from "react";
export const ThemeContext = createContext(null);
export const SetThemeContext = createContext(null);
export const CountContext = createContext(null);
export const SetCountContext = createContext(null);
export default function ThemeProvider({ children }) {
const [theme, setTheme] = useState("white");
const [count, setCount] = useState(0);
return (
<SetCountContext.Provider value={setCount}>
<CountContext.Provider value={count}>
<SetThemeContext.Provider value={setTheme}>
<ThemeContext.Provider value={theme}>
{children}
</ThemeContext.Provider>
</SetThemeContext.Provider>
</CountContext.Provider>
</SetCountContext.Provider>
);
}
이번에는 ThirdContainer에서 여전히 count값을 사용하지만
change Theme 버튼을 눌렀을 때 LastChildContainer만 리랜더링이 되고 있습니다.
예제에서는 value에 객체를 보냈을 때 벌어지는 상황을 재현하기 위해 ThemeProvider에서 말도 안 되는 count를 사용했지만
실제로는 여러 상태를 한 번에 객체로 보내는 일은 잘 없었습니다.
그래도 위 상황을 알아두면 '커스텀 프로바이더를 만들어서 props로 랜더링 했는데 왜 리랜더링이 되지?'라는 생각이 들 때 금방 해결할 수 있습니다!