Fluent React 5 - Prop 관련 패턴들
2024/02/28
n°48
category : React
☼
2024/02/28
n°48
category : React
지난 글에 이은 Fluent React의 5장, Powerful Patterns 챕터의 마지막 내용이다. Props와 연관된 설계 패턴을 모아 작성하였다.
const WindowSize = (props) => {
const [size, setSize] = useState({ width: -1, height: -1 });
return props.render(size);
};
...
<WindowSize
render={({ width, height }) => (
<div>
Your window is {width}x{height}px
</div>
)}
/>
render prop의 기본형은 컴포넌트의 prop으로 함수 형태의 컴포넌트를 넘겨주는 것이다. 이를 받는 컴포넌트는 prop을 렌더링한다. 위의 예시에서 WindowSize는 render에 div 컴포넌트를 리턴하며, 내부의 size에 접근하고 있다. 라이브러리에서 보통 접할 수 있다. 예시를 들어 react-error-boundary의 ErrorBoundary 컴포넌트의 fallback ui 패턴은 render props를 사용한다.
다음 예시를 통해 render props의 장단점, 사용처를 알아보자.
// Before
const SnippetInput = () => {
const [text, setText] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<input
type="text"
placeholder="Type Here"
value={text}
onInput={handleInputChange}
/>
);
};
const SnippetToLower = ({ text }: { text: string }) => {
return <p>{text.toLowerCase()}</p>;
};
const SnippetToUpper = ({ text }: { text: string }) => {
return <p>{text.toUpperCase()}</p>;
};
const SnippetSample = () => (
<>
<SnippetInput />
<SnippetToLower text="Hello World" />
<SnippetToUpper text="Hello World" />
<ExpensiveComponentA />
<ExpensiveComponentB />
<ExpensiveComponentC />
</>
);
// Refactor-1
const SnippetInput = () => {
const [text, setText] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<>
<input
type="text"
placeholder="Type Here"
value={text}
onInput={handleInputChange}
/>
<SnippetToLower text={text} />
<SnippetToUpper text={text} />
</>
);
};
Before 코드에서 input의 로컬 상태(text)에 접근할 수 없어 SnippetInput 컴포넌트 내부로 SnippetToLower, SnippetToUpper가 이동한 모습이다. Refactor-1에서 부모(SnippetSample)로 상태를 올리고, Input, ToLower, ToUpper 세 컴포넌트가 상태를 공유하지 않은 이유를, 키보드 입력마다 ExpensiveComponentA~C가 리렌더링 되기 때문이라고 가정하자. 일반적인 컴포넌트의 분리이지만, 컴포넌트의 deps가 생기는 점이 아쉽다.
// Render Props - 1
const SnippetInput = (props: {
render: (text: string) => React.ReactNode;
}) => {
const [text, setText] = useState("");
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setText(e.target.value);
};
return (
<>
<input
type="text"
placeholder="Type Here"
value={text}
onInput={handleInputChange}
/>
{props.render(text)}
</>
);
};
const SnippetSample = () => {
console.log("rerender SnippetSample");
return (
<>
<SnippetInput
render= {(text: string) => (
<div>
<SnippetToLower text={text} />
<SnippetToUpper text={text} />
</div>
)}
/>
<ExpensiveComponentA />
<ExpensiveComponentB />
<ExpensiveComponentC />
</>
);
};
Render Prop 사용 시 다음과 같은 장점이 생긴다.
상태의 관리는 자식에서 관리하지만, 부모에서 자식 컴포넌트의 상태에 접근하면서 렌더링될 JSX를 작성할 수 있는 점이 장점으로 다가온다. 만약 render={() ⇒ …} 형태가 가독성이 좋지 않다면 children을 이용해 더 명시적으로 표현할 수 있다.
// Render Props - children
const SnippetInput = (props: {
children: (text: string) => React.ReactNode;
}) => {
... (생략)
return (
<>
<input
type="text"
placeholder="Type Here"
value={text}
onInput={handleInputChange}
/>
{props.children(text)}
</>
);
};
const SnippetSample = () => {
console.log("rerender SnippetSample");
return (
<>
<SnippetInput>
{(text: string) => (
<div>
<SnippetToLower text={text} />
<SnippetToUpper text={text} />
</div>
)}
</SnippetInput>
....
</>
);
};

키보드 입력이 일어나도 모든 컴포넌트가 리렌더링되지 않는 것을 확인할 수 있다. 물론 부모 컴포넌트(SnippetSample)에 로컬 상태가 추가된다면, 이 상태가 업데이트 될 때마다 SnippetInput 또한 리렌더링될 것이다. 이 경우엔 상태를 효과적으로 분리했다고 보기 힘들 수 있다.
결론적으로 render prop의 사용목적은 ‘상태 공유’에 있겠다. children 패턴을 이용하면 코드의 가독성 또한 나쁘지 않아진다. 특히 SnippetInput 예시에서 살펴본 바와 같이 다음 상황에서 이점이 있겠다.
현재는 커스텀 Hooks나 전역 상태 등으로 유즈 케이스가 많은 부분 대체되었다고 한다. 따라서 render prop이 1번 이상 네스팅된다면, 커스텀 훅을 고려해보는 것도 나쁘지 않겠다.
<SnippetInput>
{(text) ⇒ (
<>
<NestedProp>
{(localState) ⇒
<>{localState ? “YES” : “NO” </>
}
</NestedProp>
<SnippetToLower text={text} />
<SnippetToUpper text={text} />
</>
)}
</SnippetInput>
컨트롤된 컴포넌트는 자체적인 내부 상태를 유지하지 않는 컴포넌트입니다. 대신, 부모 컴포넌트로부터 현재 값이 prop으로 전달되며, 이는 그 상태의 유일한 원천이 됩니다. 상태가 변경되어야 할 때, 컨트롤된 컴포넌트는 일반적으로 onChange와 같은 콜백 함수를 사용하여 부모에게 알립니다. 따라서 부모 컴포넌트가 상태를 관리하고 컨트롤된 컴포넌트의 값을 업데이트하는 책임을 집니다.
jsxCopy code
function ControlledInput({ value, onChange }) {
return <input type="text" value={value} onChange={onChange} />;
}
function ParentComponent() {
const [inputValue, setInputValue] = useState("");
return <ControlledInput value={inputValue} onChange={e => setInputValue(e.target.value)} />;
}
부모에서 상태를 관리하고, 자식 컴포넌트에 상태와 setter를 위한 콜백 함수를 전부 prop으로 받는다. 일견 앞선 패턴(render props, compound)들과 자식 컴포넌트의 상태를 분리하지 않고 상속하는 점에서 반대되며, presentational 패턴과 비슷해 보인다. 하지만 이 패턴은 자식 컴포넌트를 제어 컴포넌트로 만들어 상태 관리를 추상화하는 것에 초점을 맞춘다. 또한 제어된 props 패턴은 필요에 따라 자체적인 내부 상태도 유지할 수도 있다. 다음 예시에서 처럼 자식 컴포넌트 내부에서 새로운 state와 setter를 선언하고, 이를 controlled prop과 결합하여 사용한다.
const Toggle = ({
on,
onToggle,
}: {
on?: boolean;
onToggle?: Dispatch<SetStateAction<boolean>>;
}) => {
const [isOn, setIsOn] = React.useState(false);
const handleToggle = () => {
const nextState = on === undefined ? !isOn : !on;
if (on === undefined) {
setIsOn(nextState);
}
if (onToggle) {
onToggle(nextState);
}
};
const displayText = useMemo(() => {
if (on !== undefined) {
return on ? "On" : "Off"
}
return isOn ? "On" : "Off"
}, [on])
return <button onClick={handleToggle}>{displayText}</button>;
};
const ControlPropsPattern = () => {
const [toggle, setToggle] = React.useState(false);
return (
<div>
<h1>Controlled Props Pattern</h1>
<Toggle on={toggle} onToggle={setToggle} />
<p>{toggle ? "The button is on" : "The button is off"}</p>
</div>
);
};
다음의 예시는 Toggle의 상태는 부모 컴포넌트의 toggle에 따라 결정된다. 굳이 이 방식을 사용할 이유가 있을까? 책에서는 다음과 같은 패턴의 이점을 설명한다.
Control Props 패턴은 컴포넌트가 외부의 props에 의해 제어되거나 내부적으로 자체 상태를 관리할 수 있도록 함으로써 컨트롤할 수 있습니다… 이러한 이중 능력은 부모가 선택적으로 자식 컴포넌트의 상태를 제어할 수 있게 해주며, 동시에 자식 컴포넌트가 제어되지 않을 경우 독립적으로 작동할 수 있도록 합니다.
... Toggle 컴포넌트에서, isOn은 내부 상태를 나타내고, on은 외부 제어 prop입니다. 부모로부터 on prop이 제공될 경우, 컴포넌트는 제어 모드에서 작동할 수 있습니다. 제공되지 않으면 내부 상태인 isOn으로 돌아갑니다. onToggle prop은 부모 컴포넌트가 상태 변경에 반응할 수 있게 하는 콜백으로, Toggle의 상태와 부모의 자체 상태를 동기화할 수 있는 기회를 부모에게 제공합니다. 이 패턴은 컴포넌트의 유연성을 향상시키며, 제어된 모드와 비제어 모드의 운영을 모두 제공합니다. 필요할 때 부모가 제어할 수 있도록 하면서도, 명시적으로 제어되지 않을 때는 컴포넌트가 자신의 상태에 대한 자율성을 유지할 수 있도록 합니다.
Toggle 컴포넌트 예시와 달리 props가 undefined일 수 있다면, 제어 컴포넌트에 로컬 상태를 추가하여 자식 컴포넌트가 제어되지 않을 경우 독립적으로 작동하는 것이 좀 더 방어적일 수 있다.
첫번째 경우엔 api 데이터를 리액트의 상태나 react-query, swr 등 라이브러리가 제공하는 상태를 props로 넘겨준다면 흔하게 일어날 수 있는 상황이다. pending 일 때 자식 컴포넌트의 default state를 보여주도록 할 수 있다. 두번째 경우, Toggle 컴포넌트를 여러 곳에서 사용하나 부모 컴포넌트에서 상태를 내려주지 않을 때를 가정할 수 있다.
결론적으로 Controlled props를 받는 제어된 컴포넌트 내부에서 로컬 상태를 포함하든, 하지 않든 Controlled Props 패턴의 효과는 동일하다. 상태를 prop으로 전달하고, 상태를 부모에서 관리해 로직을 중앙화하는 것이다. 이를 통해 상태 관리 로직이 파편화되는 것을 막을 수 있다. 상태 관리 로직을 분리하는 패턴은 prop gettter, state reducer 패턴과 함께 더 효율적으로 추상화할 수 있다.