[react] 함수형 컴포넌트 내에 다른 함수형 컴포넌트를 선언할 때 생길 수 있는 문제(trouble-shooting)
AS-IS
현재상황
리엑트 프로젝트에서 아래 코드처럼 함수형 컴포넌트 내에 다른 함수형 컴포넌트를 선언하였다.
import { useEffect, useRef, useState } from "react";
export default function Test({ option }) {
const [value, setValue] = useState();
const InnerTest = function ({ value, onChangeCallback }) {
return (
<div>
<input type="text" value={value} onChange={onChangeCallback} />
</div>
);
};
return (
<div>
<p>test</p>
<InnerTest
value={value}
onChangeCallback={(e) => {
setValue(e.target.value);
}}
/>
</div>
);
}
문제
내부 컴포넌트 InnerTest의 input을 입력하면 한글자 입력마다 focus가 풀려버린다.
원인 분석
함수형 컴포넌트 내부에서 선언된 다른 컴포넌트는 부모가 리렌더링될 때 함수도 새롭게 선언된다.
따라서, InnerTest 함수는 매번 새롭게 만들어지므로 리엑트에서는 < InnerTest />가 매번 동일한 자리에 있음에도 이전의 < InnerTest /> 와 다른 컴포넌트라 판단하여 돔 자체를 교체해버린다. 새로운 dom이 자리잡으니, 그 안에 있던 input의 focus도 풀려버렸던 것이다.
TO-BE
대안
부모 함수형 컴포넌트가 재호출될 때 내부의 InnerTest 함수도 재선언된다는 점이 문제이므로 InnerTest 함수를 바깥으로 빼내면 해결된다.
이러면 더 이상 함수 컴포넌트가 재선언되지 않으므로 재호출되어도 동일한 컴포넌트로 인식된다.
import { useEffect, useRef, useState } from "react";
const InnerTest = function ({ value, onChangeCallback }) {
return (
<div>
<input type="text" value={value} onChange={onChangeCallback} />
</div>
);
};
export default function Test({ option }) {
const [value, setValue] = useState();
return (
<div>
<p>test</p>
<InnerTest
value={value}
onChangeCallback={(e) => {
setValue(e.target.value);
}}
/>
</div>
);
}
분석
그렇다면, 위 방법을 쓰지 않고 다른 방법은 없을까?
한 가지 가정은 key를 사용하는 것이다.
key는 서로 다른 두 jsx 요소가 동일한 컴포넌트임을 명시할 수 있게 해준다.
예컨대, 아래 코드에서 두 input은 key를 동일하게 2로 명시함으로써 어느 것이 렌더링되든 동일한 input으로 실제 돔에 올라간다.
import { useEffect, useRef, useState } from "react";
export default function Test({ option }) {
const [value, setValue] = useState();
const [trigger, setTrigger] = useState(false);
const handleChange = (e) => {
setValue(e.target.value);
};
// 1초마다 input의 렌더링을 변경
useEffect(() => {
setInterval(() => {
setTrigger((prev) => !prev);
}, 1000);
}, []);
return (
<div>
<p>real dom</p>
<p>{trigger.toString()}</>
<p>real dom</p>
{/* 다른 input처럼 보이지만 실제로는 동일한 input으로 간주되어 focus가 풀리지 않음 */}
{trigger ? (
<input key={2} value={value} onChangeCallback={handleChange} />
) : (
<input key={2} value={value} onChangeCallback={handleChange} />
)}
</div>
);
}
그렇다면 내부 함수형 컴포넌트인 InnerTest에서도 가능하지 않을까?
하지만 그렇지 않다.
아래처럼 동일한 key를 주어도 여전히 문제는 그대로다.
추측상 원인은, React는 같은 부모 컴포넌트 내에서 동일한 key 값을 가지는 여러 자식 컴포넌트를 허용하지 않는 것 같다.
import { useEffect, useRef, useState } from "react";
export default function Test({ option }) {
const [value, setValue] = useState();
const [trigger, setTrigger] = useState(false);
const handleChange = (e) => {
setValue(e.target.value);
};
const InnerTest = function ({ value, onChangeCallback }) {
return (
<div>
<input type="text" value={value} onChange={onChangeCallback} />
</div>
);
};
useEffect(() => {
setInterval(() => {
setTrigger((prev) => !prev);
}, 1000);
}, []);
return (
<div>
<p>test</p>
<button onClick={() => setTrigger((prev) => !prev)}>
{trigger.toString()}
</button>
{trigger ? (
<InnerTest key={1} value={value} onChangeCallback={handleChange} />
) : (
<InnerTest key={1} value={value} onChangeCallback={handleChange} />
)}
</div>
);
}
다른 비교를 위해서 이번에는 다른 예시를 들고 왔다.
함수형 컴포넌트 Test내에서 호출된 InnerTest와 InnerTest2는 리턴 값이 동일하다.
하지만 두 컴포넌트에 동일한 key를 부여해도 리엑트는 서로 다른 컴포넌트로 인식하여 focus가 풀린다.
import { useEffect, useRef, useState } from "react";
const InnerTest = function ({ value, onChangeCallback }) {
return (
<div>
<input type="text" value={value} onChange={onChangeCallback} />
</div>
);
};
const InnerTest2 = function ({ value, onChangeCallback }) {
return (
<div>
<input type="text" value={value} onChange={onChangeCallback} />
</div>
);
};
export default function Test({ option }) {
const [value, setValue] = useState();
const [trigger, setTrigger] = useState(false);
const handleChange = (e) => {
setValue(e.target.value);
};
useEffect(() => {
setInterval(() => {
setTrigger((prev) => !prev);
}, 1000);
}, []);
return (
<div>
<p>test</p>
<button onClick={() => setTrigger((prev) => !prev)}>
{trigger.toString()}
</button>
//포커스 지속
{trigger ? (
<InnerTest key={1} value={value} onChangeCallback={handleChange} />
) : (
<InnerTest2 key={1} value={value} onChangeCallback={handleChange} />
)}
//포커스 풀림
{trigger ? (
<input key={2} value={value} onChange={handleChange} />
) : (
<input key={2} value={value} onChange={handleChange} />
)}
</div>
);
}
결론
- 함수형 컴포넌트 내에서 선언된 컴포넌트는 부모 컴포넌트의 재호출 시 새로이 재선언되며 리엑트는 이를 이전과 다른 컴포넌트로 인식한다.
- 이것을 피하기 위해서는 컴포넌트는 다른 함수형 컴포넌트에서 선언하면 안되며 파일 스코프 최상단에서 선언되어야 한다.
- 함수형 컴포넌트들은 서로 동일한 키를 가져도 리엑트에서 서로 다른 컴포넌트로 인식한다.
* 여기서 다루진 않았으나, memo를 사용해도 먹히지 않았다.