React 입문(b)
React 기초 개념
웹 개발을 위한 React 기초 학습
JSX에서의 인라인 핸들러
React의 새로운 기본 구성 요소인 인라인 핸들러(inline handler)에 대해 배우게 됩니다. 동시에 리스트에서 아이템을 제거하는 기능을 구현해 볼 것입니다. 더 깊이 들어가기 전에, 인라인 핸들러에 대한 사전 지식 없이도 해결할 수 있으므로 이 작업을 독립적으로 시도해 보시기 바랍니다. 단계별 지침이 아래에 제공됩니다.
목표: 애플리케이션은 아이템 리스트를 렌더링하고 사용자가 검색 기능을 통해 리스트를 필터링할 수 있게 합니다. 이제 애플리케이션은 각 리스트 아이템 옆에 사용자가 리스트에서 해당 아이템을 제거할 수 있는 버튼을 렌더링해야 합니다.
- 리스트 아이템을 나중에 조작(예: 아이템 제거)하려면
useState를 사용하여 상태 값(여기서는 상태 배열)으로 만들어야 함 - 모든 리스트 아이템은 클릭 핸들러가 있는 버튼을 렌더링
- 버튼을 클릭하면 상태를 조작하여 리스트에서 아이템을 제거함
- 상태가 있는 리스트는
App컴포넌트에 있으므로,Item컴포넌트가 식별자를 통해 아이템을 제거하기 위해App컴포넌트와 소통할 수 있도록 콜백 핸들러를 사용해야 함
이제 이 기능을 단계별로 구현하는 방법을 확인해 보겠습니다. 현재 App 컴포넌트에 있는 아이템 리스트(여기서는 stories)는 상태가 없는 변수입니다. 검색 기능을 통해 렌더링 된 리스트를 필터링할 수 있지만, 리스트 자체는 그대로 유지됩니다. 필터링 된 리스트는 제3자(여기서는 searchTerm)를 통해 파생된 상태일 뿐이며, 아직 실제 리스트를 조작하지는 않습니다. 리스트에 대한 제어권을 얻으려면 React의 useState 훅의 초기 상태로 사용하여 이를 상태로 만드세요. 배열에서 반환된 값은 현재 상태(stories)와 상태 업데이트 함수(setStories)입니다.
// src/App.jsx
const initialStories = [
{
title: 'React',
url: 'https://react.dev/',
author: 'Jordan Walke',
num_comments: 3,
points: 4,
objectID: 0,
},
{
title: 'Redux',
url: 'https://redux.js.org/',
author: 'Dan Abramov, Andrew Clark',
num_comments: 2,
points: 5,
objectID: 1,
},
];
const App = () => {
const [searchTerm, setSearchTerm] = useStorageState('search', 'React');
const [stories, setStories] = React.useState(initialStories);
// ...
};React의 useState 훅에서 상태 리스트로 반환된 stories가 여전히 searchedStories로 필터링되어 List 컴포넌트에 표시되므로 애플리케이션은 동일하게 동작합니다. 단지 stories가 오는 출처만 변경되었습니다. 하지만 우리는 아직 stories를 수정하고 있지 않습니다.
다음으로, 리스트에서 아이템을 제거하는 이벤트 핸들러를 작성하겠습니다.
// src/App.jsx
const App = () => {
const [stories, setStories] = React.useState(initialStories);
const handleRemoveStory = (item) => {
const newStories = stories.filter(
(story) => item.objectID !== story.objectID
);
setStories(newStories);
};
return (
<div>
<h1>My React Stories</h1>
{/* ... */}
<hr />
<List list={searchedStories} onRemoveItem={handleRemoveStory} />
</div>
);
};App 컴포넌트의 콜백 핸들러(결국 List/Item 컴포넌트에서 사용될)는 리스트에서 제거해야 할 아이템을 인자로 받습니다. 이 정보를 바탕으로, 함수는 조건에 맞지 않는 모든 아이템을 제거하여 현재의 stories를 필터링합니다. 원하는 아이템(story)이 제거된 반환된 이야기들은 새로운 상태로 설정되어 List 컴포넌트로 전달됩니다. 새로운 상태가 설정되었으므로 App 컴포넌트와 그 아래의 모든 컴포넌트(예: List/Item 컴포넌트)가 다시 렌더링 되어 stories의 새로운 상태를 표시합니다.
하지만 List/Item 컴포넌트가 App 컴포넌트의 상태를 수정하는 이 새로운 기능을 어떻게 사용하는지가 빠져 있습니다. List 컴포넌트 자체는 이 새로운 콜백 핸들러를 사용하지 않고 Item 컴포넌트로 전달하기만 합니다.
마지막으로 Item 컴포넌트는 들어오는 콜백 핸들러를 새로운 핸들러의 함수로 사용합니다. 이 핸들러에서 우리는 특정 아이템을 전달할 것입니다. 또한 실제 이벤트를 트리거하기 위해 추가적인 버튼 엘리먼트가 필요합니다.
// src/App.jsx
const Item = ({ item, onRemoveItem }) => {
const handleRemoveItem = () => {
onRemoveItem(item);
};
return (
<li>
<span>
<a href={item.url}>{item.title}</a>
</span>
<span>{item.author}</span>
<span>{item.num_comments}</span>
<span>{item.points}</span>
<span>
<button type="button" onClick={handleRemoveItem}>
Dismiss
</button>
</span>
</li>
);
};지금까지 우리는 React의 useState 훅으로 이야기 목록을 상태로 만들었고, 여전히 검색된 이야기들을 Props로 List 컴포넌트에 전달했으며, 버튼을 클릭하여 이야기를 제거하기 위해 각 컴포넌트에서 사용될 콜백 핸들러(handleRemoveStory)와 핸들러(handleRemoveItem)를 구현했습니다. 이 기능을 구현하기 위해 우리는 이전에 배웠던 state, props, 핸들러, 콜백 핸들러 등 많은 교훈을 적용했습니다. 기능은 작동하며, 여러분도 스스로 동일하거나 유사한 해결책에 도달했을 수 있습니다.
이제 인라인 핸들러(inline handlers)라는 주제로 들어가 보겠습니다. 들어오는 onRemoveItem 콜백 핸들러를 실행하기 위해 Item 컴포넌트에 handleRemoveItem이라는 추가적인 핸들러를 도입해야 했다는 것을 눈치채셨을 것입니다. 콜백 핸들러의 인자로 아이템을 가져오기 위해 이 추가적인 이벤트 핸들러를 도입해야 했습니다.
하지만 더 우아하게 만들고 싶다면, Item 컴포넌트의 콜백 핸들러 함수를 JSX 내에서 바로 실행할 수 있게 해주는 인라인 핸들러를 사용할 수 있습니다. Item 컴포넌트에서 들어오는 onRemoveItem 함수를 인라인 핸들러로 사용하는 두 가지 해결책이 있습니다.
첫 번째는 자바스크립트의 bind 메서드를 사용하는 것입니다.
함수에 자바스크립트 bind 메서드를 사용하면 실행 시 사용될 인자를 해당 함수에 직접 바인딩할 수 있습니다. bind 메서드는 바인딩된 인자가 부착된 새로운 함수를 반환합니다.
대조적으로, 두 번째이자 더 인기 있는 해결책은 인라인 화살표 함수(inline arrow function)를 사용하는 것입니다. 이를 통해 item과 같은 인자를 몰래 넣을 수 있습니다.
인라인 핸들러를 사용하는 것이 일반 이벤트 핸들러를 사용하는 것보다 더 간결하지만, 자바스크립트 로직이 JSX 안에 숨겨질 수 있기 때문에 디버깅하기가 더 어려울 수 있습니다. 만약 인라인 화살표 함수가 간결한 본문 대신 블록 본문을 사용하여 한 줄 이상의 구현 로직을 캡슐화한다면 더욱 장황해질 것입니다.
중요한 구현 세부 사항을 가리지 않는다면 인라인 핸들러를 사용해도 괜찮습니다. 만약 인라인 핸들러가 한 줄 이상의 코드를 실행하기 위해 블록 본문을 사용해야 한다면, 일반 이벤트 핸들러로 추출해야 할 때입니다. 결국, 이 경우 모든 핸들러 버전이 읽기 쉽고 허용 가능합니다.
React 비동기 데이터
우리 애플리케이션에는 두 가지 상호작용이 있습니다. 리스트 검색과 리스트 아이템 제거입니다. 첫 번째 상호작용은 리스트에 적용되는 제3자 상태(searchTerm)를 통한 유동적인 수정인 반면, 두 번째 상호작용은 리스트에서 아이템을 되돌릴 수 없이 삭제하는 것입니다.
하지만 우리가 다루고 있는 리스트는 여전히 샘플 데이터일 뿐입니다. 실제 데이터를 다루도록 애플리케이션을 준비하는 것은 어떨까요? 보통 클라이언트 사이드 애플리케이션(React 같은)의 경우 원격 백엔드/데이터베이스의 데이터는 비동기적으로 도착합니다. 따라서 데이터 가져오기를 시작하기 전에 컴포넌트를 먼저 렌더링해야 하는 경우가 많습니다.
다음 과정에서는 샘플 데이터를 사용하여 이러한 종류의 비동기 데이터를 시뮬레이션하는 것부터 시작할 것입니다. 나중에는 샘플 데이터를 실제 원격 API에서 가져온 실제 데이터로 대체할 것입니다. 해결(resolve)되면 데이터를 포함한 프로미스(promise)를 반환하는 함수의 단축 버전으로 시작해 보겠습니다. 해결된 객체는 이전의 이야기 목록을 담고 있습니다.
App 컴포넌트에서 initialStories를 사용하는 대신, 초기 상태로 빈 배열을 사용하세요. 우리는 빈 이야기 목록으로 시작하여 비동기적으로 이 이야기들을 가져오는 것을 시뮬레이션하고 싶습니다. 새로운 useEffect 훅에서 함수를 호출하고 반환된 프로미스를 사이드 이펙트로 해결합니다. 빈 의존성 배열로 인해 사이드 이펙트는 컴포넌트가 처음 렌더링 될 때 한 번만 실행됩니다.
애플리케이션을 시작하면 데이터가 비동기적으로 도착해야 함에도 불구하고, 즉시 렌더링 되므로 동기적으로 도착하는 것처럼 보입니다. 원격 API에 대한 모든 네트워크 요청에는 지연이 발생하므로, 약간의 현실적인 지연을 주어 이를 변경해 봅시다.
먼저, 프로미스의 단축 버전을 제거합니다.
둘째, 프로미스를 해결할 때 2초 동안 지연시킵니다.
애플리케이션을 다시 시작하면 리스트 렌더링이 지연되는 것을 볼 수 있습니다. stories의 초기 상태는 빈 배열이므로 List 컴포넌트에는 아무것도 렌더링 되지 않습니다. App 컴포넌트가 렌더링 된 후, 사이드 이펙트 훅이 한 번 실행되어 비동기 데이터를 가져옵니다. 프로미스를 해결하고 컴포넌트의 상태에 데이터를 설정한 후, 컴포넌트가 다시 렌더링 되고 비동기적으로 로드된 이야기 목록이 표시됩니다.
React에서 비동기 데이터로 가는 첫 번째 디딤돌일 뿐이었습니다. 처음부터 데이터가 있는 대신, 프로미스에서 데이터를 비동기적으로 해결했습니다. 하지만 우리는 이야기를 동기식에서 비동기식 데이터로 옮겼을 뿐입니다. 여전히 샘플 데이터이며, 결국에는 실제 데이터를 가져오는 방법을 배우게 될 것입니다.
React 조건부 렌더링
최근 도입된 비동기 데이터 처리와 관련된 새로운 기능을 소개하겠습니다. 실제 애플리케이션에서 사용자는 데이터가 로드되는 동안 로딩 스피너와 같은 피드백을 받습니다. 목표는 이 피드백 메커니즘을 구현하는 것입니다. 독립적으로 구현을 시도해 보고, 나중에 책을 참고하여 해결책을 비교해 보세요.
목표: 프로미스에서 샘플 데이터를 로드하는 데 시간이 좀 걸립니다. 이 시간 동안 사용자에게 가장 단순한 형태의 로딩 표시기(예: “Loading …”이라는 텍스트)를 보여주어야 합니다. 데이터가 비동기적으로 도착하면 로딩 표시기를 숨깁니다.
- 로딩 표시기를 보여주려면 새로운 상태 값을 도입해야 합니다.
isLoading이라는 불리언(boolean)이 가장 좋은 해결책일 수 있음 - 데이터를 로드하는 사이드 이펙트가 시작될 때 상태 불리언을
true로 설정하세요. 데이터가 로드되면 상태 불리언을 다시false로 설정함 - JSX에서
isLoading불리언이true로 설정되었을 때 조건부로 “Loading …” 텍스트를 보여야함
React에서 조건부 렌더링(conditional rendering)은 정보(예: state, props)에 따라 다른 JSX를 렌더링해야 할 때 항상 발생합니다. 비동기 데이터를 다루는 것은 조건부 렌더링을 활용하기 좋은 사용 사례입니다. 예를 들어 애플리케이션이 처음 초기화될 때는 시작할 데이터가 없습니다. 다음으로 데이터를 로드하고, 결국 데이터를 확보하여 표시합니다. 때로는 데이터 가져오기가 실패하여 오류를 받기도 합니다. 개발자인 우리가 다루어야 할 것들이 많습니다.
다행히 몇 가지 측면은 이미 처리되었습니다. 예를 들어, 초기 상태가 null 대신 빈 리스트 []이므로, 이 리스트를 필터링하거나 매핑할 때 애플리케이션이 깨질 우려가 완화되었습니다. 하지만 여전히 주의가 필요한 부분들이 있습니다. 보류 중인 데이터 요청에 대해 사용자에게 피드백을 제공하는 로딩 상태가 없다는 점을 고려해 보세요. 새로운 상태 값을 도입하여 이를 해결할 수 있으며, 데이터가 fetching 되는 동안 상태를 적절히 설정할 수 있습니다.
// src/App.jsx
const App = () => {
// ...
const [stories, setStories] = React.useState([]);
const [isLoading, setIsLoading] = React.useState(false);
React.useEffect(() => {
setIsLoading(true);
getAsyncStories().then((result) => {
setStories(result.data.stories);
setIsLoading(false);
});
}, []);
// ...
};이제 불리언 값이 적절하게 토글되어야 합니다. 빠진 것은 사용자에게 로딩 표시기를 보여주는 것입니다. 간단한 접근 방식은 App 컴포넌트에서 조기 반환(early return)을 사용하는 것입니다.
하지만 이 방식은 로딩 표시기만 렌더링 되고 다른 것은 아무것도 렌더링 되지 않습니다. 대신, 우리는 로딩 표시기나 List 컴포넌트 중 하나를 보여주기 위해 로딩 표시기를 JSX 내에 인라인 하고 싶습니다. JSX 내에 인라인 된 if-else 문을 사용하는 것은 권장되지 않습니다(JSX의 제한 사항 때문입니다). 대신 삼항 연산자(ternary operator)를 사용하여 JSX에서 조건부 렌더링을 생성할 수 있습니다.
이걸로 끝입니다. 상태 불리언에 따라 로딩 표시기나 List 컴포넌트를 조건부로 렌더링하고 있습니다.
비동기 데이터에 대한 에러 처리도 구현해 봅시다. 시뮬레이션 환경에서는 에러가 발생하지 않지만, 원격 API에서 데이터를 가져오기 시작하면 에러가 발생할 수 있습니다. 따라서 에러 처리를 위한 또 다른 상태를 도입하고 프로미스를 해결할 때 catch() 블록에서 이를 처리하세요.
// src/App.jsx
const App = () => {
// ...
const [stories, setStories] = React.useState([]);
const [isLoading, setIsLoading] = React.useState(false);
const [isError, setIsError] = React.useState(false);
React.useEffect(() => {
setIsLoading(true);
getAsyncStories()
.then((result) => {
setStories(result.data.stories);
setIsLoading(false);
})
.catch(() => setIsError(true));
}, []);
// ...
};다음으로, 문제가 발생했을 때 또 다른 조건부 렌더링으로 사용자에게 피드백을 줍니다. 이번에는 무언가를 렌더링 하거나 아무것도 렌더링 하지 않는 경우입니다. 따라서 한쪽이 null을 반환하는 삼항 연산자 대신, 논리 AND 연산자(&&)를 단축 표현으로 사용하세요.
자바스크립트에서 true && 'Hello World'는 항상 'Hello World'로 평가됩니다. false && 'Hello World'는 항상 false로 평가됩니다. React에서는 이 동작을 유리하게 사용할 수 있습니다. 조건이 참이면 논리 AND 연산자 뒤의 표현식이 출력이 됩니다. 조건이 거짓이면 React는 이를 무시하고 표현식을 건너뜁니다. expression && JSX를 사용하는 것이 expression ? JSX : null을 사용하는 것보다 더 간결합니다.
조건부 렌더링은 비동기 데이터만을 위한 것은 아닙니다. 조건부 렌더링의 가장 간단한 예제는 버튼으로 토글되는 불리언 상태입니다. 불리언 플래그가 참이면 무언가를 렌더링하고, 거짓이면 아무것도 렌더링 하지 않습니다. React의 이 기능에 대해 아는 것은 매우 강력할 수 있는데, JSX를 조건부로 렌더링 할 수 있는 능력을 제공하기 때문입니다. UI를 더 동적으로 만드는 React의 또 다른 도구입니다. 그리고 우리가 발견했듯이, 비동기 데이터와 같은 더 복잡한 제어 흐름에 종종 필요합니다.
React 고급 State
이 애플리케이션의 모든 상태 관리는 React의 useState 훅을 많이 사용합니다. 반면, React의 useReducer 훅은 복잡한 상태 구조와 전환(transitions)을 위해 더 정교한 상태 관리를 사용할 수 있게 해 줍니다. 자바스크립트의 리듀서에 대한 지식은 커뮤니티를 반으로 나누기 때문에 여기서는 기초를 다루지 않겠습니다. 하지만 리듀서에 대해 들어본 적이 없다면 자바스크립트 리듀서 가이드를 확인해 보세요.
상태 값이 있는 stories를 React의 useState 훅에서 React의 useReducer 훅으로 이동시킬 것입니다. useReducer를 useState 대신 사용하는 것은 여러 상태 값이 서로 의존하거나 하나의 도메인과 관련이 있을 때 합리적입니다. 예를 들어 stories, isLoading, error는 모두 데이터 가져오기와 관련이 있습니다. 더 추상적인 버전에서는 이 세 가지가 모두 리듀서에 의해 관리되는 복잡한 객체(예: data, isLoading, error 속성을 가진 객체)의 속성이 될 수 있습니다. stories와 그 상태 전환을 리듀서에서 관리하는 것부터 시작할 것입니다.
먼저 컴포넌트 외부에 리듀서 함수를 도입합니다. 리듀서 함수는 항상 state와 action을 받습니다. 이 두 인자를 바탕으로 리듀서는 항상 새로운 state를 반환합니다.
리듀서 액션은 항상 type과 연관되며, 모범 사례로 payload와 함께 사용됩니다. type이 리듀서의 조건과 일치하면 들어오는 state와 action을 기반으로 새로운 상태를 반환합니다. 리듀서에 의해 커버되지 않는다면 구현이 커버되지 않았음을 상기시키기 위해 에러를 던집니다. storiesReducer 함수는 하나의 타입을 커버하고, 현재 상태를 사용하여 새 상태를 계산하지 않고 들어오는 액션의 페이로드를 반환합니다. 따라서 새로운 상태는 단순히 페이로드입니다.
App 컴포넌트에서 stories 관리를 위해 useState를 useReducer로 교체하세요. 새로운 훅은 리듀서 함수와 초기 상태를 인자로 받고, 두 개의 항목이 있는 배열을 반환합니다. 첫 번째 항목은 현재 상태이고 두 번째 항목은 상태 업데이트 함수(또는 디스패치 함수, dispatch function)입니다.
새로운 디스패치 함수는 이전에 useState에서 반환된 setStories 함수 대신 사용할 수 있습니다. useState의 상태 업데이트 함수로 상태를 명시적으로 설정하는 대신, useReducer의 상태 업데이트 함수는 리듀서를 위한 액션을 디스패치(dispatch)하여 상태를 암시적으로 설정합니다. 액션은 type과 선택적인 payload와 함께 옵니다.
// src/App.jsx
const App = () => {
// ...
React.useEffect(() => {
setIsLoading(true);
getAsyncStories()
.then((result) => {
dispatchStories({
type: 'SET_STORIES',
payload: result.data.stories,
});
setIsLoading(false);
})
.catch(() => setIsError(true));
}, []);
const handleRemoveStory = (item) => {
const newStories = stories.filter(
(story) => item.objectID !== story.objectID
);
dispatchStories({
type: 'SET_STORIES',
payload: newStories,
});
};
// ...
};애플리케이션은 브라우저에서 동일하게 보이지만, 이제는 리듀서와 React의 useReducer 훅이 stories의 상태를 관리하고 있습니다. 하나 이상의 상태 전환을 처리하여 리듀서의 개념을 최소 버전으로 가져와 봅시다. 상태 전환이 하나뿐이라면 리듀서는 의미가 없습니다.
지금까지는 handleRemoveStory 핸들러가 새로운 이야기들을 계산했습니다. 이 로직을 리듀서 함수로 옮기고 액션으로 리듀서를 관리하는 것이 타당하며, 이는 명령형 프로그래밍에서 선언형 프로그래밍으로 이동하는 또 다른 사례입니다. 우리가 어떻게 해야 하는지 말하는 대신, 리듀서에게 무엇을 해야 할지 말하는 것입니다. 그 외의 모든 것은 리듀서 안에 숨겨져 있습니다.
이제 리듀서 함수는 새로운 조건부 상태 전환에서 이 새로운 케이스를 커버해야 합니다. 이야기 제거 조건이 충족되면 리듀서는 이야기를 제거하는 데 필요한 모든 구현 세부 사항을 갖게 됩니다. 액션은 현재 상태에서 이야기를 제거하고 필터링 된 새로운 이야기 목록을 상태로 반환하는 데 필요한 모든 정보(여기서는 아이템의 식별자)를 제공합니다.
이 모든 if-else 문들은 하나의 리듀서 함수에 더 많은 상태 전환을 추가할수록 결국 복잡해질 것입니다. 이를 모든 상태 전환에 대해 switch 문으로 리팩터링하는 것이 더 가독성이 좋으며 React 커뮤니티에서 모범 사례로 여겨집니다.
우리가 다룬 것은 자바스크립트 리듀서의 최소 버전과 React의 useReducer 훅을 사용한 활용법입니다. 리듀서는 두 가지 상태 전환을 커버하고, 현재 상태와 액션을 계산하여 새로운 상태를 만드는 방법을 보여주며, 상태 전환을 위해 일부 비즈니스 로직(이야기 제거)을 사용합니다. 이제 우리는 하나의 상태 관리 리듀서와 연관된 useReducer 훅만으로 비동기적으로 도착하는 데이터에 대한 이야기 목록을 상태로 설정하고, 이야기 목록에서 이야기를 제거할 수 있습니다.
React 불가능한 상태
여러 개의 React useState 훅을 사용할 때 App 컴포넌트 내의 단일 상태들 사이의 단절을 눈치챘을 수도 있습니다. 기술적으로 비동기 데이터와 관련된 모든 상태는 함께 속합니다. 여기에는 실제 데이터로서의 이야기뿐만 아니라 로딩 및 에러 상태도 포함됩니다. 이것이 바로 도메인 관련 상태를 관리하기 위해 하나의 리듀서와 React의 useReducer 훅이 등장하는 이유입니다.
하지만 왜 신경 써야 할까요? 하나의 React 컴포넌트에 여러 개의 useState 훅이 있는 것은 잘못된 것이 아닙니다. 하지만 여러 상태 업데이트 함수가 연달아 있는 것을 보면 주의해야 합니다. 이러한 조건부 상태들은 불가능한 상태(impossible states)와 UI에서의 원치 않는 동작으로 이어질 수 있습니다.
에러를 시뮬레이션하고 에러 처리를 구현하기 위해 가짜 데이터 가져오기 함수를 다음 구현으로 변경해 보세요.
불가능한 상태는 비동기 데이터에 에러가 발생할 때 일어납니다. 에러에 대한 상태는 설정되지만, 로딩 표시기에 대한 상태는 취소되지 않습니다. UI에서는 에러 메시지만 보여주고 로딩 표시기는 숨기는 것이 더 좋겠지만, 무한 로딩 표시기와 에러 메시지가 함께 나타나게 됩니다. 불가능한 상태는 발견하기 쉽지 않아 UI 버그를 유발하는 것으로 악명이 높습니다.
이 버그를 직접 고쳐볼 수도 있습니다. 다행히 우리는 함께 속하는 상태들을 여러 useState(및 useReducer) 훅에서 하나의 useReducer 훅으로 옮김으로써 통합된 상태 관리를 통해 이러한 버그를 다룰 가능성을 높일 수 있습니다.
React의 useState 훅에서 나온 상태 업데이트 함수를 더 이상 사용할 수 없으므로, 비동기 데이터 가져오기와 관련된 모든 것은 상태 전환을 위해 새로운 디스패치 함수를 사용해야 합니다. 가장 간단한 접근 방식은 상태 업데이트 함수를 디스패치 함수로 교체하는 것입니다. 그러면 디스패치 함수는 고유한 type과 payload를 받습니다. 후자는 상태 업데이트 함수로 상태를 업데이트할 때 사용했을 것과 동일한 페이로드입니다.
// src/App.jsx
const App = () => {
// ...
React.useEffect(() => {
dispatchStories({ type: 'STORIES_FETCH_INIT' });
getAsyncStories()
.then((result) => {
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: result.data.stories,
});
})
.catch(() =>
dispatchStories({ type: 'STORIES_FETCH_FAILURE' })
);
}, []);
// ...
};리듀서 함수에 대해 두 가지를 변경했습니다. 첫째, 외부에서 디스패치 함수를 호출할 때 새로운 타입들을 도입했습니다. 따라서 상태 전환을 위한 새로운 케이스들을 추가해야 합니다. 둘째, 상태 구조를 배열에서 복잡한 객체로 변경했습니다. 따라서 들어오는 상태와 반환되는 상태로서 새로운 복잡한 객체를 고려해야 합니다.
// src/App.jsx
const storiesReducer = (state, action) => {
switch (action.type) {
case 'STORIES_FETCH_INIT':
return {
...state,
isLoading: true,
isError: false,
};
case 'STORIES_FETCH_SUCCESS':
return {
...state,
isLoading: false,
isError: false,
data: action.payload,
};
case 'STORIES_FETCH_FAILURE':
return {
...state,
isLoading: false,
isError: true,
};
case 'REMOVE_STORY':
return {
...state,
data: state.data.filter(
(story) => action.payload.objectID !== story.objectID
),
};
default:
throw new Error();
}
};모든 상태 전환에 대해, 우리는 현재 상태 객체의 모든 키/값 쌍(자바스크립트 전개 연산자를 통해)과 새로운 덮어쓰기 속성들을 포함하는 새로운 상태 객체를 반환합니다. 예를 들어, STORIES_FETCH_FAILURE는 isLoading 불리언을 false로 설정하고 isError 불리언을 true로 설정하면서, 다른 모든 상태(예: data인 stories)는 그대로 유지합니다. 이것이 에러 발생 시 로딩 불리언을 false로 설정해야 하므로 이전에 불가능한 상태로 인해 발생했던 버그를 피하는 방법입니다.
REMOVE_STORY 액션도 어떻게 변경되었는지 관찰하세요. 이제는 일반 상태가 아닌 state.data에서 작동합니다. 상태는 단순히 이야기 목록이 아니라 데이터, 로딩, 에러 상태를 가진 복잡한 객체입니다. 이는 나머지 코드에서 상태를 배열이 아닌 객체로 주소 지정하여 해결해야 합니다.
// src/App.jsx
const App = () => {
// ...
const searchedStories = stories.data.filter((story) =>
story.title.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<div>
{/* ... */}
{stories.isError && <p>Something went wrong ...</p>}
{stories.isLoading ? (
<p>Loading ...</p>
) : (
<List
list={searchedStories}
onRemoveItem={handleRemoveStory}
/>
)}
</div>
);
};우리는 여러 useState 훅을 사용한 신뢰할 수 없는 상태 전환에서 React의 useReducer 훅을 사용한 예측 가능한 상태 전환으로 이동했습니다. 리듀서에 의해 관리되는 상태 객체는 로딩 및 에러 상태를 포함한 이야기 가져오기와 관련된 모든 것, 그리고 이야기 목록에서 이야기를 제거하는 것과 같은 구현 세부 사항까지 캡슐화합니다. 이전처럼 중요한 불리언 플래그를 빠뜨릴 가능성이 여전히 있기 때문에 불가능한 상태를 완전히 없앤 것은 아니지만, 더 예측 가능한 상태 관리를 향해 한 걸음 더 나아갔습니다.
React 데이터 가져오기
React에서 비동기 데이터 가져오기를 위한 모든 설정을 마쳤습니다. 하지만 우리는 여전히 가짜 API를 위해 스스로 설정한 프로미스에서 오는 가짜 데이터를 사용하고 있습니다. 그래도 지금까지 비동기 React와 고급 상태 관리에 대한 모든 레슨은 실제 원격 서드파티 API에서 데이터를 가져오기 위한 준비 과정이었습니다. 유익한 Hacker News API를 사용하여 인기 있는 기술 이야기들을 요청할 것입니다.
목표: 애플리케이션은 프로미스(가짜 API)로부터 비동기적이지만 가짜인 데이터를 사용합니다. getAsyncStories() 함수를 사용하는 대신 Hacker News API를 사용하여 데이터를 가져오세요.
- Hacker News API의
https://hn.algolia.com/api/v1/search?query=ReactAPI 엔드포인트를 사용 initialStories변수는 데이터가 API에서 올 것이므로 제거- 요청을 수행하기 위해 브라우저의 기본
fetchAPI를 사용 - 참고: 성공적인 요청이나 에러가 있는 요청은 우리가 이미 가지고 있는 동일한 구현 로직을 사용
우리는 이미 모든 것이 제자리에 있기 때문에 비동기 데이터를 가져오기 위한 훌륭한 기반을 가지고 시작합니다. 해결책과 거리가 있는 유일한 점은 실제 데이터 대신 샘플 데이터를 사용한다는 것입니다. 따라서 다음 코드 스니펫은 원격 API에 연결하기 위해 변경해야 할 모든 것을 보여줍니다. 제거할 수 있는 initialStories 배열과 getAsyncStories 함수를 사용하는 대신, API에서 직접 데이터를 가져올 것입니다.
// src/App.jsx
// A
const API_ENDPOINT = 'https://hn.algolia.com/api/v1/search?query=';
const App = () => {
// ...
React.useEffect(() => {
dispatchStories({ type: 'STORIES_FETCH_INIT' });
fetch(`${API_ENDPOINT}react`) // B
.then((response) => response.json()) // C
.then((result) => {
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: result.hits, // D
});
})
.catch(() =>
dispatchStories({ type: 'STORIES_FETCH_FAILURE' })
);
}, []);
// ...
};먼저, API_ENDPOINT (A)는 특정 쿼리(검색어)에 대한 인기 기술 이야기를 가져오는 데 사용됩니다. 이 경우 React에 관한 이야기를 가져옵니다(B). 둘째, 이 요청을 수행하기 위해 브라우저의 기본 fetch API가 사용됩니다(B). fetch API의 경우 응답을 JSON으로 변환해야 합니다(C). 마지막으로, 반환된 결과는 다른 데이터 구조(D)를 가지며, 이를 컴포넌트의 상태 리듀서로 페이로드로 보냅니다.
이전 코드 예제에서 문자열 보간을 위해 자바스크립트의 템플릿 리터럴(Template Literals)을 사용했습니다.
브라우저를 확인하여 Hacker News API에서 가져온 초기 쿼리와 관련된 이야기들을 확인해 보세요. 샘플 데이터와 동일한 데이터 구조를 사용했기 때문에 Item 컴포넌트에서 아무것도 변경할 필요가 없었습니다. 데이터를 가져온 후에도 여전히 title 속성이 있으므로 검색 기능으로 이야기를 필터링할 수 있습니다.
React 데이터 다시 가져오기
이제 우리는 원격 API에서 가져온 데이터를 가지고 있으며, 샘플 데이터보다 더 흥미로운 환경을 제공합니다. 이후 애플리케이션을 실행해 보았다면 완성도가 떨어진다는 느낌을 받았을 수 있습니다. 미리 정의된 쿼리(여기서는 ‘react’)로 데이터를 가져오기 때문에 우리는 지속적으로 “React”와 관련된 이야기만 봅니다. 검색 기능이 있음에도 불구하고 이미 존재하는 이야기들만 필터링할 수 있습니다.
따라서 검색 기능은 원격 API와 상호 작용하지 않고 클라이언트에 있는 데이터에만 작동하기 때문에 클라이언트 사이드 검색(client-side search)이라고 합니다. 클라이언트 사이드 검색은 (초기 데이터 가져오기 이후) 클라이언트에 있는 이야기들만 필터링하는 반면, 서버 사이드 검색(server-side search)은 검색어를 기반으로 원격 API에서 데이터를 가져올 수 있게 해 줍니다.
본질적으로 클라이언트 사이드 검색과 서버 사이드 검색은 검색 작업이 일어나는 위치가 다릅니다. 클라이언트 사이드 검색은 사용자 기기에서 발생하여 빠른 응답을 제공하지만 대규모 데이터셋에는 적합하지 않을 수 있습니다. 서버 사이드 검색은 서버에서 발생하여 대규모 데이터셋에 더 적합하지만 서버 왕복으로 인해 사용자 응답 시간이 느려질 수 있습니다. 선택은 데이터셋 크기, 검색 복잡성, 성능 고려 사항과 같은 요소에 따라 달라집니다. 클라이언트 사이드 검색을 서버 사이드 검색으로 변경하고자 합니다.
목표: 검색 기능은 이미 있는 데이터만 필터링하기 때문에 클라이언트 사이드 검색입니다. 대신 검색어를 사용하여 검색어와 관련된 데이터를 가져올 수 있어야 합니다.
- API로부터 필터링 된 데이터를 직접 받을 것으로 예상되므로 계산된 값
searchedStories는 생략할 수 있음 - 데이터 검색 과정에서 하드코딩 된 ’react’를 동적인
searchTerm으로 교체 searchTerm이 빈 문자열인 엣지 케이스를 해결
애플리케이션을 클라이언트 사이드에서 서버 사이드 검색으로 마이그레이션 하는 데는 많은 단계가 필요하지 않습니다. 첫째, API로부터 검색어에 의해 필터링 된 이야기를 받을 것이므로 searchedStories를 제거합니다. List 컴포넌트에는 일반 stories만 전달합니다.
둘째, 하드코딩 된 검색어(여기서는 ‘react’) 대신 컴포넌트 상태의 실제 searchTerm을 사용합니다. 그 후, 사용자가 입력 필드를 통해 무언가를 검색할 때마다 searchTerm이 원격 API에서 해당 종류의 이야기를 요청하는 데 사용됩니다. 또한 searchTerm이 빈 문자열인 경우 요청이 실행되는 것을 방지하는 엣지 케이스를 처리해야 합니다.
// src/App.jsx
const App = () => {
// ...
React.useEffect(() => {
if (!searchTerm) return;
dispatchStories({ type: 'STORIES_FETCH_INIT' });
fetch(`${API_ENDPOINT}${searchTerm}`)
.then((response) => response.json())
.then((result) => {
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: result.hits,
});
})
.catch(() =>
dispatchStories({ type: 'STORIES_FETCH_FAILURE' })
);
}, [searchTerm]);
// ...
};중요한 조각이 하나 더 있습니다. 초기 데이터 가져오기는 searchTerm(여기서는 초기 상태로 설정된 ‘React’)을 존중하지만, 사용자가 입력 필드에 타이핑하여 searchTerm이 변경될 때는 존중되지 않았습니다. useEffect 훅의 의존성 배열을 검사해 보면 비어 있는 것을 볼 수 있습니다. 이는 사이드 이펙트가 App 컴포넌트의 초기 렌더링에 대해서만 실행된다는 것을 의미합니다. searchTerm이 변경될 때도 사이드 이펙트를 실행하고 싶다면 의존성 배열에 searchTerm을 포함해야 합니다.
우리는 기능을 클라이언트 사이드 검색에서 서버 사이드 검색으로 전환했습니다. 클라이언트에서 미리 정의된 이야기 목록을 필터링하는 대신, 이제 searchTerm을 활용하여 서버 측에서 필터링 된 목록을 검색합니다. 서버 사이드 검색은 초기 데이터 가져오기뿐만 아니라 searchTerm이 변경될 때도 발생합니다.
하지만 매 키 입력마다 데이터를 다시 가져오는 것은 최적화되지 않았습니다. 이 구현은 잦은 요청으로 API에 부담을 줍니다. 과도한 요청은 많은 양의 요청으로부터 보호하기 위해 많은 API가 사용하는 조치인 속도 제한(rate limiting)(예: 1분에 X개의 요청만 허용)으로 인해 API 에러를 유발할 수 있습니다. 우리는 곧 이 문제를 해결할 계획입니다.
React 메모이제이션된 함수
대부분 React 컴포넌트에 정의된 함수들은 이벤트 핸들러 역할을 합니다. 하지만 React 컴포넌트 자체도 함수이기 때문에 컴포넌트 내부에 함수, 함수 표현식, 화살표 함수 표현식을 선언할 수도 있습니다. React의 useCallback 훅을 사용한 메모이제이션된 함수(memoized function) 개념을 소개합니다.
먼저, 메모이제이션된 함수를 통합하도록 코드를 리팩터링하고 자세한 설명을 이어가겠습니다. 리팩터링은 사이드 이펙트의 모든 데이터 가져오기 로직을 화살표 함수 표현식(A)으로 옮기는 것을 포함합니다. 이 새로운 함수는 React의 useCallback 훅(B) 내에 캡슐화되고, 이후 useEffect 훅(C) 내에서 호출됩니다.
// src/App.jsx
const App = () => {
// ...
// A
const handleFetchStories = React.useCallback(() => { // B
if (!searchTerm) return;
dispatchStories({ type: 'STORIES_FETCH_INIT' });
fetch(`${API_ENDPOINT}${searchTerm}`)
.then((response) => response.json())
.then((result) => {
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: result.hits,
});
})
.catch(() =>
dispatchStories({ type: 'STORIES_FETCH_FAILURE' })
);
}, [searchTerm]); // E
React.useEffect(() => {
handleFetchStories(); // C
}, [handleFetchStories]); // D
// ...
};핵심적으로 애플리케이션은 동일하게 동작합니다. 우리는 React의 useEffect 훅에서 새로운 함수를 추출했을 뿐입니다. 데이터 가져오기 로직을 사이드 이펙트에서 직접 사용하는 대신, 전체 애플리케이션에서 사용할 수 있는 함수로 만들었습니다. 이점은 재사용성입니다. 이 새로운 함수를 호출함으로써 애플리케이션의 다른 부분에서도 데이터 가져오기를 사용할 수 있습니다.
하지만 추출된 함수를 감싸기 위해 React의 useCallback 훅을 사용했는데, 여기서 왜 이것이 필요한지 알아보겠습니다. React의 useCallback 훅은 의존성 배열(E)이 변경될 때마다 메모이제이션된 함수를 생성합니다. 결과적으로 useEffect 훅은 새로운 함수(D)에 의존하기 때문에 다시 실행(C)됩니다.
- 변경:
searchTerm(원인: 사용자 상호작용) - 변경:
handleFetchStories(원인: 변경된searchTerm) - 실행: 사이드 이펙트 (원인: 변경된
handleFetchStories)
만약 React의 useCallback 훅을 빼고 새로운 handleFetchStories 이벤트 핸들러만 정의한다면, App 컴포넌트가 리렌더링 될 때마다 새로운 handleFetchStories 함수가 생성되고 useEffect 훅에서 실행되어 데이터를 가져올 것입니다. 가져온 데이터는 컴포넌트의 상태로 저장됩니다. 그러면 컴포넌트의 상태가 변경되었으므로 컴포넌트가 리렌더링 되고 새로운 handleFetchStories 함수를 생성합니다. 사이드 이펙트가 데이터를 가져오기 위해 트리거 되고, 우리는 무한 루프에 빠지게 됩니다.
- 정의:
handleFetchStories - 실행: 사이드 이펙트
- 업데이트: 상태 (state)
- 리렌더링: 컴포넌트
- 재정의:
handleFetchStories - 실행: 사이드 이펙트
- … (무한 반복)
useCallback 훅 없이 직접 시도해 볼 수 있지만 브라우저가 충돌할 준비를 하세요. 결국 React의 useCallback 훅은 의존성 배열의 값 중 하나가 변경될 때만 함수를 변경합니다. 이때가 바로 우리가 데이터 다시 가져오기를 트리거하고 싶은 시점입니다. 입력 필드에 새로운 입력이 있고 리스트에 표시된 새로운 데이터를 보고 싶기 때문입니다.
데이터 가져오기 함수를 React의 useEffect 훅 밖으로 이동시킴으로써 애플리케이션의 다른 부분에서도 재사용할 수 있게 되었습니다. 아직 사용하지는 않겠지만 React에서 메모이제이션된 함수를 이해하기에 좋은 사용 사례입니다. 이제 searchTerm이 변경될 때마다 handleFetchStories가 재정의되기 때문에 useEffect 훅은 암시적으로 실행되고, useEffect 훅이 handleFetchStories에 의존하므로 데이터 가져오기 사이드 이펙트가 다시 실행됩니다.
React 명시적 데이터 가져오기
누군가 입력 필드에 타이핑할 때마다 모든 데이터를 다시 가져오는 것은 최적화되지 않았습니다. 데이터를 가져오기 위해 서드파티 API를 사용하고 있으므로 그 내부 사정은 우리 손을 떠나 있습니다. 결국 우리는 데이터 대신 에러를 반환하는 속도 제한(rate limiting)에 직면하게 될 것입니다. 이 문제를 해결하기 위해 구현 세부 사항을 암시적 데이터 가져오기에서 명시적 데이터 (재)가져오기로 변경할 것입니다. 즉, 누군가 확인 버튼을 클릭할 때만 애플리케이션이 데이터를 다시 가져올 것입니다.
목표: 서버 사이드 검색은 사용자가 입력 필드에 타이핑할 때마다 실행됩니다. 새로운 구현은 사용자가 확인 버튼을 클릭할 때만 검색을 실행해야 합니다. 버튼이 클릭되지 않는 한 검색어는 변경될 수 있지만 API 요청으로 실행되지는 않습니다.
- 검색 요청을 확인하기 위한 버튼 엘리먼트를 추가
- 확인된 검색(confirmed search)을 위한 상태 값을 생성
- 버튼의 이벤트 핸들러는 현재 검색어를 사용하여 확인된 검색을 상태로 설정
- 새로운 확인된 검색이 상태로 설정될 때만 서버 사이드 검색을 수행하는 사이드 이펙트를 실행
이 기능에서 중요한 것은 변동하는 searchTerm을 위한 상태와 확인된 검색을 위한 새로운 상태가 필요하다는 것입니다. 우선 검색을 확인하고 데이터 요청을 실행할 새로운 버튼 엘리먼트를 생성하세요.
// src/App.jsx
const App = () => {
// ...
return (
<div>
<h1>My React Stories</h1>
<InputWithLabel
id="search"
value={searchTerm}
isFocused
onInputChange={handleSearchInput}
>
<strong>Search:</strong>
</InputWithLabel>
<button
type="button"
disabled={!searchTerm}
onClick={handleSearchSubmit}
>
Submit
</button>
{/* ... */}
</div>
);
};둘째, 입력 필드의 핸들러와 버튼의 핸들러를 구별합니다. 이름이 변경된 입력 필드 핸들러는 여전히 searchTerm 상태를 설정하지만, 새로운 버튼 핸들러는 현재 searchTerm과 정적 API 엔드포인트에서 파생된 url이라는 새로운 상태 값을 설정합니다.
// src/App.jsx
const App = () => {
const [searchTerm, setSearchTerm] = useStorageState(
'search',
'React'
);
const [url, setUrl] = React.useState(
`${API_ENDPOINT}${searchTerm}`
);
const handleSearchInput = (event) => {
setSearchTerm(event.target.value);
};
const handleSearchSubmit = () => {
setUrl(`${API_ENDPOINT}${searchTerm}`);
};
// ...
};셋째, 모든 searchTerm 변경(이전처럼 입력 필드 값이 변경될 때마다 발생)에 대해 데이터 가져오기 사이드 이펙트를 실행하는 대신, 사용자가 버튼을 클릭하여 검색 요청을 확인할 때 변경되는 새로운 상태인 url을 사용합니다.
// src/App.jsx
const App = () => {
// ...
const handleFetchStories = React.useCallback(async () => {
dispatchStories({ type: 'STORIES_FETCH_INIT' });
try {
const result = await axios.get(url);
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: result.data.hits,
});
} catch {
dispatchStories({ type: 'STORIES_FETCH_FAILURE' });
}
}, [url]);
React.useEffect(() => {
handleFetchStories();
}, [handleFetchStories]);
// ...
};이전에는 searchTerm이 입력 필드의 상태 업데이트와 데이터 가져오기 사이드 이펙트 활성화라는 두 가지 경우에 사용되었습니다. 이제는 전자(입력 필드 상태)에만 사용됩니다. url이라는 두 번째 상태가 도입되어, 사용자가 확인 버튼을 클릭할 때만 데이터 가져오기 사이드 이펙트를 트리거하도록 변경되었습니다.
React 서드파티 라이브러리
우리는 이전에 Hacker News API에 요청을 수행하기 위해 기본 fetch API(브라우저 제공)를 도입했습니다. 하지만 모든 브라우저가 이를 지원하는 것은 아니며, 특히 구형 브라우저에서는 더욱 그렇습니다. 또한 헤드리스 브라우저 환경에서 애플리케이션을 테스트하기 시작하면 실제 브라우저가 없기 때문에 fetch API에 문제가 발생할 수 있습니다. 구형 브라우저(폴리필)와 테스트(isomorphic fetch)에서 fetch를 작동하게 하는 몇 가지 방법이 있지만, 이 학습 경험의 목적에는 다소 벗어납니다.
한 가지 대안은 원격 API에 대한 비동기 요청을 수행하는 axios와 같은 안정적인 라이브러리로 기본 fetch API를 대체하는 것입니다. 라이브러리(이 경우 브라우저의 기본 API)를 npm 레지스트리의 다른 라이브러리로 대체하는 방법을 알아볼 것입니다.
먼저 커맨드 라인에서 axios를 설치합니다.
둘째, App 컴포넌트 파일에 axios를 임포트합니다.
이제 fetch 대신 axios를 사용할 수 있습니다. 사용법은 기본 fetch API와 거의 동일해 보입니다. URL을 인자로 받고 프로미스를 반환합니다. axios는 결과를 자바스크립트 내의 데이터 객체로 래핑해주므로 반환된 응답을 JSON으로 변환할 필요가 없습니다. 반환된 데이터 구조에 맞게 코드를 조정하기만 하면 됩니다.
// src/App.jsx
const App = () => {
// ...
const handleFetchStories = React.useCallback(() => {
dispatchStories({ type: 'STORIES_FETCH_INIT' });
axios
.get(url)
.then((result) => {
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: result.data.hits,
});
})
.catch(() =>
dispatchStories({ type: 'STORIES_FETCH_FAILURE' })
);
}, [url]);
// ...
};이 코드에서 우리는 명시적인 HTTP GET 요청을 위해 axios.get()을 호출합니다. 이는 브라우저의 기본 fetch API에서 기본적으로 사용했던 것과 동일한 HTTP 메서드입니다. axios.post()와 같은 다른 HTTP 메서드도 사용할 수 있습니다. 이 예제들을 통해 axios가 원격 API에 요청을 수행하기 위한 강력한 라이브러리임을 알 수 있습니다. 요청이 복잡해지거나, 구형 브라우저와 작업하거나, 테스트를 할 때 기본 fetch API보다 axios를 추천합니다.
React Async/Await
실제 애플리케이션을 작업할 때 비동기 데이터를 피할 방법은 없습니다. 프론트엔드든 백엔드든 데이터를 제공하는 원격 API는 항상 존재하므로, 이 데이터를 비동기적으로 다루는 방법을 이해해야 합니다. React 애플리케이션에서 우리는 then/catch 블록으로 프로미스를 해결하기 시작했습니다. 하지만 최신 자바스크립트(따라서 React)에서는 async/await를 사용하는 것이 더 인기 있는 해결책입니다.
then/catch 문법을 async/await 문법으로 대체해야 합니다. 다음 handleFetchStories() 함수의 리팩터링은 에러 처리 없이 이를 달성하는 방법을 보여줍니다.
async/await를 사용하려면 함수에 async 키워드가 필요합니다. 반환된 프로미스에 await 키워드를 사용하기 시작하면 모든 것이 동기 코드처럼 읽힙니다. await 키워드 뒤의 동작들은 프로미스가 해결될 때까지 실행되지 않습니다. 즉, 코드가 기다립니다. 이전처럼 에러 처리를 포함하려면 try와 catch 블록이 도움이 됩니다. try 블록에서 무언가 잘못되면 코드는 에러를 처리하기 위해 catch 블록으로 이동합니다.
// src/App.jsx
const App = () => {
// ...
const handleFetchStories = React.useCallback(async () => {
dispatchStories({ type: 'STORIES_FETCH_INIT' });
try {
const result = await axios.get(url);
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: result.data.hits,
});
} catch {
dispatchStories({ type: 'STORIES_FETCH_FAILURE' });
}
}, [url]);
// ...
};결국 then/catch보다 try/catch와 함께 async/await를 사용하는 것이 더 가독성이 좋을 때가 많습니다. 콜백 함수 사용을 피하고 대신 코드를 동기적인 방식으로 더 읽기 쉽게 만들려고 하기 때문입니다. 하지만 then/catch를 사용하는 것도 괜찮습니다. 결국 프로젝트에 참여하는 전체 팀이 하나의 문법에 동의해야 합니다.
React 폼
폼(Form)을 사용하지 않는 최신 애플리케이션은 없습니다. 폼은 다양한 입력 컨트롤(예: 입력 필드, 체크박스, 라디오 버튼, 슬라이더)에서 버튼을 통해 데이터를 제출하기 위한 적절한 수단일 뿐입니다. 앞서 우리는 버튼 클릭으로 데이터를 명시적으로 가져오는 새로운 버튼을 도입했습니다. 라벨이 있는 검색어 입력 필드와 버튼을 캡슐화하는 적절한 HTML 폼으로 사용법을 발전시켜 보겠습니다.
폼은 React의 JSX에서도 HTML과 크게 다르지 않습니다. HTML/자바스크립트로 두 단계의 리팩터링을 구현해 보겠습니다.
먼저, 입력 필드와 버튼을 HTML 폼 엘리먼트로 감쌉니다.
// src/App.jsx
const App = () => {
// ...
return (
<div>
<h1>My React Stories</h1>
<form onSubmit={handleSearchSubmit}>
<InputWithLabel
id="search"
value={searchTerm}
isFocused
onInputChange={handleSearchInput}
>
<strong>Search:</strong>
</InputWithLabel>
<button type="submit" disabled={!searchTerm}>
Submit
</button>
</form>
<hr />
{/* ... */}
</div>
);
};handleSearchSubmit() 핸들러를 버튼에 전달하는 대신 새로운 폼 엘리먼트의 onSubmit 속성에 사용합니다. 버튼은 submit이라는 새로운 type 속성을 받는데, 이는 클릭을 버튼이 아닌 폼 엘리먼트의 onSubmit이 처리함을 나타냅니다. 다음으로, 핸들러가 폼 이벤트에 사용되므로 React의 합성 이벤트에 대해 preventDefault()를 추가로 실행합니다. 이는 브라우저 리로드를 유발하는 HTML 폼의 기본 동작을 방지합니다.
이제 독립형 버튼 대신 폼을 사용하고 있으므로 키보드의 “Enter” 키로 검색 기능을 실행할 수 있습니다. 다음 두 단계에서는 전체 폼을 새로운 SearchForm 컴포넌트로 분리할 것입니다.
// src/App.jsx
const SearchForm = ({
searchTerm,
onSearchInput,
onSearchSubmit,
}) => (
<form onSubmit={onSearchSubmit}>
<InputWithLabel
id="search"
value={searchTerm}
isFocused
onInputChange={onSearchInput}
>
<strong>Search:</strong>
</InputWithLabel>
<button type="submit" disabled={!searchTerm}>
Submit
</button>
</form>
);새로운 컴포넌트는 App 컴포넌트에서 인스턴스화됩니다. 폼의 상태는 여전히 App 컴포넌트가 관리합니다. 상태가 App 컴포넌트에서 데이터 요청을 트리거하고, 요청된 데이터는 결국 List 컴포넌트에 props로 전달되기 때문입니다.
폼은 일반 HTML과 React에서 크게 다르지 않습니다. 입력 필드와 그 데이터를 제출할 버튼이 있을 때, onSubmit 속성을 가진 폼 엘리먼트로 감싸서 HTML에 더 많은 구조를 줄 수 있습니다. 제출을 실행하는 버튼은 프로세스를 폼 엘리먼트의 핸들러로 참조하기 위해 “submit” 타입이 필요합니다. 결국 이는 키보드 사용자에게도 더 접근하기 쉽게 만듭니다.
React 폼은 데이터를 제출하는 강력한 도구입니다. 이전 노트에서는 API에서 데이터를 가져오기 위해 검색어를 제출하는 폼을 도입했습니다. 우리는 데이터 가져오기 프로세스를 트리거하기 위해 onSubmit 이벤트 핸들러를 사용했습니다. 폼에 action 속성이라는 새로운 개념을 소개합니다.
action 속성은 폼 데이터가 제출되어야 할 URL을 지정하는 표준 HTML 속성입니다. React를 사용할 때는 폼 컴포넌트에 액션 함수(action function)를 전달할 수 있으며, 이 함수는 폼이 제출될 때 실행됩니다.
// src/App.jsx
const SearchForm = ({ searchTerm, onSearchInput, searchAction }) => (
<form action={searchAction}>
<InputWithLabel
id="search"
value={searchTerm}
isFocused
onInputChange={onSearchInput}
>
<strong>Search:</strong>
</InputWithLabel>
<button type="submit" disabled={!searchTerm}>
Submit
</button>
</form>
);제출 핸들러를 폼의 onSubmit 속성에 전달하는 대신, 새로운 searchAction 함수를 폼의 action 속성에 전달합니다.
네이티브 폼 동작에 더 가까워졌으므로 제출 핸들러에서 preventDefault() 호출을 제거할 수 있습니다.
React 19로 나아가면서 action 속성은 onSubmit 속성보다 폼 데이터를 제출하는 데 더 많이 사용될 것입니다. 네이티브 폼 동작의 이점을 더 많이 활용하기 때문입니다. 선택적으로 폼 액션의 함수 시그니처를 통해 폼 데이터에 접근할 수 있으며, 이는 폼 유효성 검사나 기타 폼 관련 작업에 유용할 수 있습니다.
- 작성한 소스 코드를 저자의 소스 코드와 비교해 보세요
- 이 노트의 모든 소스 코드 변경 사항을 요약해 보세요
- React와
FormData에 대해 더 읽어보세요 - 폼(Forms)과 로딩 상태(Loading State)에 대해 더 읽어보세요
최종 코드(b)
라이브러리 설치
npm install axios lodash
최종 코드
import * as React from 'react';
import axios from 'axios';
import { sortBy } from 'lodash';
const API_BASE = 'https://hn.algolia.com/api/v1';
const API_SEARCH = '/search';
const PARAM_SEARCH = 'query=';
const PARAM_PAGE = 'page=';
const getUrl = (searchTerm, page) =>
`${API_BASE}${API_SEARCH}?${PARAM_SEARCH}${searchTerm}&${PARAM_PAGE}${page}`;
const extractSearchTerm = (url) =>
url
.substring(url.lastIndexOf('?') + 1, url.lastIndexOf('&'))
.replace(PARAM_SEARCH, '');
const getLastSearches = (urls) =>
urls
.reduce((result, url, index) => {
const searchTerm = extractSearchTerm(url);
if (index === 0) {
return result.concat(searchTerm);
}
const previousSearchTerm = result[result.length - 1];
if (searchTerm === previousSearchTerm) {
return result;
} else {
return result.concat(searchTerm);
}
}, [])
.slice(-6)
.slice(0, -1);
const storiesReducer = (state, action) => {
switch (action.type) {
case 'STORIES_FETCH_INIT':
return {
...state,
isLoading: true,
isError: false,
};
case 'STORIES_FETCH_SUCCESS':
return {
...state,
isLoading: false,
isError: false,
data:
action.payload.page === 0
? action.payload.list
: state.data.concat(action.payload.list),
page: action.payload.page,
};
case 'STORIES_FETCH_FAILURE':
return {
...state,
isLoading: false,
isError: true,
};
case 'REMOVE_STORY':
return {
...state,
data: state.data.filter(
(story) => action.payload.objectID !== story.objectID
),
};
default:
throw new Error();
}
};
const useStorageState = (key, initialState) => {
const [value, setValue] = React.useState(
localStorage.getItem(key) || initialState
);
React.useEffect(() => {
localStorage.setItem(key, value);
}, [value, key]);
return [value, setValue];
};
const App = () => {
const [searchTerm, setSearchTerm] = useStorageState(
'search',
'React'
);
const [urls, setUrls] = React.useState([getUrl(searchTerm, 0)]);
const [stories, dispatchStories] = React.useReducer(
storiesReducer,
{ data: [], page: 0, isLoading: false, isError: false }
);
const handleFetchStories = React.useCallback(async () => {
dispatchStories({ type: 'STORIES_FETCH_INIT' });
try {
const lastUrl = urls[urls.length - 1];
const result = await axios.get(lastUrl);
dispatchStories({
type: 'STORIES_FETCH_SUCCESS',
payload: {
list: result.data.hits,
page: result.data.page,
},
});
} catch {
dispatchStories({ type: 'STORIES_FETCH_FAILURE' });
}
}, [urls]);
React.useEffect(() => {
handleFetchStories();
}, [handleFetchStories]);
const handleRemoveStory = (item) => {
dispatchStories({
type: 'REMOVE_STORY',
payload: item,
});
};
const handleSearchInput = (event) => {
setSearchTerm(event.target.value);
};
const handleSearch = (searchTerm, page) => {
const url = getUrl(searchTerm, page);
setUrls(urls.concat(url));
};
const searchAction = () => {
handleSearch(searchTerm, 0);
};
const handleLastSearch = (searchTerm) => {
setSearchTerm(searchTerm);
handleSearch(searchTerm, 0);
};
const lastSearches = getLastSearches(urls);
const handleMore = () => {
const lastUrl = urls[urls.length - 1];
const searchTerm = extractSearchTerm(lastUrl);
handleSearch(searchTerm, stories.page + 1);
};
return (
<div>
<h1>My React Stories</h1>
<SearchForm
searchTerm={searchTerm}
onSearchInput={handleSearchInput}
searchAction={searchAction}
/>
<LastSearches
lastSearches={lastSearches}
onLastSearch={handleLastSearch}
/>
<hr />
{stories.isError && <p>Something went wrong ...</p>}
<List list={stories.data} onRemoveItem={handleRemoveStory} />
{stories.isLoading ? (
<p>Loading ...</p>
) : (
<button type="button" onClick={handleMore}>
More
</button>
)}
</div>
);
};
const LastSearches = ({ lastSearches, onLastSearch }) => (
<>
{lastSearches.map((searchTerm, index) => (
<button
key={searchTerm + index}
type="button"
onClick={() => onLastSearch(searchTerm)}
>
{searchTerm}
</button>
))}
</>
);
const SearchForm = ({ searchTerm, onSearchInput, searchAction }) => (
<form action={searchAction}>
<InputWithLabel
id="search"
value={searchTerm}
isFocused
onInputChange={onSearchInput}
>
<strong>Search:</strong>
</InputWithLabel>
<button type="submit" disabled={!searchTerm}>
Submit
</button>
</form>
);
const InputWithLabel = ({
id,
value,
type = 'text',
onInputChange,
isFocused,
children,
}) => {
const inputRef = React.useRef();
React.useEffect(() => {
if (isFocused && inputRef.current) {
inputRef.current.focus();
}
}, [isFocused]);
return (
<>
<label htmlFor={id}>{children}</label>
<input
ref={inputRef}
id={id}
type={type}
value={value}
onChange={onInputChange}
/>
</>
);
};
const SORTS = {
NONE: (list) => list,
TITLE: (list) => sortBy(list, 'title'),
AUTHOR: (list) => sortBy(list, 'author'),
COMMENT: (list) => sortBy(list, 'num_comments').reverse(),
POINT: (list) => sortBy(list, 'points').reverse(),
};
const List = ({ list, onRemoveItem }) => {
const [sort, setSort] = React.useState({
sortKey: 'NONE',
isReverse: false,
});
const handleSort = (sortKey) => {
const isReverse = sort.sortKey === sortKey && !sort.isReverse;
setSort({ sortKey, isReverse });
};
const sortFunction = SORTS[sort.sortKey];
const sortedList = sort.isReverse
? sortFunction(list).reverse()
: sortFunction(list);
return (
<ul>
<li style={{ display: 'flex' }}>
<span style={{ width: '40%' }}>
<button type="button" onClick={() => handleSort('TITLE')}>
Title
</button>
</span>
<span style={{ width: '30%' }}>
<button type="button" onClick={() => handleSort('AUTHOR')}>
Author
</button>
</span>
<span style={{ width: '10%' }}>
<button type="button" onClick={() => handleSort('COMMENT')}>
Comments
</button>
</span>
<span style={{ width: '10%' }}>
<button type="button" onClick={() => handleSort('POINT')}>
Points
</button>
</span>
<span style={{ width: '10%' }}>Actions</span>
</li>
{sortedList.map((item) => (
<Item
key={item.objectID}
item={item}
onRemoveItem={onRemoveItem}
/>
))}
</ul>
);
};
const Item = ({ item, onRemoveItem }) => (
<li style={{ display: 'flex' }}>
<span style={{ width: '40%' }}>
<a href={item.url}>{item.title}</a>
</span>
<span style={{ width: '30%' }}>{item.author}</span>
<span style={{ width: '10%' }}>{item.num_comments}</span>
<span style={{ width: '10%' }}>{item.points}</span>
<span style={{ width: '10%' }}>
<button type="button" onClick={() => onRemoveItem(item)}>
Dismiss
</button>
</span>
</li>
);
export default App;