컴포넌트는 어디까지나 "틀" 이다.
중요한 건 "틀" 이 아닌 "내용물" 이다.
데이터가 바로 그 "내용물" 이다.
데이터는 유기적으로 이동하며 사용자의 행동에 따라 바로바로 업데이트 되어야 한다.
그렇지 못한 데이터는 신선하지 않은 데이터 ( stale 데이터 ) 라고 불린다.
fetch : HTTP 요청을 수행하는 가장 유명한 방법이다.
fetch(uri)
.then((res) => res.json)
.then(console.log)
.catch(console.error);
JSON을 response하는 API는 JSON 데이터를 Response 객체의 body에 넣어주기 때문에 Response.json()
메서드를 호출해서 파싱해야 한다.
Response.json()
메서드는 promise
를 반환하므로 비동기 작업이다.
따라서 then
을 체이닝하여 사용할 수 있다.
POST : 데이터 생성
PUT : 데이터 변경
예제 코드
fetch(uri, {
method: "POST",
body: JSON.stringify({username, password})
})
파일을 업로드하려면 서버에게 하나 이상의 파일이 들어있다고 알려주기 위해 multipart-formdata 라는 HTTP 요청을 보내야 한다.
예제 코드
const formData = new FormData();
formData.append("avatar", img);
fetch(uri, {
method: "POST",
body: formData
});
예제 코드 ( 깃헙 권한 요청 예시 )
fetch(깃헙uri, {
method: "GET",
headers: {
Authorization: `Bearer ${token}`
}
})
리액트 컴포넌트에서 외부 데이터를 가져오려면 useState
와 useEffect
훅을 같이 사용해야 한다.
function GithubUser({ login }) {
const [data, setData] = useState();
useEffect(() => {
if (!login) return;
fetch(`https://api.github.com/users/${login}`)
.then((res) => res.json())
.then(setData)
.catch(console.error);
}, [login]);
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
<GithubUser login="ehdgus8054" />;
의존 관계 배열에 login 변수가 포함되어 있기 때문에
login 값이 변경되었을 시 useEffect
가 실행된다.
따라서 첫 부분에 login 값이 존재하는지 검사를 한다.
이 패턴은 자주 사용되는 듯하니 외워두자.
웹 API를 이용하면 브라우저에 데이터를 저장할 수 있다.
localStorage : 데이터를 직접 제거하지 않으면 무기한 보관한다.
sessionStorage : 탭이나 브라우저를 닫으면 데이터가 소멸된다.
브라우저 스토리지에 데이터를 저장하려면 문자열로 변환해야 하고 불러올 땐 JSON으로 파싱해야 한다.
// 불러오기 코드 예제
const loadJson = key => key && JSON.parse(localStorage.getItem(key));
// 저장 코드 예제
const saveJSON = (key, data) => localStorage.setItem(key, JSON.stringify(data));
웹 스토리지를 이용한 작업은 모두 동기적인작업 이기 때문에 조심해서 사용해야 한다.
캐시를 위해 웹 스토리지를 사용하고자 할 수 있는데 그럴 필요없다.
HTTP 요청에서 캐시를 처리하게 하면 된다.
헤더에 Cache-Control: max-age=시간
을 추가하면 된다.
HTTP 요청과 promise 에는 3가지 상태가 있다.
진행 중 ( pending )
성공 ( resolve )
실패 ( reject )
각각의 상태를 상태변수로 처리하자.
function 컴포넌트() {
const [data, setData] = useState();
const [error, setError] = useState();
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!login) return;
setLoding(true); // 로딩 시작
fetch(uri)
.then((data) => data.json())
.then(setData) // 데이터를 상태변수에 저장
.catch(setError) // 에러를 상태변수에 저장
.finally(() => setLoading(false)); // 성공이든 실패든 로딩 끝
}, [login]);
return loading ? (
<h1>Loading</h1>
) : error ? (
<pre>{JSON.stringify(error, null, 2)}</pre>
) : (
data && <pre>{JSON.stringify(data, null, 2)}</pre>
);
}
3가지 상태를 모두 처리하려면 코드가 약간 커지지만 필수적인 과정이다.
render props는 렌더링되는 프로퍼티를 뜻한다.
render porps는 비동기 컴포넌트의 재사용을 극대화하고 싶을 때 사용한다.
이 패턴을 사용하면 지루한 준비코드나 복잡한 작동방식을 추상화해주는 컴포넌트를 만들 수 있다.
function List({
data = [],
renderItem = (f) => f,
renderEmpty = <p>empty</p>
}) {
return !data.length ? (
renderEmpty
) : (
<ul>
{data.map((item, i) => (
<li key={i}>{renderItem(item)}</li>
))}
</ul>
);
}
위의 추상 컴포넌트는 장담점이 있다.
<ul>
을 렌더링할 때 재사용할 수 있는 컴포넌트가 있다.한 번에 모든 데이터를 렌더링하는건 매우 비효율적이다.
사용자가 볼 수 있는 데이터는 화면에 보이는 데이터뿐이기 때문이다.
따라서 사용자 화면을 포함하여 위 아래로 어느정도까지만 렌더링하고 그 너머의 데이터는 렌더링 하지 않는것이 좋다.
만약 스크롤의 위치를 위아래로 움직이면 이전의 화면 데이터를 마운트 해제한다.
이러한 기법을 windowing 이나 virtualization이라 한다.
지원 패키지는 아래와 같다.
테스트를 위한 데이터가 많이 필요할 땐
faker
패키지를 이용한다.
import { FixedSizeList } from "react-window";
const bigList = [...Array(100)];
<FixedSizeList
height={window.innerHeight}
width={window.innerWidth}
itemCount={bigList.length}
itemSize={50}
>
{(index, style) => <p>{index}</p>}{/* 함수 render props 이다 */}
</FixedSizeList>;
render porps를 FixedSizeList의 childrend 프로퍼티로 전달한다.
이 render porps는 함수 render porps이고 index와 style이 인자로 전달된다.
자주 사용되는 패턴이니 익숙해져야 한다.
function useFetch(uri) {
const [data, setData] = useState();
const [error, setError] = useState();
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!uri) return;
fetch(uri)
.then((data) => data.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [uri]);
return { loading, data, error };
}
이 훅을 컴포넌트 안에서 사용해보자.
function GithubUser({ login }) {
const {loading, data, error } = useFetch(`https://api.github.com/users/${login}`);
if(loading) return <h1>Loading</h1>
if(error) return <pre>{JSON.stringify(error, null, 2)}</pre>
return <pre>{JSON.stringify(data, null, 2)}</pre>
일반적으로 Hook을 사용하면 여러 컴포넌트에서 재사용할 수 있다.
컴포넌트를 렌더링할 때 완전히 똑같은 패턴을 반복하는 경우가 있다.
예를 들어 앱 전체에서 fetch 요청이 진행중일 때 표시되는 스피너가 ( loading spinner ) 모두 같을 수 있다.
한 애플리케이션 내에 있는 모든 fetch 요청에서 오류를 처리하는 방식도 일관성이 있을 것이다.
이럴 땐 차라리 같은 코드를 반복하는 대신에 하나의 컴포넌트로 묶어서 처리할 수 있다.
function Fetch({
uri,
renderSuccess = (f) => f,
renderError = (error) => <pre>{JSON.stringify(error, null, 2)}</pre>,
loadingFallback = <p>Loading</p>
}) {
const { loading, data, error } = useFetch(uri);
if (loading) return loadingFallback;
if (error) return renderError(error);
return renderSuccess({ data });
}
function GithubUser({ login }) {
return (
<Fetch
uri={`https://api.github.com/users/${login}`}
renderSuccess={UserDetails}
/>
);
}
function UserDetails({ data }) {
return <pre>{JSON.stringify(data, null, 2)}</pre>;
}
재사용 가능한 로직을 컴포넌트와 Hook으로 추상화하여 없앰으로써 복잡도를 감소시킨다.
HTTP 요청을 여러번 수행해야 할 때가 있다.
요청들을 배열에 집어넣고 차례로 요청한다고 할 때
배열을 하나씩 이터레이션할 수 있게 하는 훅을 만들어보자.
const useIterator = (items = [], initialIndex = 0) => {
const [i, setIndex] = useState(initialIndex);
const prev = () => {
if (i === 0) return setIdex(items.length - 1);
setIndex(i - 1);
};
const next = () => {
if (i === items.length - 1) return setIndex(0);
setIndex(i + 1);
};
return [items[i], prev, next];
};
const useIterator = (items = [], initialIndex = 0) => {
const [i, setIndex] = useState(initialIndex);
const prev = useCallback(() => {
if (i === 0) return setIdex(items.length - 1);
setIndex(i - 1);
}, [i]);
// 함수 안에서 사용하는 상태가 있다면 꼭, 의존 관계 배열안에 포함시켜야 된다
const next = useCallback(() => {
if (i === items.length - 1) return setIndex(0);
setIndex(i + 1);
}, [i]);
const item = useMemo(() => items[i], [i]);
return [item || items[0], prev, next];
};
값을 메모화하여 useIterator 커스텀 훅을 사용하면 메모화한 값이 항상 같은 객체와 함수를 가리킨다.
한 요청이 끝나고 나서야 다음 요청이 시작되는 걸 폭포수 요청이라 한다.
개발자 도구의 네트워크 탭에서 조정 가능하다.
"Fast 3G"나 "Slow 3G"를 선택하면 네트워크 속도가 상당히 제한된다.
추가로 HTTP 요청의 타임라인도 보여준다.
XHR필터를 고르면 fetch
요청만 따로 살펴볼 수 있다.
function MyFetch(){
const [login, setLogin] = useState("ehdgus8054");
return (
<>
<SearchForm value={login} onSearch={setLogin} />
<GithubUser login={login} />
<UserRepositories login={login} />
<RepositoryReadme login={login} />
</>
);
}
위의 컴포넌트들은 모두 깃허브에 데이터를 얻기위한 fetch
요청을 보낸다.
같은 레벨에서 이들을 렌더링하면 이 모든 요청이 동시에 병렬적으로 이뤄진다.
개발자 도구의 네트워크 탭에서 HTTP 요청 타임라인을 통해 확인할 수 있다.
최초에 어떤 데이터를 렌더링 해야할 지 추측하기 어려울 때가 있다.
이럴 경우에는 필요한 데이터가 준비되기 전까지 컴포넌트를 렌더링하지 않을 수도 있다.
<>
{login && <SearchForm value={login} onSearch={setLogin} />}
{login && <GithubUser login={login} />}
{login && <UserRepositories login={login} />}
{login && <RepositoryReadme login={login} />}
</>
느린 네트워크상에서 fetch
요청을 처리하는 도중에 위의 컴포넌트들이 마운트 해제가 된다면 fetch
요청에 대한 응답을 처리할 곳이 사라진다.
만약 요청에 대한 응답으로 상태를 변경하는 코드가 있다면
마운트가 되어있지 않은 컴포넌트의 상태값을 변경하는 꼴이 된다.
이 경우 오류가 발생한다.
이를 해결하기 위해 현재 컴포넌트가 마운트되어 있는지 알려주는 훅을 만들어 사용한다.
fucntion useMountedRef(){
const mounted = useRef(false);
useEffect(() => {
mounted.current = true;
return () => mounted.current = false;
})
return mounted;
}
컴포넌트가 마운트 되면 useEffect
가 최초 실행되며 mounted.current
가 true
로 설정되고
컴포넌트가 언마운트 되면 useEffect
의 리턴문이 실행되며
mounted.current
가 false
로 설정된다.
이 Hook을 컴포넌트 안에서 사용해보자.
function MyFetch(){
const mounted = useMountedRef();
const loadReadme = useCallback(async(login, repo) => {
setLoading(true);
const uri = `https://api.github.com/users/${login}`;
const {download_url} = await fetch(uri).then(res=>res.json());
const markdown = await fetch(download_url).then(res=>res.text());
if(mounted.current){ // 컴포넌트가 마운트되어 있다면 상태를 변경한다.
setMarkdown(markdown);
setLoading(false);
}
},[]);
}
useRef Hook 은 DOM 을 선택하는 용도 외에도, 다른 용도가 한가지 더 있다. 바로, 컴포넌트 안에서 조회 및 수정 할 수 있는 변수를 관리하는 것 이다.
useRef로 만들어진 객체는 React가 만든 전역 저장소에 저장되기 때문에 함수를 재 호출하더라도 마지막으로 업데이트한 current 값이 유지됩니다.