context api의 렌더링 관련 내용을 찾아보던 중 렌더링 이슈가 있다는 내용을 접하게 되었다 렌더링 이슈가 무엇이고, 왜 생기는지에 대해 정리해 본다
리액트의 state 공유 api로 기존의 props drilling 문제를 해결하기 위해 제공된다.
Context api의 hook인 useContext는 함수형 컴포넌트의 리렌더링을 유발하기에 안티패턴으로 사용할 경우 의미없는 리렌더링이 일어날 수 있다.
Context api를 사용하기 위해 필요한 단계는 다음과 같다.
//1. context 생성
const MyContext = createContext(initialState)
//2. 공유할 변수를 provider에 전달
<MyContext.Provider value={initialState}>{children}</MyContext.Provider>
//3. 공유된 변수를 불러와 사용
const state = useContext(MyContext)
Context api에 어떤 문제가 있길래 렌더링 이슈가 존재하는 것일까?
렌더링 이슈에 대해서 소개하는 글들의 코드를 토대로 테스트 코드를 작성해보면 다음과 같은 모습이다.
import {createContext, useState} from "react"
interface CountContextProps {
count: number;
setCount: Dispatch<SetStateAction<number>>;
}
const CountContext = createContext<CountContextProps | undefined>(undefined);
const App = () => {
const [count, setCount] = useState(0);
return (
<CountContext.Provider value={{count,setCount}}>
<CountText />
<SayHi />
</CountContext.Provider>
)
}
const CountText = () => {
const context = useContext(CountContext);
if (!context) return <h1>count is undefiend</h1>;
console.log("count text component " + context.count);
return (
<div>
<h1>{context.count}</h1>
</div>
);
};
const SayHiText = () => {
console.log("hi component");
return <h1>Hi!</h1>;
};
그리고 button 까지 추가하면 테스트 준비는 끝난다.
//...
const IncreaseBtn = () => {
const context = useContext(CountContext);
return (
<>
{context ? (
<button onClick={() => context.setCount((prev: number) => prev + 1)}>
+
</button>
) : (
<button disabled>+</button>
)}
</>
);
};
콘솔에 나온 결과는 다음과 같다.
위와 같이 count와 전혀 상관없는 SayHiText도 리렌더링이 일어나는 상황을 볼 수 있다.
사실 위에서 본 예시 코드가 context api의 안티패턴이라 볼 수 있다.
위의 코드가 안티패턴인 이유는 리액트의 함수형 컴포넌트의 업데이트(리렌더링) 조건에 대해 고려하지 않았기 때문이다.
리액트에서 컴포넌트(함수형 기준)가 업데이트 되는 조건은 크게 4가지 정도이다.
useState
useContext
우리가 context api를 사용하는 이유는 props drilling 해결과 함께 props drilling으로 인한 중간 컴포넌트들의 이유없는 렌더링을 줄이기 위해서이다.
위의 예시 코드를 토대로 props 업데이트는 방지했기에 끝이라고 생각할 수 있지만 4번의 부모 컴포넌트 업데이트를 막지는 못했다.
IncreaseBnt을 클릭해보자
IncreaseBtn 클릭
count update
count state가 존재하는 App 컴포넌트가 리렌더링
App 컴포넌트의 자식 컴포넌트들 CountText, SayHiText, IncreaseBnt 리렌더링
공유되는 state가 App 컴포넌트에 존재하기 때문에 state가 변경되면 모든 자식 컴포넌트가 리렌더링 되는 것이다.
우리가 원하는 최소한의 결과를 얻기 위해서는 공식문서에서 제공하는 예시 코드처럼 ContextProvider를 자체적으로 state를 가지는 래퍼 컴포넌트로 생성해 사용하는것이 좋다.
//...
const CountContextProvier = ({childrem}: PropsWithChildren) => {
const [count, setCount] = useState(0)
return (
<CountContext.Provider valu={{count, setCount}}>
{children}
</CountContext.Provider>
)
}
const App = () => {
reutrn (
<CountContextProvier>
<IncreaseBtn />
<CountText />
<SayHiText />
</CountContextProvier>
)
}
이렇게 분리해 사용하면 다음과 같이 context에 접근하는 컴포넌트만 리렌더링 되도록 할 수 있다.
명시적으로 종속된 컴포넌트는 리렌더링 시에 자식 컴포넌트까지 리렌더링 시키지만 props.children을 사용하는 래퍼 컴포넌트는 호출부에서 자식으로 전달된 컴포넌트를 리렌더링 시키지 않는다.
(테스트를 통해서 래퍼 컴포넌트가 자식으로 전달된 컴포넌트를 리렌더링 시키지 않는다는 사실은 확인 했으나 아직 정확히 왜 리렌더링을 안시키는지는 몰라서 추후에 알게되면 추가할 예정이다.)
// case 1
const ParentsComponent = () => {
console.log('Parents')
return (
<div>
<h1>Parents</h1>
<ChildComponent />
<div>
)
}
const ChildComponent = () => {
console.log('Child');
return (
<h2>Child</h2>
)
}
// console
// 'Parents'
// 'Child'
// case2
const WrapperComponent = ({child}: PropsWithChildren) => {
console.log('wrapper');
return (
<div>
<h1>Wrapper</h1>
<ChildComponent />
<div>
)
}
// console
// 'Wrapper'
여기까지만 해도 충분할 수 있지만 button 컴포넌트에 console을 찍어보면 조금 거슬리는 사실을 알게된다.
(decrease 버튼까지 추가한 후 테스트 결과)
count state의 값을 직접적으로 사용하지 않는 버튼 컴포넌트도 리렌더링 되는 것을 알 수 있으며, decrease 버튼만 클릭했지만 increase 버튼도 리렌더링 되는 것을 확인할 수 있다.
이렇게 최신화 되지 않는 setState 함수의 리렌더링까지 줄이고 싶다면 Context를 2개 만들어 사용하는 방법이 있다.
const CountContext = createContext(0); // count state만 전달할 Context
const DispatchContext = createContext<
Dispatch<SetStateAction<number>> | undefined
>(undefined); // setCount 함수만 전달할 Context
const CountContextProvider = ({ children }: PropsWithChildren) => {
const [count, setCount] = useState(0);
return (
<CountContext.Provider value={count}>
<DispatchContext.Provider value={setCount}>
{children}
</DispatchContext.Provider>
</CountContext.Provider>
);
};
컴포넌트는 useContext에 전달된 context 객체의 값이 바뀌는 경우 리렌더링이 일어나기 때문에 변하지 않을 setCount 함수를 다른 context 객체에 전달하면 count 값이 바뀔때 CountContext 객체의 값을 사용하는 CountText 컴포넌트만 리렌더링 된다.
여기까지 코드를 작성하고 보면 CountText, IncreaseBtn, DecreaseBtn 컴포넌트들이 CountContextProvider에 종속되고 있어서, 다른 곳에 활용되기 힘들다는 것을 알게 된다. 그렇다면 굳이 다르게 관리할 필요 없이 이점은 가지고 가면서 붙여놓을 수는 없을까?
라는 고려를 하게 된다면 Compound pattern을 사용할 수 있다.
달라지는 것 없이 Wrapper 컴포넌트에 할당하기만 하면 된다.
//...
CountContextProvider.CountText = CountText;
CountContextProvider.IncreaseBtn = IncreaseBtn;
CountContextProvider.DecreaseBtn = DecreaseBtn;
//App.tsx
const App = () => {
//...
return (
<CountContextProvider>
<CountContextProvider.IncreaseBtn />
<CountContextProvider.DecreaseBtn />
<CountContextProvider.CountText />
<SayHiText />
</CountContextProvider>
);
}
다만 단점도 명확한 디자인 패턴이라 남용하지 않는 것이 좋다.
여러 페이지에서 사용되지만 내부 레이아웃의 순서나 위치가 달라야 하는 경우 유용할 수 있다.
Reference
react-dev