React의 props.children의 re-render 동작과 원리

React의 props.children의 re-render 동작과 원리

Tags
React.js
Published
January 12, 2025
Author
Seongbin Kim
작성 중
 

1. props.children가 re-render되는 시점, 이유

 

1-1. props.children을 렌더링하는 컴포넌트 사례

 

Context Provider를 래핑한 컴포넌트

  • Context Provider는 여러 목적으로 많이 사용하실 것 같습니다.
    • Theme
    • IsMobile
    • 기타 여러가지 값들
    • 외부 라이브러리
      • React-query
      • Redux
      • Zustand
  • 작성한 코드에 따라 다르겠지만, 보통 아래와 같이 작성하는 경우가 많을 것 같습니다.
    • export const CounterContextProvider = ({ children }: PropsWithChildren) => { const [count, setCount] = useState(0); return ( <CounterContext.Provider value={{ count, setCount }}> {children} </CounterContext.Provider> ); };
  • 위 코드에서 count 상태가 바뀌면 children은 re-render 될까요?
    • 정답은 “아니다”인데요,
    • 왜 부모가 re-render 되어도 자식이 전부 re-render 되지 않는지 확인해보았습니다.
 

2. props.children의 re-render 동작 검증

 

2-1. 동작 확인 결과

  • props.children은 그 컴포넌트 트리를 정의한 컴포넌트가 re-render될 때만 re-render 됩니다.
  • props.children을 반환하는 컴포넌트가 re-render 될 때 re-render되지 않습니다.
 

2-2. 재현 코드 (Context Provider)

  • Provider 정의 및 사용 코드
    • export const ProviderUsingUseState = ({ children }: PropsWithChildren) => { const [count, setCount] = useState(0); return ( <SampleContext.Provider value={{ count, setCount }}> <div> <h1>Provider</h1> {children} </div> </SampleContext.Provider> ); };
  • Provider를 사용하는 코드
    • NestedChild, ConsumerChild도 모두 props.children을 사용합니다.
    • export const NestedChild = ({ children }: PropsWithChildren) => { return ( <div> <h3>NestedChild</h3> {children} </div> ); }; export const ConsumerChild = ({ children }: PropsWithChildren) => { const { count, setCount } = useSample(); return ( <div> <h3>ConsumerChild</h3> <p>count: {count}</p> <button onClick={() => setCount(count + 1)}>increment!</button> {children} </div> ); }; // App에서 정의한 컴포넌트 트리를 Provider에 children으로 전달 // App 자체는 re-render가 없는 상황 function App() { return ( <ProviderUsingUseState> <NestedChild> <NestedChild> <ConsumerChild> <NestedChild /> </ConsumerChild> </NestedChild> </NestedChild> </ProviderUsingUseState> ); }
 

2-3. React가 컴포넌트를 re-render하는 시점 3가지

  • React의 기본 re-render 시점은 아래와 같습니다.
      1. 본인의 state가 변경될 때
      1. 본인의 prop이 변경될 때
      1. 부모가 re-render될 때
 

2-4. props.children의 re-render 실제 결과

  • 예상 결과: (3)에 해당해 모든 자식들이 re-render
  • 실제 결과: Consumer를 제외한 어떤 children 컴포넌트도 re-render 되지 않음
    • React DevTools로 확인한 결과, re-render flash가 표시되지 않았습니다.
      • 좌: children으로 전달한 경우
      • 우: 직접 반환한 경우
  • 직접 반환하는 코드입니다
    • 기존 코드는 App에서 children을 전달했고,
    • 이 코드는 Provider에서 직접 반환합니다.
    • export const ProviderUsingUseState = () => { const [count, setCount] = useState(0); // children을 입력 받는 대신, 직접 컴포넌트 트리를 반환 return ( <SampleContext.Provider value={{ count, setCount }}> <div> <h1>Provider</h1> <NestedChild> <NestedChild> <ConsumerChild> {/* 중략 */} </ConsumerChild> </NestedChild> </NestedChild> </div> </SampleContext.Provider> ); };
 

2-5. props.children의 re-render 조건 정리

 

re-render가 안 되는 경우

  • props.children을 렌더링하는 컴포넌트에 의해서는 re-render가 트리거 되지 않습니다.
  • 외부에서 입력 받은 children은 re-render되지 않습니다.
 

re-render가 되는 경우

  • props.children을 사용하더라도, 직접 렌더링한 것을 전달하는 경우에는 소용이 없습니다.
    • function App() { return ( <ProviderUsingUseState> <NestedChild /> </ProviderUsingUseState> ); }
    • 따라서 위 코드에서 App의 state가 변경된다면, Provider는 children으로 렌더링하더라도 NestedChild는 re-render 됩니다.
 

결론

  • props.children은 외부에서 주입받는 컴포넌트로, props.children을 렌더링하는 컴포넌트가 re-render 될 때 re-render되지 않습니다.
  • props.children을 넘기는 쪽에서 re-render가 발생하면, props.children을 반환하는 컴포넌트가 re-render되지 않아도 re-render가 됩니다.
 

3. props.children가 re-render 되지 않는 원리

 
 
💡
re-render가 생략되는 이유에 대해 설명하려면 우선 ReactElement에 대해 먼저 알아야 합니다. 이미 알고 계신 경우 다음 단락으로 넘어가시면 됩니다.
 

3-1. React Element란?

  • React에서 <컴포넌트 />로 작성하는 문법(JSX Markup)이 표현하는 값, 파싱된 값입니다.
    • 이런 JS/TS 비표준 커스텀 문법을 JSX라는 문법 설탕으로 가능케 하는데요,
    • 이 표현식(?)을 babel로 transpile하면 함수 호출로 변환됩니다.
      • // Before transpile const component = <Component />; // After transpile const component = React.createElement(Component, null, null);
  • React Element는 (컴포넌트 함수(생성자), props 객체) 쌍으로, 다음과 같은 형태의 객체입니다.
    • interface ReactElement< P = any, T extends string | JSXElementConstructor<any> = string | JSXElementConstructor<any>, > { type: T; props: P; key: string | null; }
  • 이 객체의 핵심은 type 프로퍼티입니다. type은 3가지 종류의 값을 받습니다.
    • DOM Element (이름 - string으로 지정 - ex: ‘div’)
    • Function Component (함수)
    • Class Component (class(생성자))
    • 이 타입이 바로 JSXElementConstructor의 내용입니다.
      • type JSXElementConstructor<P> = | (( props: P, deprecatedLegacyContext?: any, ) => ReactNode) | (new( props: P, deprecatedLegacyContext?: any, ) => Component<any, any>);
  • React는 ReactElement를 일종의 레시피로 취급하고, 이전 렌더링과 신규 렌더링 간의 ReactElement를 비교해 re-render 여부에 반영하며, 이런 re-render 과정 전체에 필요한 알고리즘과 자료구조를 Virtual DOM이라고 합니다.
 

3-2. ReactElement가 같은 인스턴스라면 re-render를 생략

  • 공식문서 발췌
    • “개별 JSX 노드 메모화” 부분
  • ReactElement가 같은 인스턴스라면 re-render를 생략합니다.
    • <List items={visibleTodos} />와 같은 JSX 노드는 { type: List, props: { items: visibleTodos } }와 같은 객체입니다. 이 객체를 생성하는 것은 매우 저렴하지만, React는 그 내용이 지난번과 동일한지 알 수 없습니다. 그래서 기본적으로 React는 List 컴포넌트를 다시 렌더링합니다.
      하지만 React가 이전 렌더링과 동일한 JSX를 발견하면 컴포넌트를 다시 렌더링하려고 시도하지 않습니다. JSX 노드는 불변하기 때문입니다. JSX 노드 객체는 시간이 지나도 변경되지 않으므로 React는 재렌더링을 생략해도 안전하다는 것을 알고 있습니다. 그러나 이것이 동작하려면 노드가 단순히 코드적으로 동일해 보이는 것이 아닌 실제로 동일한 객체여야 합니다.
  • 즉, React는 createElement 호출로 생성되는 ReactElement 객체를 shallowEqual로 확인하고, 만약 기존과 동일하다면 렌더링을 생략합니다.
 

3-3. 예시 사례에 대한 해석

  • props.children은 외부에서 정의한 ReactElement 입니다.
  • 해당 ReactElement가 신규 인스턴스로 재생성되려면 해당 컴포넌트 트리를 반환한 컴포넌트가 re-render를 해서 신규 ReactElement를 생성해야 합니다.
    • (ex) 아래의 코드에서 App 컴포넌트는 첫 렌더링 시 <NestedChild> ~ </NestedChild> 만큼의 createElement 호출의 결과물을 <ProviderUsingUseState />의 children prop으로 전달합니다.
      • 이후 <ProviderUsingUseState />가 스스로 re-render할 때, 기존에 받았던 props.children은 재생성 없이 그대로 사용하게 됩니다. <App>이 re-render하지 않았기 때문입니다.
      • React는 반환된 ReactElement의 레퍼런스를 비교해 동일하므로 비교를 거기서 멈추고, re-render를 수행하지 않습니다.
        • function App() { return ( <ProviderUsingUseState> {/* children은 App에서 ReactElement를 신규 생성하지 않는 한(App이 re-render하지 않는 한) re-render되지 않음 */} <NestedChild> <NestedChild> <ConsumerChild> <NestedChild /> </ConsumerChild> </NestedChild> </NestedChild> </ProviderUsingUseState> ); }
 
 

4. ReactElement의 캐싱

 

4-1. ReactElement 캐싱 활용

 

사례: React.memo

const MemoizedComponent = memo(SomeComponent, arePropsEqual?)
  • React.memo는 re-render 여부를 (prevProps, nextProps) => boolean 함수의 결과값에 따라 결정합니다.
  • 만약 false인 경우, React는 해당 컴포넌트를 re-render하지 않습니다.
 

React 컴포넌트를 memoize하는 방법

  • React 컴포넌트를 memoize하려면 기존에 만들어 둔 ReactElement를 재사용해야 합니다.
  • 즉, 아래와 같은 코드는 매번 새로운 ReactElement를 생성하기 때문에, Function 컴포넌트의 경우 아예 실행을 하지 않고 반환 값을 memoize하거나, 컴포넌트를 실행은 하되, 반환하는 ReactElement를 memoize해야 합니다.
 

코드 비교

  • 매번 재생성하는 경우
    • // before transpile const Component = () => { return ( <ComponentA> hello world </ComponentA> ); } // after transpile const Component = () => { // 매번 신규 객체를 생성 return React.createElement(ComponentA, null, "hello world"); };
  • memoize하는 경우
    • 편의 상 useMemo를 사용했지만 임의의 외부 상태에 보관할 수도 있을 것입니다.
    • // before transpile const Component = () => { const memoizedReactElement = useMemo( () => <ComponentA>hello world</ComponentA>, [] ); return memoizedReactElement; } // after transpile const Component = () => { // 최초 1회 생성 후 매번 동일한 객체를 반환 const memoizedReactElement = useMemo( () => React.createElement(ComponentA, null, "hello world"), [] ); return memoizedReactElement; };
 

실제 예시

  • 시연 영상:
  • 코드:
    • const App = () => { const [count, setCount] = useState(0); return ( <div> <MemoizedComponentParent /> <span>count: {count}</span> <button onClick={() => setCount((prev) => prev + 1)}>Increment</button> </div> ); }; export const MemoizedComponentParent = () => { const memoizedReactElement = useMemo(() => <ComponentA />, []); return ( <div> <h2>MemoizedComponent Parent</h2> {memoizedReactElement} </div> ); }; export const ComponentA = () => { return <div>ComponentA</div>; };