useCallback은 함수를 캐시한다. 다시말해 함수를 메모화해서 기억한다는 뜻이다. useCallback을 사용하지 않으면 컴포넌트가 렌더링될 때마다 함수도 계속 새로 만든다.
그렇다면 함수를 계속 새로 만드는 것은 낭비니 모든 함수를 useCallback을 이용해 캐시하나??? 그렇지 않다. 함수를 캐시하고 함수를 호출할 때마다 캐시된 함수를 가져오는 비용도 생각해야 한다.
부모 컴포넌트에서 자식 컴포넌트로 함수를 전달하는데, 이 함수가 변경되지 않았을 경우에만 단 1회 실행해야 할 때 useCallback으로 전달할 함수를 메모화하고, 자식 컴포넌트에서는 useEffect에서 이 전달받은 함수를 호출한다. 당연히 useEffect의 의존성 배열엔 이 함수를 추가해야 한다!!
useCallback 안에서 useState로 관리리중인 값을 사용한다면 어떤 결과가 나올까?
// parent
import { useCallback, useState } from 'react';
import Child from './Child';
const Parent = () => {
const [count, setCount] = useState(0);
const onClick = useCallback(
(val) => {
console.log('Parent!!!');
console.log(count + val);
setCount((c) => c + val);
}, []);
return (
<div>
<Child fn={onClick} />
</div>
);
};
export default Parent;
// child
import { useCallback, useEffect } from 'react';
const Child = ({ fn }) => {
useEffect(() => {
console.log('Child Effect!!!');
// fn(5);
}, [fn]);
return (
<div>
<button onClick={() => fn(5)}>click</button>
</div>
);
};
export default Child;

버튼을 아무리 클릭해도 계속 5가 출력되는 것을 확인할 수 있다. 이렇게 동작하는 이유를 생각해보자면 count를 사용하기 위해 외부 렉시컬 환경 참조를 따라 상위 렉시컬 환경으로(스코프 체이닝) 올라가며 값을 찾는것이 아니라 useCallback으로 메모화한 함수의 렉시컬 환경이 딱 한 번 생기고 렉시컬 환경의 Declarative Environment Record에 따로 count를 등록하는 것 같다.
useState로 관리하는 state와 setState함수는 이미 memo되어있다. (dispatcher에 한 번 생기고 다시 안 생김.) 이들은 state가 primitive 타입이든 reference 타입이든 리액트 dispatcher 영역에서 관리된다. 따라서 정확히는 모두 참조 형식이다.
위 예제에서 count의 기본값이 0(primitive)으로 세팅되어있는데, { num: 0 } 처럼 객체로 줘도 useCallback 함수 내에서는 primitive 타입일 때랑 똑같이 동작한다. 결국 원시값, 참조값 모두 함수의 Environment Record영역에 그냥 박혀버린다!
그래서 외부의 어떤 값을 사용할 때는 의존성 배열에 그 값(변수)을 넣어주라고 하는거였다.

BookCard를 새로 추가하는데(BookTitle3) 정상적인 동작은 빈 BookCard가 만들어져야 하지만 MarkCard들이 추가된 상태로 만들어졌다. 에러 메시지를 자세히 보면 다음과 같다.

키가 중복이다라… 콘솔로 출력해 확인해봤더니 아래와 같이 새로 추가된 bookTitle3의 id가 1이었다.

현재 books 배열에서 가장 큰 id값을 구하는 코드에는 문제가 없다.
const maxId = Math.max(...session.books.map((book) => book.id), 0);
그럼 books 배열이 혹시 비어있는걸까 하고 다시 확인해봤다. 역시나 session이 비어있었다.. 로그인 구현부분이 이상했나..? 하고 코드를 살펴보던중에 아차! 의존성 배열이 비어있네..?

🚀 의존성 배열이 비어있으니 addBook이라는 함수는 단 한 번만 만들어진다. 그런데 이 함수가 session 값을 사용하는데 아마 위에서 쓴 내 생각대로라면 session도 상위 렉시컬 환경에서 찾는게 아니라 addBook 함수의 렉시컬 환경 내에 새로 잡힐테니(초기 상태로) session이 비어있는 것이었을테다. 🚀
// src/contexts/session-context.jsx
const getBooks = (userName) => {
const bookAndMark = JSON.parse(localStorage.getItem(userName));
return bookAndMark ? bookAndMark : [];
};
const setBooks = (userName, book) => {
const books = getBooks(userName);
books.push(book);
localStorage.setItem(userName, JSON.stringify(books));
};
const reducer = (state, action) => {
switch (action.type) {
case 'LOGIN':
return {
loginUser: action.payload,
books: getBooks(action.payload),
};
case 'LOGOUT':
return {
loginUser: null,
books: [],
};
case 'ADD_BOOK':
setBooks(state.loginUser, action.payload);
state.books.push(action.payload);
return {
...state,
};
case 'ADD_MARK':
break;
case 'REMOVE_BOOK':
break;
case 'REMOVE_MARK':
break;
default:
return state;
}
};
// src/contexts/session-context.jsx
const addBook = useCallback((title) => {
const maxId = Math.max(...session.books.map((book) => book.id), 0);
dispatch({
type: 'ADD_BOOK',
payload: { id: maxId + 1, title, marks: [] },
});
}, []); // 여기 의존성 배열이 비어있음...
session은 useState로 관리중이다.
const addBook = useCallback(
(title) => {
const maxId = Math.max(...session.books.map((book) => book.id), 0);
dispatch({
type: 'ADD_BOOK',
payload: { id: maxId + 1, title, marks: [] },
});
},
[session] // 잊지말자...
);

이제 BookCard를 추가하면 정상적으로 빈 BookCard가 만들어진다!