- 자신의
state
가 바뀔 때- 부모 컴포넌트로부터 받아오는
props
가 바뀔 때- 부모 컴포넌트가 리렌더링될 때
- forceUpdate를 통한 강제 업데이트 시
앱을 구성하는 컴포넌트가 많아질수록, 또 그것들이 자주 리렌더링될수록 성능 최적화를 고려하는 것이 좋다.
간단한 일정 관리 앱의 구조를 가정하자. 상위 컴포넌트부터 차례대로...
TodoApp
: todo
객체들로 이루어진 배열 todos
를 자신의 state로 가지며, 이에 대한 추가/수정/삭제 함수를 정의하고 자식 컴포넌트인 TodoList
에게 props로 전달한다.TodoList
: props로 받은 todos
를 상응하는 TodoItem
컴포넌트들로 렌더링하며, 수정/삭제 함수를 props로 전달한다.TodoItem
: props로 받은 todo
의 정보를 표시하고 수정/삭제 함수를 이벤트 리스너에 등록한다.만약 우리가 새로운 일정을 todos
에 추가한다면, 이것을 state로 가지는 TodoApp
이 먼저 리렌더링되고 그 자식들도 연달아 리렌더링될 것이다.
문제는 굳이 리렌더링될 필요가 없음에도 위의 규칙에 따라 리렌더링이 발생하는 컴포넌트가 존재한다는 것이다. (예: 새로 추가된 TodoItem
외 모든 TodoItem
이 리렌더링됨)
먼저, 고차 컴포넌트 React.memo()
이다. 다른 컴포넌트를 인수로 받으며, 해당 컴포넌트가 React.memo()
로 감싸지고 나면 이 컴포넌트가 받는 props가 바뀌지 않는 한 리렌더링되지 않는다.
고차 컴포넌트: 컴포넌트를 가져와 새 컴포넌트를 반환하는 함수
다음은 useCallback()
hook이다. 주로 자식 컴포넌트에게 props로 전달하려는 함수에 적용한다. 첫 번째 인자는 함수, 두 번째 인자는 의존 배열(deps)로, 이 안에 포함된 값이 바뀌면 해당 함수를 새롭게 생성하고, 그렇지 않다면 이전에 생성한 함수를 기억해두고 그대로 사용한다.
// TodoApp.js
const TodoApp () => {
const onRemove = useCallback((id) => {
setTodos(todos.filter((todo) => todo.id !== id));
}, [todos]);
...
return <TodoList onRemove={onRemove}, ... />
};
// TodoItem.js
const TodoItem = ({ onRemove, ... }) => {
...
}
export default React.memo(TodoItem);
이 둘은 함께 쓰일 때 그 효력을 발휘한다. 우선 자식 컴포넌트인 TodoItem
에 React.memo()
를 적용하여 props가 변하지 않을 때 리렌더링을 막는다. (= 부모의 리렌더링에 의한 단순 리렌더링을 방지)
그리고 상위 컴포넌트인 TodoApp
에 정의된 수정/삭제 함수에 useCallback
을 적용하면, (deps에 포함된 todos가 변하지 않는 한) TodoApp
이 리렌더링되더라도 이 함수를 새로 생성하지 않게 된다. 즉, TodoItem
에 전달하는 props이 바뀌지 않는다는 뜻이다.
중간 정리
useCallback()
: (의존 배열이 변하지 않는 한) 기존 함수를 재생성하지 않고 그대로 props로 내려보냄.
React.memo()
: props이 바뀌지 않으면 해당 컴포넌트를 리렌더링되지 않게 함.
➡️ 즉, 내려받는 함수 == props이 바뀌지 않으므로 컴포넌트가 리렌더링되지 않음!
그리고 마지막 도구는 useState의 함수형 업데이트이다.
useCallback
을 적용하더라도 deps에 포함된 state가 변한다면 해당 함수는 재생성된다. todos를 직접 변화시키는 수정/삭제 함수가 그렇다.
그러나 만약 함수 내에서 직접 state에 접근하여 작업을 처리하는 로직이 있다면, useState의 함수형 업데이트를 이용하여 deps에서 해당 state를 제외할 수 있다.
const onRemove = useCallback((id) => {
setTodos(todos.filter((todo) => todo.id !== id));
}, [todos]); // 일반적인 setter
const onRemove = useCallback((id) => {
setTodos((todos) => todos.filter((todo) => todo.id !== id));
}, []); // '함수형 업데이트' 이용
단순히 Setter의 인자로 새로운 state 값 자체가 아닌 'state를 업데이트 하는 방법을 정의하는 함수'를 넣어줬을 뿐인데, 의존 배열 내 todos
가 불필요해졌다.
이것이 가능한 이유는 Setter 내에서 todos
를 직접 참조할 수 있게 되었기 때문이다. 따라서 이제 useCallback
은 현재의 todos
값을 기억하지 않아도 된다.
또한, 가장 처음 state를 정의할 때 useState
대신 useReducer
의 dispatch()
를 사용하여 state 관련 로직을 컴포넌트 바깥으로 분리함으로써 동일한 효과를 얻을 수 있다.
const onRemove = useCallback((id) => {
dispatch({ type: 'REMOVE', payload: id })
}, []); // state를 deps에 포함시키지 않아도 된다
추가로 리스트 관련 컴포넌트를 구성할 때 react-virtualized 등의 최적화 라이브러리도 사용해보면 좋을 것 같다.