컴포넌트를 재사용 가능하게 할 수 있는 방법으로 HOC 패턴을 알아보았다.
컴포넌트를 재사용 할 수 있는 또 다른 방법으로 Render Props 패턴을 사용할 수 있다.
Render Props는 컴포넌트의 props로 함수이며, JSX 엘리먼트를 리턴한다. 컴포넌트 자체는 아무 것도 렌더링하지 않지만 render prop 함수를 호출한다.
Title 컴포넌트가 있다고 생각해보자. Title 컴포넌트는 prop으로 넘어온 함수를 호출하여 반환하는 것 외에는 아무런 동작을 하지 않는다.
<Title render={() => <h1>I am a render prop!</h1>} />
Title 컴포넌트 내에서는 단순히 prop의 render 함수를 호출하여 반환한다.
const Title = props => props.render()
컴포넌트 엘리먼트에 react 엘리먼트를 반환하는 render
라는 이름의 prop을 넘긴다.
import { render } from "react-dom";
import "./styles.css";
const Title = (props) => props.render();
render(
<div className="App">
<Title
render={() => (
<h1>
<span role="img" aria-label="emoji">
✨
</span>
I am a render prop!{" "}
<span role="img" aria-label="emoji">
✨
</span>
</h1>
)}
/>
</div>,
document.getElementById("root")
);
render prop 패턴의 장점은 prop을 받는 컴포넌트가 재사용성이 좋다는 점이다.
Title 컴포넌트는 이제 render
prop만 바꿔가며 여러 번 사용할 수 있다.
import { render } from "react-dom";
import "./styles.css";
const Title = (props) => props.render();
render(
<div className="App">
<Title render={() => <h1>✨ First render prop! ✨</h1>} />
<Title render={() => <h2>🔥 Second render prop! 🔥</h2>} />
<Title render={() => <h3>🚀 Third render prop! 🚀</h3>} />
</div>,
document.getElementById("root")
);
이 패턴의 이름이 render prop이지만, 넘기는 prop의 이름을 꼭 render라고 할 필요는 없다. JSX를 렌더하는 어떤 prop이던 render prop으로 볼 수 있다.
import { render } from "react-dom";
import "./styles.css";
const Title = (props) => (
<>
{props.renderFirstComponent()}
{props.renderSecondComponent()}
{props.renderThirdComponent()}
</>
);
render(
<div className="App">
<Title
renderFirstComponent={() => <h1>✨ First render prop! ✨</h1>}
renderSecondComponent={() => <h2>🔥 Second render prop! 🔥</h2>}
renderThirdComponent={() => <h3>🚀 Third render prop! 🚀</h3>}
/>
</div>,
document.getElementById("root")
);
render prop을 받는 컴포넌트는 단순히 함수를 호출해 JSX 엘리먼트를 렌더링하는 것 외에도 많은 동작을 할 수 있다. 단지 함수를 호출하는 것 대신 render prop 함수를 호출할 때 인자를 전달할 수 있다.
function Component(props) {
const data = { ... }
return props.render(data)
}
위처럼 인자를 넘기게 구현하면 render prop은 이제 아래 코드와 같이 데이터를 인자로 받을 수 있다.
<Component render={data => <ChildComponent data={data} />} />
아래 예제는 텍스트박스에 섭씨 온도를 받아서 켈빈과 화씨 온도로 표현해주는 단순한 앱이다.
import { useState } from "react";
import "./styles.css";
function Input() {
const [value, setValue] = useState("");
return (
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
);
}
export default function App() {
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input />
<Kelvin />
<Fahrenheit />
</div>
);
}
function Kelvin({ value = 0 }) {
return <div className="temp">{value + 273.15}K</div>;
}
function Fahrenheit({ value = 0 }) {
return <div className="temp">{(value * 9) / 5 + 32}°F</div>;
}
Input
컴포넌트는 값 입력을 받기 위해 state를 갖고 있는데, Kelvin
컴포넌트와 Fahrenheit
컴포넌트는 이 state를 전달 받을 방법이 없다.
Kelvin
컴포넌트와 Fahrenheit
컴포넌트가 사용자가 입력한 값을 전달 받기 위한 방법 중 하나는 상태를 부모 컴포넌트로 올려보내는 방법이 있다.
아래 예제에서 상태를 가지고 있는 Input
컴포넌트가 있지만, 형제 컴포넌트인 Fahrenheit
, Kelvin
컴포넌트도 이 값에 접근할 수 있어야 변환된 값을 보여줄 수 있다. 이 때 Input 자체가 상태를 갖는 것 대신 세 컴포넌트의 부모 컴포넌트로 상태를 올려보내는 것이다.
function Input({ value, handleChange }) {
return <input value={value} onChange={e => handleChange(e.target.value)} />
}
export default function App() {
const [value, setValue] = useState('')
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input value={value} handleChange={setValue} />
<Kelvin value={value} />
<Fahrenheit value={value} />
</div>
)
}
이 방법도 유효하긴 하지만 규모가 큰 앱에서 컴포넌트가 여러 자식 컴포넌트를 가지고 있는 경우 이 작업을 하기란 까다로운 일이다.
상태의 변경은 모든 자식 컴포넌트의 리렌더링을 유발할 수 있고 이런 상황이 쌓이면 앱의 전체적인 성능을 떨어트릴 수 있다.
그 대신 render props 패턴을 활용할 수 있다. Input
컴포넌트가 render prop을 받도록 리팩토링 해보자.
function Input(props) {
const [value, setValue] = useState('')
return (
<>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
{props.render(value)}
</>
)
}
export default function App() {
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input
render={value => (
<>
<Kelvin value={value} />
<Fahrenheit value={value} />
</>
)}
/>
</div>
)
}
이로써 Kelvin
과 Fahrenheight
컴포넌트는 사용자의 입력값을 받을 수 있게 되었다.
일반적인 JSX 컴포넌트에 자식 엘리먼트로 react 엘리먼트를 반환하는 함수를 전달할 수 있다. 해당 컴포넌트에서 이 함수는 children
prop으로 사용 가능하며 이것도 역시 render prop에 해당한다.
Input
컴포넌트에 명시적으로 render prop을 넘기는 대신 자식 컴포넌트를 함수로 넘기도록 수정해보자.
export default function App() {
return (
<div className="App">
<h1>☃️ Temperature Converter 🌞</h1>
<Input>
{value => (
<>
<Kelvin value={value} />
<Fahrenheit value={value} />
</>
)}
</Input>
</div>
)
}
Input
컴포넌트는 props.children
을 통해 이 함수에 접근할 수 있다.
props.render
를 쓰는 대신 props.children
함수를 호출하며 인자를 넘기도록 수정한다.
function Input(props) {
const [value, setValue] = useState('')
return (
<>
<input
type="text"
value={value}
onChange={e => setValue(e.target.value)}
placeholder="Temp in °C"
/>
{props.children(value)}
</>
)
}
몇몇 상황에서 render props 패턴은 hooks로 대체될 수 있다. Apollo Client가 좋은 예시이다.
Apollo Client를 사용하는 방법 중 하나는 Mutation
과 Query
컴포넌트를 사용하는 것이다.
아래 예시는 HOC 패턴의 Input
컴포넌트 예시와 동일한데, graphql()
HOC를 사용하는 대신 Mutation
컴포넌트가 render prop을 받는 것을 알 수 있다.
import "./styles.css";
import { Mutation } from "react-apollo";
import { ADD_MESSAGE } from "./resolvers";
export default class Input extends React.Component {
constructor() {
super();
this.state = { message: "" };
}
handleChange = (e) => {
this.setState({ message: e.target.value });
};
render() {
return (
<Mutation
mutation={ADD_MESSAGE}
variables={{ message: this.state.message }}
onCompleted={() =>
console.log(`Added with render prop: ${this.state.message} `)
}
>
{(addMessage) => (
<div className="input-row">
<input
onChange={this.handleChange}
type="text"
placeholder="Type something..."
/>
<button onClick={addMessage}>Add</button>
</div>
)}
</Mutation>
);
}
}
Mutation
컴포넌트가 자식 엘리먼트에게 데이터를 전달할 수 있도록 하기 위해 컴포넌트를 렌더하는 함수를 자식 요소로 제공했다. 이 함수에서 인자로 데이터를 받을 수 있다.
<Mutation mutation={ ... } variables={ ... }>
{addMessage => <div className="input-row">...</div>}
</Mutation>
render prop 형태는 HOC에 비해 조금 더 선호되긴 하지만 단점도 존재한다.
첫 번째 단점으로는 트리가 깊어진다. 컴포넌트가 여러 개의 mutation을 사용해야 하는 경우 Mutation
컴포넌트나 Query
컴포넌트를 중첩해 사용해야 한다.
<Mutation mutation={FIRST_MUTATION}>
{firstMutation => (
<Mutation mutation={SECOND_MUTATION}>
{secondMutation => (
<Mutation mutation={THIRD_MUTATION}>
{thirdMutation => (
<Element
firstMutation={firstMutation}
secondMutation={secondMutation}
thirdMutation={thirdMutation}
/>
)}
</Mutation>
)}
</Mutation>
)}
</Mutation>
react에 훅이 추가되고 나서 Apollo에서도 훅을 지원하기 시작했다. Mutation
혹은 Query
컴포넌트를 사용하는 대신 개발자는 훅을 사용하여 직접 필요한 값을 참조할 수 있게 되었다.
아래 예시에서는 Query
컴포넌트를 render prop과 함께 사용하는 대신 useQuery
훅을 사용하고 있다.
import { useState } from "react";
import "./styles.css";
import { useMutation } from "@apollo/react-hooks";
import { ADD_MESSAGE } from "./resolvers";
export default function Input() {
const [message, setMessage] = useState("");
const [addMessage] = useMutation(ADD_MESSAGE, {
variables: { message }
});
return (
<div className="input-row">
<input
onChange={(e) => setMessage(e.target.value)}
type="text"
placeholder="Type something..."
/>
<button onClick={addMessage}>Add</button>
</div>
);
}
render prop을 사용하여 몇몇 컴포넌트 간 데이터를 공유하는 것은 간단하다. children
prop을 활용하는 것으로 해당 컴포넌트를 재사용할 수 있게 된다. HOC 패턴도 마찬가지로 재사용성과 데이터의 공유 부분에서 같은 이슈를 해결할 수 있다.
props를 자동으로 머지하도록 구현하지 않기 때문에 HOC 패턴을 사용할 때 prop이 어디서 만들어져 어디서 오는지 구별하기 힘들었던 이슈가 없다. 부모 컴포넌트로부터 받은 prop을 명시적으로 받아 처리하기 때문이다.
함수의 인자에서 명시적으로 prop이 전달되기 때문에 HOC를 사용할 때 prop이 모호한 문제가 해결된다. 이 때문에 prop이 어디로부터 오는지 확실히 알 수 있다.
render props를 활용하여 렌더링 컴포넌트와 앱의 로직을 분리할 수 있다. 상태를 가진 컴포넌트는 render prop을 받고 상태가 없는 컴포넌트를 렌더할 수 있다.
위에서 render props로 해결하려한 문제는 react hooks로 대체됐다. hooks는 컴포넌트에 재사용성과 데이터 공유를 위한 방법 자체를 바꿔놓았다. 대부분의 render props는 hooks로 대체 가능하다.
render prop 내에서는 생명 주기 함수를 사용할 수 없기 때문에 render prop 패턴은 받은 데이터를 수정할 필요가 없는 컴포넌트들에 대하여 사용할 수 있다.
< 출처 : https://patterns-dev-kr.github.io/design-patterns/render-props-pattern/ >