모든 데이터 변환은 컴포넌트 최상위 레벨에서 한다.
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 Bad: 중복 state 및 불필요한 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: 렌더링 과정 중에 계산
const fullName = firstName + ' ' + lastName;
// ...
}
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 Bad: 중복 state 및 불필요한 Effect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ getFilteredTodos()가 느리지 않다면 Good
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
getFilteredTodos
함수를 감싸고, 의존성 배열에 인수로 들어가는 값들을 넣어준다.Tip: 고비용 연산인지 계산하는 법
- 형태:
console.time
+ 연산 시간을 측정하고 싶은 코드 +console.timeEnd
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
// filter array : 0.15ms
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 Bad: prop 변경 시 Effect에서 state 재설정 수행
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
key를 사용하면 일어나는 일
- key가 동일한 UI 컴포넌트들 간이라도 서로 간에 state를 공유하지 않는다는 걸 명시
export default function ProfilePage({ userId }) {
return (
<Profile
key={userId}
userId={userId}
/>
);
}
function Profile({ userId }) {
// ✅ Good: key가 변하면 이 컴포넌트 및 모든 자식 컴포넌트의 state가 자동으로 재설정됨
const [comment, setComment] = useState('');
// ...
}
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴 Bad: prop 변경시 Effect에서 state 조정
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// Better: 렌더링 중에 state 조정
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ Best: 렌더링 중에 모든 값을 계산
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
BONUS: ?? 연산자와 || 연산자의 차이
- ?? 연산자는 좌항이 null이나 undefined이면 우항 반환 (아니면 좌항 반환)
- || 연산자는 좌항이 falsy값(false, null, 0, NaN, undefined, 빈문자열)인 경우에 우항 반환 (어느 것에도 해당 안 되면 좌항 반환)
function ProductPage({ product, addToCart }) {
// 🔴 Bad: Effect 내부에 특정 이벤트에 대한 로직 존재
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
function ProductPage({ product, addToCart }) {
// ✅ Good: 이벤트 핸들러 안에서 각 이벤트별 로직 호출
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
Effect인지 이벤트 핸들러인지 구분하는 법
- '이 코드가 실행되어야 하는 이유(코드가 trigger된 근본적 이유)'를 생각해보기
- 컴포넌트가 사용자에게 '표시되었기 때문에' 실행되는 코드는 Effect를 사용해야
- 위 예제의 알림 로직은 '화면이 표시'되었기 때문이 아니라 '사용자가 버튼을 눌렀기 때문에' 실행
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Good: '컴포넌트가 표시되었기 때문에 로직이 실행되는 경우'에 해당
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// 🔴 Bad: Effect 안에 이벤트 관련 로직 존재
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ...
function handleSubmit(e) {
e.preventDefault();
// ✅ Good: 이벤트 핸들러 안에서 특정 이벤트 로직 호출
post('/api/register', { firstName, lastName });
}
// ...
}
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 Bad: 오직 서로를 촉발하기 위해서만 state를 조정하는 Effect 체인
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
// ...
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ 가능한 것을 렌더링 중에 계산
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ 이벤트 핸들러에서 다음 state를 모두 계산
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
// 동기적으로 round를 판별하려면 useState 대신
// const nextRound = round + 1 이용
if (round === 5) {
alert('Good game!');
}
}
}
}
// ...
예외
- 이벤트 핸들러에서 다음 state를 계산할 수 없는 경우
- 예: 이전 드롭다운의 선택 값에 따라 다음 드롭다운의 옵션이 달라지는 form
- 이를 네트워크와 동기화(서버 호출)해야 한다면 Effect 체인이 적절
function App() {
// 🔴 Bad: 한 번만 실행되어야 하는 로직이 포함된 Effect
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
Tip: Strict Mode 처리 방법
- 처리하지 말기 (그대로 유지)
- 빌드 후(상용 환경)에는 두 번 실행되는 동작 없음 (개발 모드에서만 발생)
- 참고 링크
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 앱 로드당 한 번만 실행됨
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인
// ✅ 앱 로드당 한 번만 실행됨
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 Bad: onChange 핸들러가 너무 늦게 실행됨
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
function updateToggle(nextIsOn) {
// ✅ Good: 이벤트 발생시 모든 업데이트를 수행
setIsOn(nextIsOn);
onChange(nextIsOn);
}
function handleClick() {
updateToggle(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
updateToggle(true);
} else {
updateToggle(false);
}
}
// ...
}
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 Bad: Effect에서 부모에게 데이터 전달
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
function Parent() {
const data = useSomeAPI();
// ...
// ✅ Good: 자식에게 데이터 전달
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
function useOnlineStatus() {
// 🔴 Not Ideal: Effect에서 수동으로 store 구독
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ Good: 빌트인 훅에서 외부 store 구독
return useSyncExternalStore(
subscribe, // React는 동일한 함수를 전달하는 한 다시 구독하지 않음
() => navigator.onLine, // 클라이언트에서 값을 가져오는 방법
() => true // 서버에서 값을 가져오는 방법
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
useSyncExternalStore의 상세한 사용법 => 참고 링크
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 Bad: 클린업 없이 fetch만 수행
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false; // flag 변수 추가
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true; // 클린업 함수에서 flag 변수 변경
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}