이번 유닛에서는 React 데이터 흐름에 대해서 다시 한번 알아보고,
여러 컴포넌트 사이에서 어떤 방식으로 데이터들을 다루게 되는지에 대한 방법을 배우게 된다.
또, Effect Hook과 Ajax를 사용해 서버로부터 데이터를 받아오는 방법에 대해서 학습하게 된다.
오늘 구현할 컴포넌트들을 트리 구조로 나타내면 다음 그림과 같다.
이제는 데이터를 어디에 둘지를 결정해보자.
컴포넌트는 컴포넌트 바깥에서 props를 이용해 데이터를 마치 전달인자(arguments)
혹은 속성(attributes)처럼 전달받을 수 있다.
즉 데이터를 전달하는 주체는 부모 컴포넌트가 된다. 이는 데이터 흐름이 하향식(top-down)임을 의미한다.
이 원칙은 매우 중요하다.
단방향 데이터 흐름(one-way data flow)이라는 키워드가 React를 대표하는 설명 중 하나일 정도이다.
또한 컴포넌트는 props를 통해 전달받은 데이터가 어디서 왔는지 전혀 알지 못한다.
애플리케이션에서 필요한 데이터가 무엇인지 먼저 정의했다.
이 중에 변하는 값과 변하지 않는 값은 무엇일까?
확실히 사용자 입력은 이벤트에 따라 얼마든지 변할 수 있다. 이것은 상태(state)이다.
트윗 목록이 추가 또는 삭제될 여지가 없다면 사실 state로 둘 필요가 없다.
그러나, 우리는 새 트윗 추가라는 기능을 염두에 두고 있으므로, 트윗 목록 역시 상태(state)이다.
모든 데이터를 상태로 둘 필요는 없다. 사실 상태는 최소화하는 것이 가장 좋다.
상태가 많아질수록 애플리케이션은 복잡해기 때문이다.
어떤 데이터를 상태로 두어야 하는지 여부는 다음 세 가지 질문을 통해 판단해보자.
상태를 어디에 위치시켜야 할까?
상태가 특정 컴포넌트에서만 유의미하다면, 특정 컴포넌트에만 두면 되니까 크게 어렵지 않지만, 만일 하나의 상태를 기반으로 두 컴포넌트가 영향을 받는다면 이때에는 공통 소유 컴포넌트를 찾아 그곳에 상태를 위치해야 한다.
즉, 두 개의 자식 컴포넌트가 하나의 상태에 접근하고자 할 때는 두 자식의 공통 부모 컴포넌트에 상태를 위치해야 한다.
그런데, 새 글을 추가하는 이벤트가 발생할 경우, 이때 전체 트윗 목록에 새로운 트윗 객체를 추가할 수 있어야 한다.
즉, 두 컴포넌트 모두 트윗 목록에 의존한다.
그렇다면, 두 컴포넌트의 부모는 무엇인가? 바로 Twittler이다. 전체 트윗 목록 상태는 여기에 위치한다.
이제 "사용자가 작성 중인 새로운 트윗 내용"이라는 상태를 위치해보자.
NewTweetForm에서는 사용자가 트윗 내용을 작성할 수 있다.
사용자 입력에 따라 값이 변하므로 "작성 중인 트윗 내용"은 상태이다.
그럼 Tweets나 다른 컴포넌트가 작성중인 내용을 가질 필요가 있을까?
아니다. NewTweetForm은 그저 버튼이 눌린 후 완성된 하나의 트윗 객체를 전체 트윗 목록에 전달하기만 하면 된다.
입력에 따라 실시간으로 다른 컴포넌트가 변한다면 모를까, 여기에서는 그렇지 않으므로 다른 컴포넌트와 공유할 필요가 없다. "작성 중인 트윗 내용"이라는 상태는 NewTweetForm에 두는 것으로 충분하다.
그런데, 갑자기 웬 역방향 데이터 흐름이라는 이야기가 나올까?
상태 위치를 전부 정하고 나서 생각해보니, 부모 컴포넌트에서의 상태가 하위 컴포넌트에 의해 변하는 상황이다.
바로 새로운 트윗 추가가 대표적인 예시이다. 버튼을 통한 이 액션은, 부모의 상태를 변화시켜야 한다.
하위 컴포넌트(NewTweetForm)에서의 클릭 이벤트가, 부모의 상태를 바꾸어야만 하는 상황이 왔다.
이를 어떻게 해결할 수 있을까?
이를 해결할 수 있는 키워드는 바로 "State 끌어올리기(Lifting state up)"이다.
이는 상태를 변경시키는 함수(handler)를 하위 컴포넌트에 props로 전달해서 해결할 수 있다.
이는 마치 콜백 함수를 사용하는 방법과 비슷한 것이다.
단방향 데이터 흐름이라는 원칙에 따라, 하위 컴포넌트는 상위 컴포넌트로부터 전달받은 데이터의 형태 혹은 타입이 무엇인지만 알 수 있다. 데이터가 state로부터 왔는지, 하드코딩으로 입력한 내용인지는 알지 못한다.
그러므로 하위 컴포넌트에서의 어떤 이벤트로 인해 상위 컴포넌트의 상태가 바뀌는 것은
마치 "역방향 데이터 흐름"과 같이 조금 이상하게 들릴 수 있다.
React가 제시하는 해결책은 다음과 같다.
상위 컴포넌트의 "상태를 변경하는 함수" 그 자체를 하위 컴포넌트로 전달하고, 이 함수를 하위 컴포넌트가 실행한다.
여전히 단방향 데이터 흐름의 원칙에 부합하는 해결 방법이다. 바로 이것이 "상태 끌어올리기"이다.
다음 코드를 해결해보자.
import React, { useState } from "react";
export default function ParentComponent() {
const [value, setValue] = useState("날 바꿔줘!");
const handleChangeValue = () => {
setValue("보여줄게 완전히 달라진 값");
};
return (
<div>
<div>값은 {value} 입니다</div>
<ChildComponent />
</div>
);
}
function ChildComponent() {
const handleClick = () => {
// 이 버튼을 눌러서 부모의 상태를 바꿀 순 없을까?
};
return <button onClick={handleClick}>값 변경</button>;
}
상태를 변경하는 함수는 무엇일까? 하위 컴포넌트로는 어떻게 전달할 수 있을까?
상태를 변경하는 함수는 handleChangeValue이다. 전달은 props를 이용하도록 한다.
하위 컴포넌트가 버튼 클릭 이벤트에 따라 상태를 변경하려고 하므로 이름은 handleButtonClick이라고 지어주자.
const handleChangeValue = () => {
setValue("보여줄게 완전히 달라진 값");
};
return (
<div>
<div>값은 {value} 입니다</div>
<ChildComponent handleButtonClick={handleChangeValue} />
</div>
);
}
<ChildComponent>
는 마치 고차 함수가 인자로 받은 함수를 실행하듯, props로 전달받은 함수를 컴포넌트 내에서 실행할 수 있게 된다. "상태 변경 함수"는 버튼이 클릭할 때 실행되기를 원하므로, 해당 부분에 콜백 함수를 실행하자.
function ParentComponent() {
const [value, setValue] = useState("날 바꿔줘!");
const handleChangeValue = (newValue) => {
setValue(newValue);
};
// ...생략...
}
function ChildComponent({ handleButtonClick }) {
const handleClick = () => {
// Q. 이 버튼을 눌러서 부모의 상태를 바꿀 순 없을까?
// A. 인자로 받은 상태 변경 함수를 실행하자!
handleButtonClick('넘겨줄게 자식이 원하는 값')
}
return (
<button onClick={handleClick}>값 변경</button>
)
}
오늘은 이 두 개의 컴포넌트로 필터링 조건을 업데이트하는 기능을 구현시킬 것이다.
부모 컴포넌트인 Main.js, 자식 컴포넌트인 Search.js 로 이루어져있다.
딱 봐도 오늘 복습을 진행하려면 Main -> Search 식으로 상태 끌어올리기가 이뤄질 것이다.
// Main.js
import Head from 'next/head';
import { useEffect, useState } from 'react';
import { getFlight } from '../api/FlightDataApi';
import FlightList from './component/FlightList';
import LoadingIndicator from './component/LoadingIndicator';
import Search from './component/Search';
import Debug from './component/Debug';
// 후반 테스트를 진행할 때 아래 import를 삭제합니다.
import json from '../resource/flightList';
export default function Main() {
// 항공편 검색 조건을 담고 있는 상태
const [condition, setCondition] = useState({
departure: 'ICN',
});
const [flightList, setFlightList] = useState(json);
// 주어진 검색 키워드에 따라 condition 상태를 변경시켜주는 함수
const search = ({ departure, destination }) => {
if (
condition.departure !== departure ||
condition.destination !== destination
) {
console.log('condition 상태를 변경시킵니다');
// TODO: search 함수가 전달 받아온 '항공편 검색 조건' 인자를 condition 상태에 적절하게 담아보세요.
}
};
const filterByCondition = (flight) => {
let pass = true;
if (condition.departure) {
pass = pass && flight.departure === condition.departure;
}
if (condition.destination) {
pass = pass && flight.destination === condition.destination;
}
return pass;
};
global.search = search; // 실행에는 전혀 지장이 없지만, 테스트를 위해 필요한 코드입니다. 이 코드는 지우지 마세요!
// TODO: Effeck Hook을 이용해 AJAX 요청을 보내보세요.
// TODO: 더불어, 네트워크 요청이 진행됨을 보여주는 로딩 컴포넌트(<LoadingIndicator/>)를 제공해보세요.
// useEffect(() => {
// }, [])
// TODO: 테스트 케이스의 지시에 따라 search 함수를 Search 컴포넌트로 내려주세요.
return (
<div>
<Head>
<title>States Airline</title>
<link rel="icon" href="/favicon.ico" />
</Head>
<main>
<h1>여행가고 싶을 땐, States Airline</h1>
<Search />
<div className="table">
<div className="row-header">
<div className="col">출발</div>
<div className="col">도착</div>
<div className="col">출발 시각</div>
<div className="col">도착 시각</div>
<div className="col"></div>
</div>
<FlightList list={flightList.filter(filterByCondition)} />
</div>
<div className="debug-area">
<Debug condition={condition} />
</div>
<img id="logo" alt="logo" src="codestates-logo.png" />
</main>
</div>
);
}
// Search.js
import { useState } from 'react';
function Search() {
const [textDestination, setTextDestination] = useState('');
const handleChange = (e) => {
setTextDestination(e.target.value.toUpperCase());
};
const handleKeyDown = (e) => {
if (e.type === 'keydown' && e.code === 'Enter') {
handleSearchClick();
}
};
const handleSearchClick = () => {
console.log('검색 버튼을 누르거나, 엔터를 치면 search 함수가 실행됩니다');
// TODO: 지시에 따라 상위 컴포넌트에서 props를 받아서 실행시켜 보세요.
};
return (
<fieldset>
<legend>공항 코드를 입력하고, 검색하세요</legend>
<span>출발지</span>
<input id='input-departure' type='text' disabled value='ICN'></input>
<span>도착지</span>
<input
id='input-destination'
type='text'
value={textDestination}
onChange={handleChange}
placeholder='CJU, BKK, PUS 중 하나를 입력하세요'
onKeyDown={handleKeyDown}
/>
<button id='search-btn' onClick={handleSearchClick}>
검색
</button>
</fieldset>
);
}
export default Search;
오늘은 Sprint를 진행할 때, 순서를 나열해서 순차적으로 풀어보겠다.
전체적으로 큰 컴포넌트 구현(Main.js) -> 작은 컴포넌트 구현(Search.js) 으로 진행한다.
순으로 진행하면 되겠다.
1. Main -> Search 상태 변화 함수를 props로 넘겨주기
return (
...
<main>
<h1>여행가고 싶을 땐, States Airline</h1>
✨ <Search onSearch={search} />
<div className="table">
<div className="row-header">
<div className="col">출발</div>
<div className="col">도착</div>
<div className="col">출발 시각</div>
<div className="col">도착 시각</div>
<div className="col"></div>
</div>
...
import { useState } from "react";
function Search({ onSearch }) { ✨
const [textDestination, setTextDestination] = useState("");
const handleChange = (e) => { ...
문제에서 onSearch로 전달하라고 주어졌으므로, 자식 컴포넌트인 Search 컴포넌트에게
onSearch로 상태 변화 함수인 search 함수(useState를 담고있음)를 전달한다.
그 onSearch props를 받아온 Search 컴포넌트는 중괄호로 묶어 다음과 같이 표현한다.
2. Main - search 함수가 전달 받아온 '항공편 검색 조건'
export default function Main() {
// 항공편 검색 조건을 담고 있는 상태
const [condition, setCondition] = useState({
departure: "ICN",
});
const [flightList, setFlightList] = useState(json);
// 주어진 검색 키워드에 따라 condition 상태를 변경시켜주는 함수
const search = ({ departure, destination }) => {
if (
condition.departure !== departure ||
condition.destination !== destination
) {
console.log("condition 상태를 변경시킵니다");
// TODO: search 함수가 전달 받아온 '항공편 검색 조건' 인자를 condition 상태에 적절하게 담아보세요.
✨ setCondition({ ...condition, departure, destination });
}
};
...
상태를 업데이트할 때는 새로운 객체를 생성하고, 기존 상태의 속성을 유지한 채로 변경해야 한다.
이를 위해 전개 연산자(...)를 사용하여 새로운 객체를 만들고, 필요한 속성을 변경한다.
따라서 condition은 State이므로 setCondition
함수에 condition 객체의 모든 속성을 새로운 객체에 복사한다.
이를 통해 기존 condition 객체의 속성을 유지할 수 있다.
그 후 새로운 상태 객체에 departure와 destination 속성을 추가한다.
이 값들은 search 함수의 매개변수로 전달된 출발지와 목적지 값이다.
이렇게 생성된 새로운 상태 객체는 setCondition 함수를 통해 condition 상태로 업데이트된다.
3. Search - handleSearchClick 함수 구현 (상위 컴포넌트에서 props를 받아서 실행)
import { useState } from "react";
function Search({ onSearch }) {
const [textDestination, setTextDestination] = useState("");
const handleChange = (e) => {
setTextDestination(e.target.value.toUpperCase());
};
const handleKeyDown = (e) => {
if (e.type === "keydown" && e.code === "Enter") {
handleSearchClick();
}
};
const handleSearchClick = () => {
console.log("검색 버튼을 누르거나, 엔터를 치면 search 함수가 실행됩니다");
// TODO: 지시에 따라 상위 컴포넌트에서 props를 받아서 실행시켜 보세요.
✨ onSearch({ departure: "ICN", destination: textDestination });
};
...
검색 버튼을 눌렀을 때나, 엔터를 치면 handleSearchClick 함수가 실행된다.
이제 이 함수 안에 props로 전달된 onSearch
함수를 넣어 가공해야 한다.
가공하는 방법은 우리가 입력 값이 무엇인지, 그리고 상태인지를 생각해보면 된다.
결론적으로, onSearch 함수에 { departure: "ICN", destination: textDestination } 객체를 전달하여 실행하면 된다.
이렇게 하면 검색 버튼을 클릭하거나 Enter 키를 눌렀을 때, Search 컴포넌트의 도착지 값과
함께 검색 조건을 상위 컴포넌트로 전달할 수 있다.
그럼 정상적으로 search 함수가 실행될 것이고, setCondition 함수를 통해 condition이 정상적으로 업데이트된다 !