만약에 SearchResult
가 검색 결과 데이터를 내부 상태로 가지고 있다면, 외부에서는 이 값에 접근할 방법이 없습니다.
App
컴포넌트에서는 검색어를 키워드로 들고 있기 때문에, 마찬가지로 검색도 할 수 있고, 검색 결과도 앱 컴포넌트가 바로 알수있다.
따라서, 검색 결과를 SearchResult
가 가지고 있는 것보다는 앱 컴포넌트가 가지고 있는 것이 더 좋을 것같다.
그리고 앱 컴포넌트는 데이터를 자식 컴포넌트인 SearchResult
쪽으로 전달 해주는 쪽이 구조가 더 깔끔하다.
정리하면, SearchForm
처럼 SearchResult
도 검색 결과 데이터를 props
로 받아서 리엑트 엘리먼트로 반환하는 컴포넌트를 만들자!!
state
값이 해당 컴포넌트에서는 필요가 없기 때문에, 함수 컴포넌트로 만들겠다.
// 1
const SearchResult = ({ data = [] }) => {
// 2
if (data.length <= 0) {
return <div className="empty-box">검색 결과가 없습니다</div>
}
// 3
// props로 받은 data로 ul 태그를 그리자!!
return (
<ul className="result">
{data.map(item => (
<li key={item.id}>
<img src={item.imageUrl} />
<p>{item.name}</p>
</li>
))}
</ul>
)
}
- 상태가 필요 없어 함수 컴포넌트로 만들었다. => (1)
- 검색 결과를 props의 data 변수로 받는다. 데이터가 없을 경우 검색 결과가 없는 메세지를 보일 것이고 => (2)
- 그렇지 않을 경우 리스트를 출력한다. => (3)
이제 App 컴포넌트를 변경해보자!!
기존에 Sprint에서 사용하던 Store.js
, helpers.js
그리고 storage.js
를 components 폴더
로 가져와 줬다.
class App extends React.Component {
constructor() {
super();
this.state = {
searchKeyword: "",
searchResult: [], // 1
submitted: false, // 2
}
}
search(searchKeyword) {
const searchResult = store.search(searchKeyword);
// 3
this.setState({
searchResult,
submitted: true,
});
}
render() {
return (
<>
<Header title="검색" />
<SearchForm onSubmit={searchKeyword => this.search(searchKeyword)} />
{/* 4 */}
{submitted && <SearchResult data={searchResult} />}
</>
)
}
- 검색 결과를 저장할
searchResult
상태를 추가하고 빈 배열로 초기화 했다(1).- 첫 렌딩 시에는 검색 결과를 숨기고 폼이 제출된 다음 보이기 위한
submitted
플래그도 두었다(2).searchResult
배열만으로는 검색 여부를 알 수 없기 때문이다.SearchForm
컴포넌트에서 submit 이벤트를 수신하면search()
메소드가 호출되는데 이 때 스토어에서 검색 결과를 가져와state
를 갱신한다(3).- 그럼
render()
에서 의존하는SearchResult
컴포넌트가 다시 그려질 것이다(4).
현재 x 버튼
클릭시, 검색폼을 초기화된다. 그러나, 아직 검색결과는 사라지지 않는다.
검색 결과도 검색폼 값이 변하면, 반응하게 하려면, App의 state 값을 조절해야한다.
검색폼 초기화 시에 submitted를 false로 만들고 주고, 좀 더 깔끔하게 해주기 위해서, searchResult
도 빈 배열로 재할당해서 이 문제를 해결해보자!!
reset을 담당하는 handleReset()
을 수정하자!!
class App extends React.Component {
// 1
handleReset() {
this.setState({
searchKeyword: "",
searchResult: [],
submitted: false,
});
}
render() {
return (
<>
<Header title="검색" />
<SearchForm
onSubmit={searchKeyword => this.search(searchKeyword)}
onReset={()=>this.handleReset()} {/* 2 */}
/>
{submitted && <SearchResult data={searchResult} />}
</>
)
}
}
SearchForm
의 reset 이벤트를 처리하는handleReset()
메소드를 수정하면 된다(1).
검색 결과의 출력 여부를 submitted 상태가 관리하고 있기 때문에 이를false
로 설정한다. 검색어도 빈 문자열로 초기화하면 이를 사용하는SearchForm
의 input 값이 사라질 것이다. 검색 결과를 담은searchResult
도 빈 배열로 초기화 했다.
지금까지는 선택된 탭을 의미하는 state.selectedTab
으로 상태 관리를 했다. 하지만, 지금은 App 컴포넌트는 선택한 탭에 따라 "추천 검색어"나 "최근 검색어" 목록을 노출한다. 그런 이유로, selectedTab
도 Tabs 컴포넌트로 숨기는 것 보다는 App 컴포넌트에 위치하는 것이 더 낫다.
- 🙋로직 이해를 위해 알아둬야 될 점
=>TabLabel["KEYWORD"]
가 어떻게 "추천 검색어"를 출력하는지 이해하자!!
const TabType = {
KEYWORD: "KEYWORD",
HISTORY: "HISTORY",
}
const TabLabel = {
[TabType.KEYWORD]: "추천 검색어",
[TabType.HISTORY]: "최근 검색어",
}
// 1
const Tabs = ({ selectedTab, onChange }) => (
<ul className="tabs">
{Object.values(TabType).map(tabType => (
<li
key={tabType}
className={selectedTab === tabType ? "active" : ""} // 2
onClick={() => onChange(tabType)} // 3
>
{TabLabel[tabType]}
</li>
))}
</ul>
)
상태가 필요 없기 때문에 함수 컴포넌트로 만들었다.
- 선택된 탭
selectedTab
과 탭을 변경할 때 알려줄onChange
콜백 함수을props
로 받는다(1).- 선택된 탭일 경우
active CSS 클래스
를 추가해 선택된 탭으로 표시한다(2).- 탭을 클릭하면 이를 부모 컴포넌트로 전달한다(3).
App를 수정해보자!!
import Tabs, { TabType } from "./components/Tabs.js"; // 추가해줌
class App extends React.Component {
constructor() {
super();
this.state = {
searchKeyword: "",
searchResult: [],
submitted: false,
selectedTab: TabType.KEYWORD, // 1
}
render() {
const { submitted, selectedTab } = this.state
return (
<>
<Header title="검색" />
<SearchForm onSubmit={/* 생략 */} onReset={/* 생략 */} />
{submitted ? (
<SearchResult />
) : (
// 2
<>
<Tabs
selectedTab={this.state.selectedTab} // 3
onChange={selectedTab => this.setState({ selectedTab })} // 4
/>
{selectedTab === TabType.KEYWORD && <>{`TODO: 추천 검색어`}</>}
{selectedTab === TabType.HISTORY && <>{`TODO: 최근 검색어`}</>}
</>
)}
</>
)
}
}
- 💡요구사항: 추천 검색어, 최근 검색어 탭이 검색폼 아래 위치한다
- selectedTab을 두어 선택된 탭 상태를 관리한다(1).
초기값은 추천 검색어로 설정했다. Tabs는 폼이 제출되기 전에 SearchForm 아래에 놓았다(2).- 💡요구사항: 기본으로 추천 검색어 탭을 선택한다
- 기본 값인 추천 검색어가 selectedTab 속성으로 전달되어 화면 첫 렌딩시에는 추천 검색어 탭이 표시될 것이다(3).
- 💡요구사항: 각 탭을 클릭하면 탭 아래 내용이 변경된다
- 그리고 각 탭을 클릭하면 change 이벤트가 발생하는데 선택한 탭을 selectedTab 상태로 반영한다(4). 이 값에 따라 아래 내용이 변경될 것이다.
이렇게 컴포넌트로 바꾸고 보니 App의 render()
메소드 안에 있던 앨리먼트 변수를 각 각 컴포넌트로 분리한 결과가 되었다. 여전히 state
는 이동하지 않고 App 컴포넌트의 소유로 남아있는데, 추천 검색어
, 최근 검색어
에서는 자신만의 고유한 state
를 관리하는 컴포넌트로 분리해 보겠다.
- 추천 검색어
💡요구사항: 번호와 추천 검색어 이름이 목록 형태로 탭 아래 위치한다
💡요구사항: 목록에서 검색어를 클릭하면 선택된 검색어의 검색 결과 화면으로 이동한다
- 최근 검색어
💡요구사항: 최근 검색어 이름, 검색일자, 삭제 버튼이 목록 현태로 탭 아래 위치한다
💡요구사항: 목록에서 검색어를 클릭하면 선택된 검색어로 검색 결과 화면으로 이동한다
💡요구사항: 목록에서 x 버튼을 클릭하면 선택된 검색어가 목록에서 삭제된다
💡요구사항: 검색시마다 최근 검색어 목록에 추가된다
- 상속
- 조합: 컴포넌트 담기
- 조합: 특수화
KeywordList.js
와 HistoryList.js
의 부모 컴포넌트가 될 List.js
를 만들자.
추천검색어와 최근검색어의 중복된 기능을 List라는 컴포넌트로 모았다.
// 1
class List extends React.Component {
constructor() {
super()
this.state = { data: [] } // 2
}
// 3
renderItem(item, index) {
throw "renderItem()을 구현하세요"
}
// 4
render() {
const { onClick } = this.props
const { data } = this.state
return (
<ul className="list">
{data.map((item, index) => (
// 5
<li key={item.id} onClick={() => onClick(item.keyword)}>
{this.renderItem(item, index)} {/* 6 */}
</li>
))}
</ul>
)
}
}
- 배열 상태를 사용하기 때문에 클래스 컴포넌트를 사용했다(1, 2).
render()
메소드에서는 이 상태 값으로 리스트 렌더링을 한다(4).- 리스트를 클릭하면 외부의 콜백 함수를 호출해 선택한 키워드 문자열을 전달한다(5).
- 리스트 렌더링에 보면
renderItem()
이란 추상 메서드를 호출해서 각 아이템을 그리고 있다(6).- List 클래스를 구현한 자식 클래스에서는 이
renderItem()
을 오버라이딩해서 각자에 맞는 형태로 리스트를 그릴수 있도록 열어둔 것이다(3).
추상 메서드 : 구현부가 없는 메서드
이제 List.js
를 상속할 KeywordList.js
파일을 추가하자.
// 1
class KeywordList extends List {
// 2
componentDidMount() {
const data = store.getKeywordList()
this.setState({ data })
}
// 3
renderItem(item, index) {
return (
<>
<span className="number">{index + 1}</span>
<span>{item.keyword}</span>
</>
)
}
}
- List 클래스를 상속했다(1).
renderItem()
을 오버라이딩해 키워드 목록을 그리는데 순서와 키워드를 출력한다(3).- 이 메서드는 부모 클래스의
render()
메소드에서 호출되어 리액트 앨리먼트를 만들 때 사용될 것이다. 이때는 data가 빈 배열이라, 빈배열을 render()한다.
state.data
에는 빈 배열이 초기값인데, 외부에서 데이터를 가져오기 위해 생명 주기 메소드componentDidMount()
를 사용했다. 컴포넌트가 돔에 마운트 된 직후에 실행되는데 store에서 키워드 목록을 가져와 컴포넌트 상태를 갱신할 것이다(2).
State 변화를 감지한 리액트는 다시render()
메소드로 화면을 다시 그리고 자식 클래스에서 완성한renderItem()
도 호출 되어 번호와 추천 검색어 이름을 출력할 것이다.
HistoryList.js 파일도 만들어보자!!
// 1
class HistoryList extends List {
// 2
componentDidMount() {
this.fetch()
}
// 3
fetch() {
const data = store.getHistoryList()
this.setState({ data })
}
// 4
handleClickRemove(event, keyword) {
event.stopPropagation()
store.removeHistory(keyword)
this.fetch()
}
// 5
renderItem(item) {
return (
<>
<span>{item.keyword}</span>
<span className="date">{formatRelativeDate(item.date)}</span>
<button
className="btn-remove"
onClick={event => this.handleClickRemove(event, item.keyword)}
/>
</>
)
}
}
- List 클래스를 상속하고(1),
renderItem()
메소드를 오버라딩 한다(5).- 컴포넌트가 돔에 마운트 된 후 데이터를 불러오기위해
fetch()
함수를 호출한다(2, 3).
- 동일한 코드 내용을 데이터를 받아올 때, 한 번 그리고 클릭된 데이터를 삭제할 때, 또 한 번 써줘야 하기 때문에,
fetch()
라는 함수로 코드를 담고 재활용한다.- 스토어에서 데이터를 불러와 컴포넌트 상태로 갱신하면 리액트는 상태 변화를 감지하고 리액트 앨리먼트를 다시 만들 것이다. 그리고 x 버튼 클릭을 처리하는
handleClickRemove()
메소드도 추가했다(4). 스토어에서 검색 이력을 삭제하고 다시 불러오는 역할이다.
App.js
도 이에 맞게 수정해보자!!
class App extends React.Component {
render() {
const { submitted, selectedTab } = this.state
return (
<>
<Header />
<SearchForm />
{submitted ? (
<SearchResult />
) : (
<>
<Tabs />
{selectedTab === TabType.KEYWORD && (
<KeywordList onClick={keyword => this.search(keyword)} />
)}
{selectedTab === TabType.HISTORY && (
// 1
<HistoryList onClick={keyword => this.search(keyword)} />
)}
</>
)}
</>
)
}
}
최근 검색어를 보여주는 부분을 텍스트에서
HistoryList
사용으로 대체했다.KeywordList
를 사용한 방식과 동일하다. App은selectedTab
에 따라KeywordList
나HistoryList
를 렌더링한다. 각 컴포넌트는 생겼다 사라졌다 하는데 매번componentDidMount()
메서드에 기술한 로직이 실행될 것이다. 이 때 외부 데이터를 가져오고 자신만의 리스트를 렌더링하는 방식으로 각자의 화면을 그린다.
리액트는 클래스 상속으로 컴포넌트 재활용하는 것을 권장하지는 않는다.
대신, 상속보다 더 쉬운 조합이라는 방법을 권장한다.
조합은 2가지 방법으로 이뤄질 수 있다.
1. 컴포넌트 담기
2. 특수화
=> 둘 다 props
를 통해 컴포넌트를 합성할 수 있다.
Facebook에서는 수천 개의 React 컴포넌트를 사용하지만, 컴포넌트를 상속 계층 구조로 작성을 권장할만한 사례를 아직 찾지 못했습니다. - 출처: 리액트 문서
List 컴포넌트를 조합할수 있는 방식으로 변경하고 이를 활용해 KeywordList
, HistoryList
컴포넌트를 조합해 만들어 보면서 이 방식의 장점을 알아보자.
List 컴포넌트를 조합할 수 있는 형태로 변경하자.
// 리스트를 렌더링하는 부분과 각 리스트를 클릭했을 때에 처리를 List가 담당
// 1
const List = ({ data = [], onClick, renderItem }) => {
return (
<ul className="list">
{data.map((item, index) => (
<li key={item.id} onClick={() => onClick(item.keyword)}>
{renderItem(item, index)} {/* 2 */}
</li>
))}
</ul>
)
}
- 클래스를 사용하지 않고 함수 컴포넌트로 만들었다(1).
- 외부에서 렌더링에 필요한 데이터를 주입 받겠다는 의도이다.
- 키워드 목록과 검색 목록을
props.data
로 받았다.
- 리스트 출력까지만 담당하고 리스트를 구성하는 각 항목을 출력하는 함수는
props.renderItem
이란 이름으로 전달 하도록 했다.renderItem()
함수에 아이템과 인덱스를 전달해 List 컴포넌트를 사용하는 측에서 구체적으로 그리도록 역할을 위임한 셈이다(2).
참고로, props에 함수를 전달할 수 있다고 했다. 이러한 함수 중 리액트 앨리먼트를 반환하는 함수를 render props라고 부른다. 전달된 함수로 UI 렌더링을 하기 때문이다.renderItem
이render props
이다.
render props
: 리엑트 엘리먼트를 반환해서, 그것으로 UI를 그리는 용도로 활용하는 함수
이제 이걸 사용한 KeywrodList를 만들어 보자.
// List 컴포넌트를 조합하는 방식으로 KeywrodList를 만들었다
// KeywrodList는 자신만의 데이터를 관리하고 그 데이터를 List로 전달해줬다.
// 그리고 renderItem()을 통해서 추천검색어 모양을 출력했다.
// 1
class KeywordList extends React.Component {
constructor() {
super()
this.state = { keywordList: [] } // 2
}
// 3
componentDidMount() {
const keywordList = store.getKeywordList()
this.setState({ keywordList })
}
render() {
const { onClick } = this.props
const { keywordList } = this.state
return (
// 4
<List
data={keywordList}
onClick={onClick}
// 5
renderItem={(item, index) => (
<>
<span className="number">{index + 1}</span>
<span>{item.keyword}</span>
</>
)}
/>
)
}
}
- 키워드 목록을 내부 상태로 갖기 위해 클래스 컴포넌트로 선언했다(1).
state.keywordList
란 이름으로 빈 배열로 초기화했다(2).- 이 값은
render()
메소드에서 사용하는데 List 컴포넌에 전달했다(4).- 배열을 보고 List 컴포넌트가 목록을 그리도록
renderItem
함수도 함께 전달해 세부사항을 그리도록 했다(5).- 돔이 마운트 된 후 스토어에서 키워드 목록을 가져오고 상태로 갱신되면 목록을 그릴 것이다(3)
List가 공통 로직과 UI를 담는 컴포넌트이고 이걸 이용해서KeywordList
와HistoryList
를 다시 만들었다.
클래스 상속과는 달리 List안에renderItem
이라는render props
를 전달해서 List가 컴포넌트를 그리도록 한 점이 차이다. 이 뿐만 아니라 컴포넌트 자체로props
로 전달할수 있다. 이렇게props
로 컴포넌트를 전달하거나 렌더하는 방법을 전달하는 방식을 컴포넌트 담기라고 부른다.
function SplitPane(props) {
return (
<div className="SplitPane">
<div className="SplitPane-left">
{props.left}
</div>
<div className="SplitPane-right">
{props.right}
</div>
</div>
);
}
function App() {
return (
<SplitPane
left={
<Contacts />
}
right={
<Chat />
} />
);
}
자료출처 : 합성 (Composition) vs 상속 (Inheritance)
// 1
class HistoryList extends React.Component {
constructor() {
super()
this.state = { historyList: [] } // 2
}
// 3
componentDidMount() {
this.fetch()
}
// 4
fetch() {
const historyList = store.getHistoryList()
this.setState({ historyList })
}
// 5
handleClickRemove(event, keyword) {
event.stopPropagation()
store.removeHistory(keyword)
this.fetch()
}
render() {
const { onClick } = this.props
const { historyList } = this.state
return (
// 6
<List
data={historyList}
onClick={onClick}
renderItem={item => (
<>
<span>{item.keyword}</span>
<span className="date">{formatRelativeDate(item.date)}</span>
<button
className="btn-remove"
// 7
onClick={event => this.handleClickRemove(event, item.keyword)}
/>
</>
)}
/>
)
}
}
- 최근 검색어 데이터를 상태로 관리하기 위해 클래스 컴포넌트로 만들고(1)
state.historyList
를 빈 배열로 초기화 했다(2).- 이 값을
render()
메소드에서 List 컴포넌트와 함께 사용했다(6). 돔이 마운트되면 데이터를 불러와historyList
상태를 갱신하고(4) 리스트를 다시 그릴 것이다.render()
메소드를 다시보면 히스트리 리스트의 각 항목을 그릴 수 있도록renderItem
함수도 전달했는데, 이 부분이KeywordList
와 다른 점이다.- 키워드 옆에 날짜와 삭제 버튼을 추가했다. 삭제 버튼을 클릭하면
handleClickRemove()
함수를 호출하는데(7) 이벤트 전파을 막고 검색 이력에서 삭제한 뒤 데이터를 다시 불러온다(5).
// 1
const List = ({
data = [],
hasIndex = false,
hasDate = false,
onClick,
onRemove,
}) => {
const handleClickRemove = (event, keyword) => {
event.stopPropagation()
onRemove(keyword)
}
// 2
return (
<ul className="list">
{data.map(({ id, keyword, date }, index) => (
<li key={id} onClick={() => onClick(keyword)}>
{/* 3 */}
{hasIndex && <span className="number">{index + 1}</span>}
<span>{keyword}</span>
{hasDate && <span className="date">{formatRelativeDate(date)}</span>}
{/* 4 */}
{!!onRemove && (
<button
className="btn-remove"
onClick={event => handleClickRemove(event, keyword)}
/>
)}
</li>
))}
</ul>
)
}
- List 컴포넌트가 외부에서 받는
props
갯수가 늘었다(1).props
어떻게 설정하느냐에 따라 조금씩 다른 모양과 행위를 하는 컴포넌트를 만드는데 사용할 것이다.hasIndex
를 설정하면 좌측에 순서를 표시하도록 했다(3).KeywordList
컴포넌트에서 사용할 옵션이다.hasDate
와remove
값에 따라 날짜와 삭제 버튼을 보이도록 했다(4). 최근 검색어를 위한 옵션이다.
Q&A 중에서 알아둬야할 질문 몇 개 넣어둔다. 반드시 내 것으로 만들자!!
return this.handleReset();
의미