이펙트는 리액트 패러다임에서 해치 탈출구의 역할을 해요. 그리고 나서 리액트의 "바깥으로 나가서" 컴포넌트를 리액트가 아닌 위젯이나 네트워크 또는 브라우저 DOM과 같은 외부 시스텀과 동기화 시켜줘요. 만약 (props나 상태가 바뀔 때 컴포넌트의 상태를 업데이트 하고 싶은 상황과 같이) 외부 시스템을 포함하고 있지 않다면 이펙트가 필요하지 않는 상황이에요. 불필요한 이펙트를 제거하면 코드를 이해하기 더 쉬워지고, 더 빠르게 실행되며, 에러가 발생할 확률을 낮춰줘요.
이 페이지에서는
- 불필요한 이펙트를 컴포넌트에서 왜 그리고 어떻게 제거해야하는지
- 이펙트가 없이 고비용의 연산을 어떻게 캐싱하는지
- 이펙트가 없이 컴포넌트의 상태를 어떻게 초기화하고 조정하는지
- 이벤트 핸들러 간에 로직을 어떻게 공유하는지
- 어떤 로직이 이벤트핸들러로 이동해야하는지
- 변화를 부모 컴포넌트에 어떻게 알리는지
를 알아볼 거예요.
How to remove unnecessary Effects | 불필요한 이펙트를 제거하는 방법
아래의 두 경우는 이펙트가 필요하지 않은 대표적인 상황이에요.
- 렌더링 시 데이터를 변형할 때. 예를 들어, 화면에 표시하기 전에 목록을 필터링하고 싶다고 할게요. 목록이 변형될 때 상태 변수를 업데이트하는 이펙트를 작성해야할 것만 같을 거예요. 그러나 이는 비효율적이에요. 상태를 업데이트할 때 리액트는 화면에 보여줄 것을 계산하는 컴포넌트 함수를 먼저 호출해요. 그리고 나서 리액트는 화면을 업데이트 하기 위해 DOM에 이 변경사항들을 "커밋"해요. 그리고나서 리액트는 이펙트를 실행해요. 만약 이펙트도 즉시 상태를 업데이트한다면 스크래치에서 모든 과정을 전부 재시작하는 것과 동일해요! 불필요한 렌더링이 전달되는 것을 피하려면 모든 데이터를 컴포넌트의 최상단에서 변경하세요. 해당 코드는 자동적으로 props나 상태가 변할때마다 재실행 될 거예요.
- 사용자 이벤트를 핸들링할 때. 예를 들어, 사용자가 상품을 구매할 때
/api/buy
POST 요청을 보내고 알림을 보여줘야 해요. 구매 버튼의 이벤트 핸들러에서 정확히 무슨일이 벌어지는지 알고 있어요. 이펙트가 실행될 때 사용자가 무엇을 했는지는 몰라요. (예를 들어, 어떤 버튼을 눌렀는지) 사용자 이벤트를 해당하는 이벤트 핸들러에서만 다루는 이유가 바로 이 때문이에요.
외부 시스템과 동기화할 때는 반드시 이펙트가 필요해요. 예를 들어, jQuery 위젯을 리액트 상태와 동기화하려면 이펙트를 작성해야해요. 이펙트로 데이터를 페칭할 수도 있어요. 예를 들어, 현재 탐색 쿼리와 탐색 결과를 동기화할 수 있어요. 모던 프레임워크는 컴포넌트에서 직접 이펙트를 작성하는 것보다 더 효율적인 내장 데이터 페칭 메커니즘을 제공한다는 사실을 기억하세요.
올바른 직감을 가지도록 도와주기 위하여 몇가지 구체적인 예시를 보여줄게요.
Updating state based on props or state | props 또는 상태에 기반하여 상태 업데이트하기
두 개의 상태 변수, firstName
과 lastName
을 갖고있는 컴포넌트가 있어요. 이 두 값을 연결하여 fullName
을 계산해야해요. 더하여 firstName
이나 lastNAme
이 변경되면 fullName
은 업데이트 되어야해요. 여러분은 본능적으로fullName
상태 변수를 추가하고 이펙트에서 이를 업데이트 할 거예요.
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 불필요한 상태와 이펙트는 피하세요
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
필요 이상으로 복잡해요. 물론, 비효율적이고요. fullName
의 기존 값을 사용하여 전체 렌더링 패스를 모두 수행한 후 즉시 업데이트된 값으로 리렌더링해요. 상태 변수와 이펙트를 제거하세요.
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅렌더링 동안 계산하세요
const fullName = firstName + ' ' + lastName;
// ...
}
무언가가 기존의 props와 상태에서 계산될 때, 그 값을 상태에 넣지 마세요. 대신, 렌더링 동안 값을 계산하세요. 이렇게 하면 (추가적인 업데이트를 피하여) 코드는 더 빨라지고, (코드를 제거했기 때문에) 더 간단해지고, (다른 상태 변수가 서로 동기화되지 않아서 발생된 버그를 피하기 때문에) 에러의 가능성을 낮춰줘요. 만약 이 방법이 새롭게 느껴진다면 리액트의 관점으로 생각하기 문서에서 무엇을 상태로 넣어야하는지를 설명하고 있으니 읽어보세요.
Caching expensive calculations | 고비용의 연산 캐싱하기
props로 받은todos
를 가지고 filter
prop에 따라 이것을 필터링하여 이 컴포넌트는 visibleTodos
를 계산해요. 아마도 결과를 상태에 저장하고 이펙트에서 업데이트 하고 싶을 거예요.
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 불필요한 상태와 이펙트는 피하세요
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
이전의 예시에서 그랬듯, 이는 불필요하고 비효율적이에요. 먼저 상태와 이펙트를 제거하세요.
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ getFilteredTodos()가 느리지 않다면 괜찮아요.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
보통 이 코드는 문제가 없어요. 하지만 getFilteredTodos()
가 느리거나 todos
가 너무 많을 수도 있어요. 이런 경우에는 만약 관계가 없는 newTodo
와 같은 상태 변수가 변경된다면 getFilteredTodos()
를 재계산 하고 싶을 거예요.
useMemo
훅으로 감싸서 고비용의 계산을 캐싱하거나 메모이제이션 할 수 있어요.
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ✅ todo나 filter가 변하지 않았다면 재실행되지 않아요.
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}
또는 한 줄에 적을 수도 있어요.
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ todo나 filter가 변하지 않았다면 getFilteredTodos()는 재실행되지 않아요
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
이는 리액트에게 todos
나 filter
가 변경되지 않는다면 내부 함수를 재실행하지 않고 싶다고 알려주는 거예요. 리액트는 초기 렌더링 동안 getFilteredTodos()
의 반환값을 기억할 거예요. 다음 렌더링 동안, todos
나 filter
이 달라졌는지를 확인할 거예요. 만약 이전과 동일하다면 useMemo
는 저장하고 있는 마지막 값을 반환할 거예요. 그러나 만약 달라졌다면 리액트는 내부 함수를 다시 호출해요. (그리고 결과를 저장해요.)
useMemo
로 감싼 함수는 렌더링동안 실행되기 때문에 순수한 계산만 동작해요.
고비용의 연산이란 무엇인가요?
더보기일반적으로 수천개의 객체를 생성하거나 이 객체들을 반복하지 않는 이상 비싸지 않을 거예요. 만약 조금 더 믿음을 갖고싶다면 해당 코드에서 사용된 시간을 측정하려면 콘솔창에 로그를 찍어보세요.
console.time('filter array'); const visibleTodos = getFilteredTodos(todos, filter); console.timeEnd('filter array');
(예를 들면, 입력창에 타이핑을 하는 등) 측정한 상호 작용을 수행해보세요. 그러면
ilter array: 0.15ms
와 같은 로그를 콘솔에서 볼 거예요. 만약 전체 로그 시간이 상당히 크다면 (1ms
또는 그 이상), 계산을 메모이제이션 하는 것이 합리적이에요. 실험을 하고 싶다면, 상호 작용 당 로그된 전체 시간이 감소했는지를 확인하려면 해당 계산을useMemo
로 감싸보세요.console.time('filter array'); const visibleTodos = useMemo(() => { return getFilteredTodos(todos, filter); // 만약 todos와 filter가 변경되지 않았다면 건너뛰어요 }, [todos, filter]); console.timeEnd('filter array');
useMemo
는 첫 번째 렌더링을 더 빠르게 만들지 않아요. 업데이트를 할 떄 불필요한 작업을 건너뛰도록 도와주기만 해요.여러분들의 기기가 사용자의 것보다 더 빠르기 더 때문에 인위적으로 속도를 낮춰서 성능을 테스트 하는 것도 좋은 방법임을 기억하세요. 예를 들어 크롬은 이런 테스팅을 위하여 CPU 쓰로틀링을 제공해요.
또한 개발 모드에서의 성능 측정을 통해서는 가장 정확한 결과를 얻을 수는 없어요. (예를 들어, 만약 엄격한 모드가 켜져 있다면 각 컴포넌트 렌더링은 한 번이 아니라 두 번씩 실행돼요.) 가장 정확한 시간을 얻으려면 프로덕션 환경에서 앱을 실행하고 사용자가 가진 기기로 테스트하세요.
Resetting all state when a prop changes | prop이 변경될 때 모든 상태 초기화하기
이 ProfilePage
컴포넌트는 useId
prop을 받아요. 페이지는 덧글 입력을 포함하고 있고 그 값을 저장하기 위하여 comment
상태 변수를 사용해요. 어느 날, 문제점을 알아차렸어요. 하나의 프로필에서 다른 프로필로 이동할 때, comment
상태는 초기화되지 않아요. 결론적으로, 다른 사용자의 프로필에 실수로 덧글을 남기기 쉬워요. 이 문제를 해결하기 위하여 userId
가 변할 때마다 comment
상태 변수를 초기화해야해요.
export default function ProfilePage({ userId }) {
const [comment, setComment] = useState('');
// 🔴 이펙트에서 prop이 변경될 때 상태를 초기화하기 마세요.
useEffect(() => {
setComment('');
}, [userId]);
// ...
}
ProfilePage
와 그 자식 컴포넌트들은 기존 값으로 먼저 렌더링이 되고 다시 렌더링이 되기 때문에 비효율적이에요. 또한 ProfilePage
안에서 이 상태를 갖고 있는 모든 컴포넌트에서 이 작업을 진행해야하기 때문에 복잡해요. 예를 들어 만약 덧글 UI가 중첩되어 있다면 중첩된 덧글 상태 또한 초기화해야해요.
대신, 리액트에게 명시적인 키를 제공하여 각 사용자의 프로필이 개념적으로는 다른 프로필이라는 것을 알려줄 수 있어요. 컴포넌트를 둘로 분리하고 외부 컴포넌트에서 내부 컴포넌트로 key
속성을 전달하세요.
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ 키가 변경되면 아래 상태들은 자동적으로 초기화돼요.
const [comment, setComment] = useState('');
// ...
}
보통 리액트는 동일한 위치에서 동일한 컴포넌트가 렌더링되면 상태를 보존해요. userId
를 key
로 지정하여 Profile
컴포넌트에 전달함으로써 리액트에게 다른 userId
를 가진 두 Profile
컴포넌트를 어떤 상태도 공유하지 않는 두 개의 다른 컴포넌트로 여기라고 말하는 거예요. (userId
라고 설정한) key가 변경될 때마다 리액트는 DOM을 재생성하고 Profile
컴포넌트와 그 자식 컴포넌트의 상태를 초기화해요. 이제 comment
필드는 프로필을 이동할 때 자동으로 정리돼요.
이제 이 예시에서는 외부 ProfilePage
컴포넌트만 내보내어 프로젝트의 다른 파일에서 볼 수 있어요. ProfilePage
를 렌더링하는 컴포넌트는 key를 전달할 필요가 없어요. useId
를 일반적인 prop으로 전달해요. ProfilePage
가 내부 Profile
컴포넌트에 userId
를 key
로 전달했따는 사실은 구현의 세부사항이에요.
Adjusting some state when a prop changes | prop이 변경될 때 상태 조정하기
때때로 prop이 바뀔 때 몇몇 상태를 초기화 하거나 조정해야하지만 전체 상태를 바꾸는 것은 아닐 때가 있어요.
이 List
컴포넌트는 prop으로 items
의 목록을 받고 선택된 아이템을 selection
상태 변수에서 갖고 있어요. items
prop이 다른 배열을 받을 때마다 selection
을 null
로 초기화해야해요.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 🔴이펙트에서 prop이 변할 때마다 상태를 조정하지 마세요!
useEffect(() => {
setSelection(null);
}, [items]);
// ...
}
이 또한 이상적이진 않아요. items
가 수정될 때마다 List
와 자식 컴포넌트는 이전의 selection
값으로 먼저 렌더링 돼요. 그 다음에 리액트는 DOM을 업데이트하고 이펙트를 실행해요. 마지막으로 setSelection(null)
호출은 List
와 자식 컴포넌트의 리렌더링을 발생시키고 이는 전체 프로세스를 다시 하는 것과 똑같아요.
이펙트를 지우는 것부터 시작할게요. 대신 렌더링을 하는 동안 상태를 직접 수정하세요.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
// 렌더링하는 동안 상태를 수정하는 것이 앞의 예시보단 나아요.
const [prevItems, setPrevItems] = useState(items);
if (items !== prevItems) {
setPrevItems(items);
setSelection(null);
}
// ...
}
이처럼 이전 렌더링에서 정보를 저장하는 것은 이해하기 어려울 수 있지만 이펙트에서 같은 상태를 업데이트하는 것 보다는 나아요. 리액튼느 List
를 return
문으로 탈출한 직후에 리렌더링할 거예요. 리액트는 아직 List
의 자식 컴포넌트를 렌더링하거나 DOM을 업데이트하는 일을 수행하지는 않아요. 따라서 List
의 자식 컴포넌트는 이전의 selection
값으로 렌더링되는 과정을 건너 뛰어요.
렌더링을 하는 동안 컴포넌트를 업데이트할 때, 리액트는 반환된 JSX를 던지고 즉시 렌더링을 재시도해요. 매우 느린 연쇄적인 재시도를 피하기위하여 리액트는 렌더링을 하는 동안 동일한 컴포넌트의 상태만을 업데이트해요. 만약 다른 컴포넌트의 상태가 렌더링을 하는 동안 업데이트 된다면 에러가 발생해요. items !== prevItems
와 같은 조건문은 반복문을 탈출하기 위해 필수적이에요. 이렇게 상태를 조정하겠지만 다른 사이드 이펙트(DOM 변경이나 타임아웃 설정과 같은)는 이벤트 핸들러나 이펙트에서 상태를 순수하게 유지하기 위하여 남아있어요.
이 패턴은 이펙트보다 효율적임에도 불구하고 대부분의 컴포넌트는 둘 다 사용하지 않아요. 어떤 방법을 사용하든, props나 다른 상태에 기반하여 상태를 조정하는 것은 데이터 흐름을 이해하거나 디버깅하기 더욱 어렵게 만들어요. 모든 상태를 키를 사용하여 초기화할 수 있는지 또는 렌더링 하는 동안 모든 것들 계산할 수 있는지를 항상 확인하세요. 예를 들어 선택된 항목을 저장(하고 초기화)하는 대신 선택된 항목의 ID를 저장할 수도 있어요.
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ 최고의 방법은 렌더링 하는 동안 모든 것을 계산하는 거예요
const selection = items.find(item => item.id === selectedId) ?? null;
// ...
}
이제는 상태를 "조정"할 필요가 전혀 없어요. 만약 선택된 ID의 항목이 목록에 잇다면 선택된 상태로 유지돼요. 만약 그렇지 않다면 렌더링을 하는 동안 계산된 selection
은 null
이 될 거예요. 왜냐하면 맞는 항목이 없기 때문이에요. 이 동작은 다르지만 items
의 변경사항의 대부분은 선택된 항목을 갖고 있기 때문이 틀림없이 더 나은 방법이에요.
Sharing logic between event handlers | 이벤트 핸들러 간 로직 공유하기
상품을 구매하도록 해주는 두 개의 버튼(구매와 결제)을 가진 상품 페이지가 있어요. 사용자가 장바구니에 상품을 추가할 때마다 알림을 보여주고 싶어요. showNotification()
을 두 버튼의 클릭 핸들러에서 호출하는 것은 반복적이기 때문에 이 로직을 이펙트에 넣고 싶을 거예요.
function ProductPage({ product, addToCart }) {
// 🔴 이펙트에서 이벤트에 한정적인 로직을 사용하지 마세요.
useEffect(() => {
if (product.isInCart) {
showNotification(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
이펙트는 불필요해요. 또한 버그를 발생시킬 확률도 높아요. 예를 들어, 앱이 페이지가 리로드되어도 장바구니를 "기억"하고 있다고 할게요. 만약 상품을 장바구니에 넣고 페이지를 새로고침한다면 알림창은 다시 뜰 거예요. 이 상품 페이지를 새로고침할 때마다 보일 거예요. 왜냐하면 product.isInCart
는 이미 페이지가 로드될 때 true
이기 때문에 위의 이펙트는 showNotification()
을 호출할 거예요.
어떤 코드를 이펙트에 넣을지 혹은 이벤트 핸들러에 넣을지 확신할 수 없을 때는 스스로 왜 이 코드가 실행되어야하는지를 물어보세요. 컴포넌트가 사용자에게 보여졌기 때문에 실행되어야만 하는 코드에서만 이펙트를 사용하세요. 이 예시에서 알림은 사용자가 페이지가 보여졌기 때문이 아니라버튼을 눌렀기 떄문에 보여져요. 이펙트를 삭제하고 공유된 로직을 이벤트 핸들러에서 호출된 함수에 넣으세요.
function ProductPage({ product, addToCart }) {
// ✅ 이벤트에 한정적인 로직은 이벤트 핸들러에서 호출하는 게 좋아요
function buyProduct() {
addToCart(product);
showNotification(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
이렇게 하면 불필요한 이펙트를 삭제하고 버그를 해결할 수 있어요.
Sending a POST request | POST 요청 전송하기
이 Form
컴포넌트는 두 종류의 POST 요청을 전송해요. 마운트가 되면 통계적 이벤트를 전송하고, 폼이 채워지고 제출 버튼을 누르면 /api/register
엔드포인트로 POST 요청이 전송돼요.
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Good: 컴포넌트가 화면에 나타났기 때문에 로직이 실행돼요.
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
// 🔴 이펙트에서 이벤트에 한정적인 로직을 사용하지 마세요.
const [jsonToSubmit, setJsonToSubmit] = useState(null);
useEffect(() => {
if (jsonToSubmit !== null) {
post('/api/register', jsonToSubmit);
}
}, [jsonToSubmit]);
function handleSubmit(e) {
e.preventDefault();
setJsonToSubmit({ firstName, lastName });
}
// ...
}
이전의 예시와 같은 기준을 적용해볼게요.
통계에 대한 POST 요청은 이펙트 안에 있어요. 통계 이벤트를 전송하는 이유가 폼이 보여졌기 때문에 이펙트 안에 들어가 있어요. (개발 모드에서 두 번 실행되지만 어떻게 다루는지 보려면 여기를 확인하세요.
그러나 /api/register
POST 요청은 폼이 보여지는 것에서 발생하지 않아요. 이 요청은 사용자가 버튼을 누를 때라는 특정한 순간에만 전송돼요. 특정한 상호작용이 일어날 때만 발생해야해요. 두 번째 이펙트를 삭제하고 이벤트 핸들러로 POST 요청을 옮기세요.
function Form() {
const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState('');
// ✅ Good: 컴포넌트가 화면에 나타났기 때문에 로직이 실행돼요
useEffect(() => {
post('/analytics/event', { eventName: 'visit_form' });
}, []);
function handleSubmit(e) {
e.preventDefault();
// ✅ Good: 이벤트에 한정적인 로직은 이벤트 핸들러에서 호출하세요
post('/api/register', { firstName, lastName });
}
// ...
}
어떤 로직을 이벤트 핸들러 넣을지, 이펙트에 넣을지를 선택할 때 대답해야하는 가장 중요한 질문은 '사용자의 관점에서 이 로직은 어떤 종류인가?'에요. 만얄 이 로직이 특정한 상호작용으로 발생한다면 이벤트 핸들러 안에 두세요. 만약 사용자가 화면에서 컴포넌트를 봤기 때문에 발생했다면 이펙트 안에 두세요.
Chains of computations | 계산 체이닝
때때로 다른 상태에 기반한 상태의 일부를 조정하는 여러 이펙트를 체이닝하여 실행하고 싶을 수 있어요.
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 오로지 서로를 트리거하려고 상태를 조정하는 이펙트를 피하세요
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);
}
}
// ...
이 코드에는 두 가지 문제점이 있어요.
첫 번째 문제는 굉장히 비효율적이라는 거예요. 컴포넌트(와 자식 컴포넌트)는 체인에서 set
호출 사이에 리렌더링 되어야해요. 위의 예시에서 가장 나쁜 경우(setCard
→ 렌더링 → setColdCardCart
→ 렌더링 → setRound
→ 렌더링 → setIsGameOver
→ 렌더링)에서는 트리에서 불필요한 3번의 리렌더링이 생겨요.
이 작업이 설령 느리지 않다록 하더라도 코드가 커져가면서 "체인"을 작성한 곳이 새로운 요구사항에 맞지 않는 경우가 생겨요. 게임 이동 기록을 표시하는 방법을 추가한다고 생각해보세요. 이전의 값으로 상태 변수를 업데이트하여 이 과정을 실행할 거예요. 하지만 card
라는 상태를 이전의 값으로 설정하면 이펙트 체인을 다시 실행시키고 보고 있는 데이터를 바꿔요. 어떤 코드는 위험하고 깨지기 쉬워요.
이 경우에서 렌더링을 하는 동안 할 수 있는 부분을 계산하고 이벤트 핸들러에서 상태를 조정하는 것이 좋아요.
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.');
}
// ✅ 이벤트 핸들러에서 모든 다음 상태를 계산하세요.
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
이 코드는 훨씬 효율적이에요. 또한 게임 기록을 보는 방법을 구현한다면 다른 모든 값을 조정하는 이펙트 체이닝을 발생시키지 않고서도 이전의 움직임을 상태 변수로 정할 수 있어요. 만약 여러 이벤트 핸들러에서 로직을 재사용하고 싶다면 함수로 추출하고 핸들러에서 이 함수를 호출하세요.
이벤트 핸들러 안에서 상태는 스냅샷처럼 동작한다는 사실을 기억하세요. 예를 들어 setRound(round + 1)
을 호출한 이후더라도 round
변수는 사용자가 버튼을 클릭한 순간의 값만을 반영해요. 만약 계산을 위해 다음 변수를 사용해야한다면 const nextRound = round + 1
과 같이 직접 정의하세요.
어떤 경우에서는 이벤트 핸들러에서 다음 상태를 직접 계산할 수 없어요. 예를 들어 이전의 드롭다운에서 선택된 값에 따라 다음 드롭다운의 옵션이 변하는 여러개의 드롭다운을 가진 폼을 생각해보세요. 이 경우에서는 네트워크와 동기화되어야하기 때문에 이펙트 체인이 적절해요.
Initializing the application | 어플리케이션 초기화하기
어떤 로직은 앱이 로드될 때망 실행돼요.
최상위 컴포넌트에서 이런 로직을 이펙트에 넣고싶을 거예요.
function App() {
// 🔴 한 번만 실행되어야하는 로직을 이펙트에 넣지 마세요.
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
그러나 개발 환경에서는 두 번 실행된다는 것을 재빨리 발견할 거예요. 여기서 문제가 발생하는데, 예를 들면 함수가 두 번 호출되도록 구현되지 않았기 때문에 인증 토큰을 무효화할 수도 있어요. 일반적으로 컴포넌트는 탄련적으로 마운트를 해요. 최상위의 App
컴포넌트도 여기 포함돼요.
실제로 프로덕션 환경에서 다시 마운트가 되지 않더라도, 모든 컴포넌트에서 같은 제약 조건에 따르면 코드를 이동하고 재사용하기가 쉬워져요. 만약 동알한 로직이 컴포넌트가 마운트 될 때마다 실행되는 것이 아니라 앱이 로드될 때만 실행된다면 이미 실행되었는지를 추적하는 최상위 변수를 추가하세요.
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ 앱이 로드될 때만 실행
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
모듈을 초기화하거나 앱이 렌더링 되기 전에도 실행할 수 있어요.
if (typeof window !== 'undefined') { // 브라우저에서 실행 중인지 확인하세요
// ✅ 앱이 로드될 때만 실행
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
최상위에 있는 코드는 결국 렌더링이 되지 않는다하더라도 컴포넌트가 임포트될 때 한 번만 실행돼요. 임의의 컴포넌트를 불러올 때 속도가 저하되거나 예상치 못한 동작을 하는 것을 피하기 위하여 이 패턴을 남용하진 마세요. App.js
와 같은 루트 컴포넌트 모듈이나 어플리케이션의 진입점에만 앱 전체를 초기화하는 로직을 추가하세요.
Notifying parent components about state changes | 상태 변화에 대해 부모 컴포넌트에 알리기
true
나 false
를 값으로 가질 수 있는 isOn
이라는 내부 상태를 가진 Toggle
컴포넌트가 있어요. 이 컴포넌트를 토글하는 방법은 몇가지가 있어요. (클릭이나 드래그 등등) 부모 컴포넌트에 Toggle
의 내부 상태가 변화할 때마다 알림을 보내서 onChange
이벤트를 노출하고 이펙트에서 이를 호출하고 싶어요.
function Toggle({ onChange }) {
const [isOn, setIsOn] = useState(false);
// 🔴 onChange 핸들러가 너무 늦게 실행해요
useEffect(() => {
onChange(isOn);
}, [isOn, onChange])
function handleClick() {
setIsOn(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
setIsOn(true);
} else {
setIsOn(false);
}
}
// ...
}
이전의 예시들처럼 이는 이상적이지 않아요. Toggle
은 상태를 먼저 업데이트하고 리액트는 화면을 업데이트 해요. 그리고나서 리액트는 이펙트를 실행하고, 이펙트는 부모 컴포넌트로부터 내려받은 onChange
함수를 호출해요. 이제 부모 컴포넌트는 자신의상태를 업데이트하고 또 다른 렌더링 패스를 시작해요. 이 과정을 한 패스에서 모두 수행하는 것이 더 나은 방법이에요.
이펙트를 지우는 대신 동일한 이벤트 핸들러에서 두 컴포넌트의 상태를 업데이트하세요.
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);
}
}
// ...
}
이 방법으로 Toggle
컴포넌트와 부모 컴포넌트 모두 이벤트 동안 상태를 업데이트해요. 리액트는 서로 다른 컴포넌트에서 일괄적으로 업데이트하기 때문에 한 번의 렌더링 패스만 있어요.
상태를 전부 지우는 대신 isOn
을 부모 컴포넌트에서 받는 방법도 있어요.
// ✅ Good: 컴포넌트가 완벽히 부모에 의해 조작돼요
function Toggle({ isOn, onChange }) {
function handleClick() {
onChange(!isOn);
}
function handleDragEnd(e) {
if (isCloserToRightEdge(e)) {
onChange(true);
} else {
onChange(false);
}
}
// ...
}
상태를 끌어올리면 부모 컴포넌트는 자신의 상태를 토글링하여 Toggle
컴포넌트를 조작해요. 이는 즉, 부모 컴포넌트가 더 많은 로직을 갖고 있지만 전체적으로는 걱정되는 상태가 줄어들어요. 두 개의 상태 변수를 동기화하려고 할 때마다 상태를 끌어올리는 것도 시도해보세요!
Passing data to the parent | 부모에 데이터 전달하기
이 Child
컴포넌트는 이펙트 안에서 데이터를 페칭하고 Parent
컴포넌트로 전달해요.
function Parent() {
const [data, setData] = useState(null);
// ...
return <Child onFetched={setData} />;
}
function Child({ onFetched }) {
const data = useSomeAPI();
// 🔴 이펙트에서 부모로 데이터를 전달하지 마세요
useEffect(() => {
if (data) {
onFetched(data);
}
}, [onFetched, data]);
// ...
}
리액트에서 데이터는 부모 컴포넌트에서 자식 컴포넌트로 흘러요. 화면에서 무언가 잘못된 것을 보았을 때, 어떤 컴포넌트가 잘못된 prop을 전달했는지 혹은 잘못된 상태를 갖고 있는지를 찾을 때까지 컴포넌트를 타고 올라가서 어디서 정보가 왔는지를 추적할 수 있어요. 자식 컴포넌트가 부모 컴포넌트의 상태를 이펙트에서 업데이트할 때, 데이터 흐름은 추적하기 매우 어려워져요. 부모와 자식 컴포넌트 모두에서 같은 데이터가 필요하다면 부모 컴포넌트에서 데이터를 페치하고 자식으로 내리세요.
function Parent() {
const data = useSomeAPI();
// ...
// ✅ Good: 자식에게 데이터를 내려주세요
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
이 코드가 더욱 단순하고 데이터 흐름을 예측가능하게 만들어줘요. 데이터는 부모에서 자식으로 흘러요.
Subscribing to an external store | 외부 저장소 구독하기
때때로 컴포넌트는 리액트 상태 외부의 데이터를 구독해야해요. 이 데이터는 서드 파티 라이브러리나 브라우저 내장 API가 될 수 있어요. 이 데이터는 리액트의 기능만으로 바꿀 수 없기 때문에 컴포넌트에서 수동적으로 구독해주어야해요. 보통 이 때 이펙트를 사용해요.
function useOnlineStatus() {
// 이펙트에서 수동으로 저장소를 구독하는 것은 이상적이지 않아요
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();
// ...
}
이 컴포넌트는 외부 데이터 저장소를 구독해요. (현재는 브라우저의 navigator.online
API를 구독해요.) 이 API는 서버에 존재하지 않기 때문에 (초기 HTML에서는 사용할 수 없어요.) 맨 처음 상태는 true
로 설정돼요. 데이터 저장소의 값이 브라우저에서 바뀌면 컴포넌트는 상태를 업데이트해요.
이러한 경우에 이펙트를 사용하는게 일반적이지만 리액트에서는 외부 저장소를 구독하는 특수한 내장 훅이 더 선호돼요. 이펙트를 삭제하고 useSyncExternalStore
을 호출하여 이를 대체하세요.
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ Good: 내장 훅을 사용하여 외부 저장소를 구독하세요
return useSyncExternalStore(
subscribe, // 리액트는 동일한 함수를 전달하는 한 재구독하지않아요
() => navigator.onLine, // 클라이언트에서 값을 가져오는 방법
() => true // 서버에서 값을 가져오는 방법
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
이러한 방법은 이펙트를 사용하여 리액트 상태에서 변경 가능한 데이터를 직접 동기화 하는 것보다 에러가 발생할 확률이 낮아요. 전형적으로 useOnlineStatus()
와 같은 커스텀훅을 사용하여 이 코드를 개별 컴포넌트에서 반복하지 않아요. 리액트 컴포넌트에서 외부 저장소를 구독하는 방법에 대해 더 알아보고 싶다면 이 문서를 읽어보세요.
Fetching data | 데이터 페칭하기
많은 앱은 데이터 페칭을 중단하기 위해 이펙트를 사용해요. 아래와 같은 데이터 페칭 이펙트를 작성하는 것은 제법 흔해요.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 클린업 로직 없이 페칭하지 마세요
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
이 페칭 로직을 이벤트 핸들러로 옮길 필요는 없어요.
이벤트 핸들러에 로직을 넣어야했던 이전의 예시와 모순된다고 느낄 수도 있어요! 그러나 페치를 해야하는 가장 중요한 이유는 타이핑 이벤트가 아니에요. 검색창은 보통 URL에서 미리 채워지고 사용자는 입력창을 누르지 않고 앞뒤로 이동할 거예요.
page
와 query
가 어디서 오는지는 중요하지 않아요. 이 컴포넌트가 보이는 동안 현재 page
와 query
를 위해 네트워크에서 받아온 데이터를 results
와 동기화할 거예요. 따라서 이펙트에 있어야해요.
그러나 위의 코드에는 버그가 있어요. "hello"
를 빨리 입력한다고 할게요. 그러면 query
는 "h"에서 "he"
, "hel"
,"hell"
, "hello"
로 변해요. 각 페치를 중단하지만 응답이 되돌아오는 순서를 보장할 수는 없어요. 그 예로 "hell"
의 응답은 "hello"
의 응답 이후에 올 수도 있어요. setResults()
를 나중에 호출하기 때문에 잘못된 검색 결과를 보여줄 거예요. 이를 "경쟁 상태"라고 해요. 두 개의 다른 요청은 서로 경쟁하고 예상한 것과는 다른 순서로 들어와요.
경쟁 상태를 해결하기 위해서 클린업 함수를 추가하여 이전의 응답을 무시하세요.
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
이펙트가 데이터를 페칭할 때 마지막 요청의 응답을 제외한 모든 응답은 무시돼요.
경쟁 상태를 처리하는 것만이 데이터 페칭을 구현하는데 생기는 유일한 어려움은 아니에요. 응답을 캐싱하는 것(그러면 사용자가 뒤로가기를 누를 수 있고 이전 화면을 즉시 볼 수도 있어요), 서버에서 데이터를 페칭하는 방법(그러면 초기에 서버에서 렌더링되는 HTML이 스피너 대신 페칭한 콘텐츠를 포함해요), 네트워크 워터폴을 피하는 방법(그러면 자식 컴포넌트는 모든 부모를 기다릴 필요 없이 데이터를 페칭할 수 있어요.)을 생각할 수 있어요.
이러한 이슈는 리액트 뿐만 아니라 UI 라이브러리에서도 적용돼요. 이 문제를 해결하는 것은 결코 사소한 문제가 아니에요. 이는 모던 프레임워크가 이펙트에서 데이터를 페칭하는 것 보다 더 효율적인 내장 데이터 페칭 매커니즘을 제공하는 이유에요.
만약 프레임워크를 사용하지 않지만 (그리고 직접 만들고 싶지도 않지만) 이펙트에서 데이터를 더욱 인간 공학적으로 페칭하고싶다면 페칭 로직을 아래 예시처럼 커스텀 훅으로 추출하는 것을 고려해보세요.
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;
}
에러를 처리하기 위한 로직을 추가하고 콘텐츠가 로딩되었는지를 추적하고 싶을 수도 있어요. 이와 같은 훅을 직접 만들거나 이미 리액트 생태계에서 존재하는많은 솔루션 중 하나를 사용할 수도 있어요. 이것만으로는 프레임워크의 내장 데이터 페칭 매커니즘만큼 효율적이지 않음에도 불구하고 데이터 페칭 로직을 커스텀 훅으로 옮기면 효율적인 데이터 페칭 전략을 이후에 추가하기도 쉬울 거예요.
일반적으로 이펙트 사용에 의지해야 할때마다 언제 기능의 일부를 더 선언적이고 위의 useData
와 같이 특수작된 커스텀 훅으로 추출하는지에 주목하세요. useEffect
자체를 컴포넌트에서 덜 호출할 수록 어플리케이션을 유지하기 더 쉬워져요.
Recap | 요약
- 만약 렌더링을 하는 동안 계싼할 수 있다면 이펙트를 사용할 필요가 없어요.
- 고비용의 계산을 캐싱하려면
useEffect
대신useMemo
를 사용하세요. - 전체 컴포넌트 트리의 상태를 리셋하기 위해서는 다른
key
를 전달하세요. - prop 변경에 따라 특정한 몇몇 상태를 초기화하기 위해서는 렌더링 동안 설정하세요.
- 컴포넌트가 보여졌기 때문에 실행하는 코드는 이펙트에 들어가야하고, 나머지는 이벤트에 넣으세요.
- 만약 여러 컴포넌트에서 상태를 업데이트해야한다면 하나의 이벤트동안 변경하는 것이 더 좋아요.
- 다른 컴포넌트의 상태를 동기화시키려고 할 때마다 상태를 끌어올리는 것을 고민해보세요.
- 이펙트로 데이터를 페칭할 수 있지만 경쟁상태를 피하려면 클린업 로직을 구현해야해요.
Challenges | 도전 과제
1. 이펙트 없이 데이터 변형하기
아래의 TodoList
는 할 일 목록을 보여줘요. "활성화된 할 일만 보기"라는 체크박스를 선택하면 완료된 할 일은 목록에서 보이지 않아요. 어떤 할 일이 보이는지와는 상관 없이 footer는 아직 완료되지 않은 할 일의 개수를 보여줘요.
모든 불필요한 상태와 이펙트를 제거하여 이 컴포넌트를 단순하게 만드세요.
Hint
렌더링을 하는 동안 무언가를 계산할 수 있다면 상태 또는 상태를 업데이트하는 이펙트가 필요하지 않아요.
2. 이펙트 없이 계산 캐싱하기
이 예시에서 할 일을 필터링하는 것은 getVisibleTodos()
라고 불리는 분리된 함수로 추출되었어요. 이 함수는 언제 이 함수가 호출되었는지를 알 수 있도록 내부에서 console.log()
를 호출해요. "활성화된 할 일만 보기"를 선택하면 getVisibleTodos()
가 재실행된다는 것을 확인하세요. 이것은 예상된 동작이에요. 왜냐하면 보이는 할 일들은 어떤 것을 보여줄지를 토글할 때 변경되기 때문이에요.
여러분의 과제는 TodoList
컴포넌트 안에서 visibleTodos
목록을 재계산하는 이펙트를 제거하는 거예요. 그러나 입력창에 타이핑을 할 때 getVisibleTodos()
는 재실행 되어서는 안돼요.
Hint
한 가지 해결책은 보이는 할일들을 캐싱하기 위해 useMemo
훅을 추가하는 거예요. 조금 덜 명확한 다른 해결책도 있어요.
Solution
상태 변수와 이펙트를 제거하고 그 대신 useMemo
를 호출하여 getVisibleTodos()
의 호출 결과를 캐싱하세요.
이렇게 바꾸면 getVisibleTodos()
는 todos
또는 showActive
가 바뀌었을 때만 호출돼요. 입력창에 타이핑을 하는 것은 text
만 바꾸기 때문에 getVisibleTodos()
를 호출하지 않아요.
useMemo
가 필요하지 않는 해결책도 있어요. text
는 할 일 목록에 영향을 미칠 수 없기 때문에 newTodo
폼을 별개의 컴포넌트로 추출하고 text
상태 변수를 그 안으로 옮길 수 있어요.
이 방법도 요구사항을 만족해요. 입력창에 타이핑을 치면 text
상태 변수만 업데이트 돼요. text
상태 변수는 자식인 NewTodo
컴포넌트 안에만 존재하기 떄문에 부모인 TodoList
컴포넌트는 리렌더링되지 않아요. getVisibleTodos()
가 타이핑을 할 때 호출되지 않는 이유가 바로 이 때문이에요. (TodoList
가 다른 이유로 리렌더링 된다면 여전히 호출되기는 해요.)
3. 이펙트 없이 상태 초기화하기
이EditContact
컴포넌트는 { id, name, email }
처럼 생긴 연락처 객체를 savedContact
prop으로 받아요. 입력창에서 이름과 이메일을 수정해보세요. 저장하기를 눌렀을 때 폼 위에 있는 연락처의 버튼은 수정된 이름으로 업데이트해요. 초기화 버튼을 누르면 폼의 모든 보류중인 변경사항들이 취소돼요. 느낌을 익히려면 이 UI를 사용해보세요.
위쪽의 버튼으로 연락처를 선택하면 폼은 연락처의 세부사항을 반영하지 않고 초기화 해요. 이는 EditContact.js
안에 있는 이펙트로 수행돼요. 이 이펙트를 제거하세요. savedContact.id
가 변경될 때 폼을 초기화하는 다른 방법을 찾으세요.
Hint
리액트에게 savedContact.id
가 변경되면 EditContact
폼은 개념적으로 다른 연락처의 폼이며 상태를 보존하면 안된다고 알려줄 수 있는 방법이 있다면 더 좋을 것 같아요. 그런 방법이 있을까요?
Solution
EditContact
컴포넌트를 둘로 분리하세요. 폼과 관련된 모든 상태를 내부의 EditForm
컴포넌트로 옮기세요. 바깥의 EditContact
컴포넌트를 추출하고 savedContact.id
를 key
로 내부 EditForm
컴포넌트에 전달하도록 만드세요. 결론적으로 내부의 EditForm
컴포넌트는 폼 상태를 모두 초기화하고 다른 연락처를 선택할 때마다 DOM을 재생성해요.
4. 이펙트 없이 폼 제출하기
이 Form
컴포넌트는 친구에게 메시지를 보내도록 해줘요. 폼을 제출하면 showForm
상태는 false
로 설정돼요. 이는 sendMessage(message)
를 호출하는 이펙트를 발생시키고 이는 메시지를 보내는 거예요. (콘솔에서 볼 수 있어요.) 메시지가 전송된 후에, 여러분은 폼으로 되돌아갈 수 있도록 만들어주는 "Open chat" 버튼과 함께 "Thank you"라는 다이얼로그를 보게 돼요.
사용자가 너무나 많은 메시지를 보내고 있어요. 채팅을 만드는 것을 조금 더 어렵게 만들기 위하여 "Thank you"라는 다이얼로그를 폼보다 먼저 보여주기로 결정했어요. showForm
상태 변수를 true
대신 false
로 초기화하세요. 이렇게 바꾸자마자 콘솔은 빈 메시지가 보내졌다고 보여줄 거예요. 이 로직은 뭔가 이상해요!
이 문제의 근본적인 원인은 무엇일까요? 어떻게 해결할까요?
Hint
사용자가 "Thank you" 라는 다이얼로그를 봤기 때문에 메시지를 보내야하나요? 아니면 그 반대인가요?
Solution
showForm
은 폼을 보여줄지 "Thank you"라는 문구를 보여줄지를 결정해요. 그러나 "Thank you"가 보여졌기 때문에 메시지를 전송하지 않아요. 사용자가 폼을 제출했기 때문에 메시지를 보내고 싶을 거예요. 오해의 소지가 있는 이펙트를 제거하고 senMessage
를 handleSubmit
이벤트 핸들러 안에서 호출하세요.
이 버전에서 폼을 제출했을 때만 (이는 이벤트에요) 메시지가 발송되도록 만드는 방법에 주목하세요. showForm
이 초기에 true
로 설정되든 false
로 설정되든 잘 작동해요. (false
로 설정하고 추가적인 콘솔 메시지가 안나오는 것을 확인하세요.)
'리액트 공식문서 | React Docs > Learn > Learn React' 카테고리의 다른 글
[Escape Hatches] Lifecycle of Reactive Effects | 반응형 이펙트의 생명주기 (0) | 2024.03.07 |
---|---|
[Escape Hatches] Synchronizing with Effects | 이펙트와 동기화하기 (0) | 2024.03.04 |
[Escape Hatches] Manipulating the DOM with Refs | ref로 DOM 조작하기 (0) | 2024.03.03 |
[Escape Hatches] Referencing Values with Refs | ref로 값 참조하기 (0) | 2024.03.03 |
[Managing State] Scaling Up with Reducer and Context | 리듀서와 컨텍스트로 확장하기 (1) | 2024.02.28 |