최신 버전 리엑트는 보통 함수형으로 컴포넌트를 작성한다.
그리고 컴포넌트를 호출할 때는 JSX형식(<Component1/>처럼 대괄호를 붙이는 방식)으로 호출한다.
그렇다면 JSX형식이 아니라 일반적인 함수처럼 Component1()로 호출하면 안될까?
공식문서에서는 컴포넌트 함수의 일반적인 호출(직접 호출)을 금기시합니다.
문서에 따르면 직접 호출이 반드시 에러를 일으키는 것은 아니지만 컴포넌트 사이클에 필요한 몇 가지 사이드 이펙트가 발생하지 않는다. 그래서 당장에 에러가 안난다고 직접호출을 하면 예상했던대로 작동하지 않을 수 있다.
React는 Hook을 사용해 컴포넌트에 지역 State와 같은 기능을 추가합니다.
리액트의 hook은 함수형 컴포넌트의 최상단에서 호출되어야 한다.
그리고 리엑트는 JSX로 호출된 컴포넌트만을 함수형 컴포넌트로 인식한다. 반면 직접 호출한 컴포넌트는 컴포넌트로 인식되지 못한다.
그러면 아래의 경우에 문제가 생길 수 있다.
import React, { useEffect } from "react";
export default function Child1() {
useEffect(() => {
console.log('마운트 Child1')
}, []);
return <div>Child1</div>;
}
export default function App() {
const [show, setShow] = useState(true);
return (
<div>
<button onClick={() => setShow((prev) => !prev)}>
show:{String(show)}
</button>
// Child를 조건부로 렌더링
{!show ? null : Child1()}
</div>
);
}
코드의 작동 구조
위 코드는 show라는 상태로 Child1 컴포넌트의 마운트를 분기처리한다. 위처럼 삼항 연산자를 이용해 컴포넌트 렌더링을 분기처리하는 패턴은 아주 흔하다. 하지만, 직접 호출을 하면 에러가 발생한다.
Rendered fewer hooks than expected. This may be caused by an accidental early return statement.
위 에러는 통상적으로 hook을 함수형 컴포넌트의 최상단에서 호출하지 않고 동적으로 호출할 때 발생한다.
하지만, 위 코드를 보면 그런 부분이 보이지 않는다. 어떻게 된 걸까?
공식 문서에 따르면 JSX호출은 컴포넌트가 vdom 트리 내에서 지역 state와 hook을 가지는 고유한 노드로 인식되도록 렌더링 사이클에 추가하는 작업을 한다. 하지만, 직접호출을 하면 이런 과정이 생략되며 마치 Child 컴포넌트의 코드를 그대로 호출 위치에게 복사하는 듯이 작동한다. 따라서 삼함 연산자 내에 Child 컴포넌트의 hook이 호출된 것처럼 되어서 위 에러가 발생하는 것이다.
JSX 방식은 엄밀히 말해서 함수를 호출한 것이 아닙니다.
앞서 말했듯, 직접 호출이나 JSX방식이나 화면상으로 동작하는 것은 동일하게 dom을 렌더링하는 것처럼 보인다. 그렇다면 console로 함수형 컴포넌트를 직접호출하는 것과 JSX방식을 출력해보면 동일할까?
정답은 그렇지 않다.
아래 코드는 함수형 컴포넌트 Child1의 두 방식을 각각 콘솔로 출력한다.
import React, { useEffect } from "react";
export default function Child1() {
return <div>Child1</div>;
}
export default function App() {
// Child1 컴포넌트의 직접 호출과 JSX방식을 출력 및 비교
console.log("직접호출", Child1());
console.log("호출하지 않은 함수");
console.dir(Child1);
console.log("JSX", <Child1 />);
return (
// 렌더링
);
}
출력 결과
여기서 알 수 있는 사실은 다음과 같다.
- JSX로 호출한 함수는 아직 호출되지 않은 Child1 함수를 자신의 type 키에 저장한다.(이 키는 div같은 기본 jsx 타입이나 Child1같은 함수형 JSX를 값으로 저장한다)
- 직접 호출은 개발자가 호출한 시점에 함수가 호출된다. 하지만, JSX는 개발자가 호출 시점을 컨트롤하지 않고 React가 컨트롤한다.
공식 문서에 따르면 , 리엑트는 JSX로 된 컴포넌트를 적절한 시점에 호출하여 재조정 과정(reconciliation)을 최적화한다. 이 과정에서 컴포넌트의 렌더링 사이클을 조정할 수 있는 셈이다. 결과적으로는 화면 상에 두 방식 다 호출되어 나타나겠지만, 동작 과정은 호출 시점이 다르고, hook의 처리방식도 다른 셈이다.
(위 코드를 빌드해보면 Child를 호출하는 방식도 약간 다르다. 직접 호출은 Child()와 같은 식이지만, JSX방식은 (0, Child.jsx)(n, {}) 와 같이 작성된다.)
모든 경우에 JSX방식을 써야 하는 건 아닙니다. FOC 패턴에서는 직접 호출을 해야합니다.
foc 패턴이란 아래처럼 컴포넌트의 children을 함수로서 동적으로 호출하는 패턴이다.
import React, { useEffect, useState } from "react";
// foc 패턴에서 동적으로 로드되는 자식 컴포넌트
function Child1({ data,setData}) {
return (
<div>
Child1 {data}
</div>
);
}
// foc 패턴에서 자식을 호출하는 부모 컴포넌트
function Parent({children}) {
const [data,setData] = useState(1);
return {children(data,setData)} ;
}
export default function App() {
return (
<Parent>{(data,setData)=><Child1/>}
);
}
일반적인 props 자식 패턴은 마크업 배치를 분기처리하기 어렵고(마크업 특성 상, 삼항연산자나 iife로 모든 경우의 수를 명시하는데 코드 가독성이 안좋음) props drilling 문제를 일으킬 수 있는데, foc를 사용하면 이런 문제를 해소할 수 있다. (특히 전자의 문제를 해결하기 위해 컴포넌트를 비즈니스 로직 컴포넌트와 ui 컴포넌트로 분리하는 패턴을 headless ui 패턴이라고 한다. 이 패턴에는 foc 외에도 합성 컴포넌트, hook 방식이 있다)
아래 코드는 헤드리스 디자인을 위해 foc를 적용한 예시다. 컴포넌트 HeadLessProductData는 children을 직접 호출 형식으로 부르고 있다. 이 때 만약 JSX 형식으로 호출하면 어떻게 될까.
import React, { useEffect, useState } from "react";
// 상품 데이터 요청
// 인터페이스를 제외한 비즈니스 로직 담당
function HeadLessProductData({ children }) {
const [data, setData] = useState();
const [loading, setLoading] = useState(false);
const [error, setError] = useState();
useEffect(() => {
setLoading(true);
fetch("데이터 요청 url")
.then((res) => setData(res))
.catch((rej) => setError(true))
.finally(() => setLoading(false));
}, []);
return <div>{children({ data, loading, error })}</div>;
}
// 모달 ui 표출
// 데이터 시각화
function ModalUI1({ data, loading, error }) {
useEffect(() => {
console.log(" useEffect 실행");
return () => console.log("클린업");
}, []);
return (
<div>
{loading
? "로딩중입니다"
: error
? "에러가 발생했습니다"
: (data && data.toString()) || null}
</div>
);
}
function UserModal(userId, UiType, theme) {
return (
<div>
<HeadLessProductData>
{({ data, loading, error }) => (
<ModalUI1
data={data}
loading={loading}
error={error}
userId={userId}
UiType={UiType}
theme={theme}
/>
)}
</HeadLessProductData>
</div>
);
}
export default function App() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount((prev) => prev + 1)}>click</button>
<UserModal />
</div>
);
}
동작 방식을 설명하자면
- App 컴포넌트에서 button을 눌러서 count 상태를 변경시키면 App이 리렌더링되며 UserModal => HeadLessProductData => ModalUI1(Foc이다) 순으로 재호출될 것이다.
- 이 때, ModalUI1 내에 useEffect는 의존성 배열에 아무 값도 없으므로 실행되지 않을 것이고 cleanup 함수도 마찬가지로 실행되지 않을 것이다.
여기까지는 예상대로 문제 없이 작동한다. 그렇다면, HeadLessProductData에서 children을 직접 호출이 아니라 JSX로 호출하면 어떻게 될까. 아래처럼 코드를 변경해보았다.
HeadLessProductData({ children }) {
// 생략
const Children = children;
// 생략
return (
<div>
// JSX로 children 호출
<Children data={data} loading={loading} error={error} />
</div>
);
}
그러자, App의 count 상태를 변경하니 ModalUI1의 useEffect가 계속 실행되었다(다시 말하지만 의존성 배열이 비어있다) 또한, 클린업 함수 역시 계속 실행되었다!
왜 이런 일이 발생했을까.
JSX로 호출한 함수는 컴포넌트로 인식된다. 그러면 다음 같이 동작하게 된다.
- 첫번째 렌더링 시, ()=> <ModalUI1/> 라고 작성된 화살표 함수가 선언되고 컴포넌트로 등록되어 리엑트 렌더링 사이클에 편입된다.
- App 컴포넌트의 버튼을 눌러서 count를 변경한다. 두 번째 렌더링이 시작된다.
- 두번째 렌더링 시, ()=> <ModalUI1/> 라고 작성된 화살표 함수가 새로 선언된다. 리엑트는 디핑 과정(재조정 알고리즘 내에서 재호출된 컴포넌트의 변화점을 확인하는 작업, 얕은 비교를 통해 진행됨)에서 이 화살표 함수가 이전의 화살표 함수와 다르다는 것을 알게된다.
- 이전 화살표 함수의 언마운트 작업이 일어난다. 이로인해 ModalUI1의 클린업 함수가 실행된다.
- 새 화살표 함수의 마운트 작업이 일어난다. 이로인해 ModalUI1의 useEffect가 다시 실행된다.
이러한 동작은 일반적으로 의도와 어긋난다. 따라서, FOC 패턴에서는 chidren을 직접 호출해야 한다.
'react' 카테고리의 다른 글
[react] SPA에서 라우팅 1: 기본 전략 (1) | 2025.03.04 |
---|---|
[React] 드래그 앤 드롭 처음부터 공부, UI 만들기, 관련 라이브러리 추천 (0) | 2025.02.26 |
[trouble-shooting] 제어 컴포넌트를 react-hook-form과 통합하기 (0) | 2025.01.08 |
[trouble-shooting]ref를 사용하는 컴포넌트에 라이브러리의 ref를 결합하기[useImperativeHandle] (0) | 2024.12.31 |
[React] CSR에서 SSR로의 전환, react 18은 어떤 SSR을 하는가. (1) | 2024.12.12 |