명령형 또는 어떤 effect를 발생하는 함수를 인자로 받습니다.
리액트 공식문서
useEffect는 리액트의 생명주기를 따르는 훅이다. 리액트는 마운트, 업데이트, 언마운트 3가지 과정을 거치는데, useEffect는 이 3가지 과정을 모두 제어할 수 있는 유용한 훅이다.
useEffect의 특징은, 컴포넌트 렌더링이 끝난 '후'에 작동한다는 점이다. 그러므로 useEffect 안에 state를 변경시키는 작업을 하게 되면 안된다.
이럴 때에는 useEffect 뒤에 dependancy arr를 설정하면 된다. 즉, 한 번만 실행하고 싶은데 그것이 state를 변경하는 작업일 때 유용하다. 예를 들어 api 통신을 통해 데이터를 가져오는 구조라고 생각해보자.
const getData = async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts").then(
(res) => res.json()
);
const initData = res.slice(0, 20).map((li) => {
return {
author: li.userId,
content: li.body,
emotion: Math.floor(Math.random() * 5),
created_date: new Date().getTime(),
dataId: li.id,
};
});
setDummyData(res);
};
getData()
아무것도 모른 상태로 다음과 같이 작업하게 되면 대참사가 일어난다.
1. getData 함수 호출
2. 데이터 수신 후 state 변경
3. 렌더링 발생 .. => getData 함수 호출
그러므로 useEffect를 이용하되, dependancy arr를 빈 배열로 주게 되면, 해당 useEffect 안에 있는 콜백 함수는 컴포넌트가 처음 마운트 된 당시에만(초기 렌더링 때만) 실행되게 된다.
그러므로 데이터 통신을 통해 데이터를 가져와서 state를 바꾸더라도 빈 배열이기 때문에 어떠한 값도 참조하지 않아서 딱 1번만 실행하게 된다. 그러므로 다음과 같이 바꾸어주어야 한다.
const getData = async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts").then(
(res) => res.json()
);
const initData = res.slice(0, 20).map((li) => {
return {
author: li.userId,
content: li.body,
emotion: Math.floor(Math.random() * 5),
created_date: new Date().getTime(),
dataId: li.id,
};
});
setDummyData(initData);
};
useEffect(() => {
getData();
},[])
useEffect의 사용 정의를 다음과 같이 내린다.
변형, 구독, 타이머, 로깅 또는 다른 부작용(side effects)은 (React의 렌더링 단계에 따르면) 함수 컴포넌트의 본문 안에서는 허용되지 않습니다. 이를 수행한다면 그것은 매우 혼란스러운 버그 및 UI의 불일치를 야기하게 될 것입니다.
리액트 공식문서
타이머도 예를 들면, 타이머는 setInterval 함수를 통해 주기적으로 state를 바꾸게 될 것인데, 만약 useEffect를 거치지 않으면 state가 변하며 리렌더링 될 때 새로운 interval이 생성되어 제대로 작동하지 않는다. 그러므로 이런 함수도 딱 1번만 실행되어야 하기 때문에 useEffect의 두 번째 인자로 빈 배열을 넣어주면 interval는 한 번만 실행된 채 state를 바꾸게 된다.
또한 지금껏 컴포넌트가 마운트, 업데이트될 때만 다루었는데, 언마운트 될 때에도 처리할 수도 있다. 언마운트란, 컴포넌트가 렌더링 대상에서 제외되는 순간을 가르킨다. 예를 들어 앞서 setInterval를 이용해 인터벌 이벤트를 일으켰다면 언마운트 될 때 더이상 인터벌 이벤트가 필요 없으므로 clearInterval를 사용할 수 있다. 또 달리는 삼항연산자를 이용한 모든 언마운트 행위를 예로 들 수도 있다.
하지만 또 달리 이런 정리 함수가 필요한 이유는...(정리 함수 = 언마운트 때 return 되는 콜백 함수) 언마운트 된 컴포넌트에 state를 변경하여 메모리 누수가 일어날 수 있기 때문이다. 예들 들어, fetch나 axios와 같은 데이터 통신을 통해 데이터를 받아온다고 쳐보자. 해당 작업은 보통 비동기로 처리하기 때문에, 데이터 통신이 늦어지고, 이 중간에 사용자가 state가 포함된 컴포넌트를 언마운트 시키게 된다면, 리액트는 언마운트된 컴포넌트에 대해 state를 변경할 수 없으므로 메모리 누수와 관련된 에러를 일으킨다. 그러므로 다음과 같이 정리함수를 통해 setState 이벤트를 조절할 수 있다.
const getData = async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/posts").then(
(res) => res.json()
);
const initData = res.slice(0, 20).map((li) => {
return {
author: li.userId,
content: li.body,
emotion: Math.floor(Math.random() * 5),
created_date: new Date().getTime(),
dataId: li.id,
};
});
if(!isComponentUnmounted) {
setDummyData(initData);
}
};
useEffect(() => {
let isComponentUnmounted = false;
getData();
return () => {
isComponentUnmounted = true;
}
},[]);
다음 코드는 컴포넌트가 언마운트 될 때 state 값을 바꾸지 못하도록 조정하여 메모리 누수를 없앤 코드이다.
그럼 뭐 useEffect는 빈 배열일 때 말고는 쓸모가 없냐고? 사이드 이펙트를 처리하는데 사용할 수도 있다. 리액트는 참조하는 state의 주소값이 바뀔 때(얕은 복사) 렌더링을 일으키게 되는데, 어떤 렌더링이 된 후에 진행하게 되어 사이드 이펙트를 방지할 수 있다. 사이드 이펙트의 정의는 다음과 같다.
함수가 실행되면서 함수 외부에 존재하는 값이나 상태를 변경시키는 등의 행위
즉, 헤더 부분에서 버튼을 누르면 app 컴포넌트 전체의 배경 색이 검게 변한다거나(블랙모드), 다음 예에서 보여줄 것과 같이 해당 함수의 외부의 값을 변경하는 행위를 사이드 이펙트라고 한다.
document.title = `${dummyData.length}`;
해당 코드를 그냥 생으로 집어넣게 되면, 웹사이트의 제목은 dummyData의 길이가 변화되든지 말든지 간에 다른 요소의 리렌더링이 일어날 때마다 해당 코드를 실행하게 될 것이다. 저 정도면 괜찮지 않냐고? 쿠키를 설정하거나 파일을 읽고 쓰는 등의 행위를 리렌더링 할 때마다 수행한다면 얼마나 소요가 많을지 가늠도 가지 않는다!!
그러므로 해당 코드는 useEffect 속에 넣어 의존 배열에 dummayData.length를 넣어, 해당 데이터가 변경될 때마다 콜백을 일으키게 만들면 된다.
useEffect(() => {
document.title = `${dummyData.length}`;
}
,[dummyData.length]);
이제 해당코드는 사이드 이펙트를 완벽하게 처리했다고 볼 수 있겠다. useEffect 사용을 정리해본다면,
1) 기본적으로 state를 바꾸는 작업은 useEffect에 넣지 않는다
(렌더링 이후 실행하므로 불필요한 렌더링을 발생시킨다)
2) 초기 1번만 실행해야하지만 state를 바꾸는 작업을 사용한다.
(ex. 데이터 통신, 타이머 등을 사용할 때 dep arr에 빈 배열)
3) side-effect를 제어할 때 사용한다.
(ex. 참조하는 데이터가 달라질 때만 이용하기 위해, 그렇지 않으면 불필요하게 side-effect가 반복됨)
메모이제이션된 값을 반환합니다.
리액트 공식문서
메모이제이션은 즉 기억화된 값이다. 그러므로 기억화된 값을 반환하는 훅으로, dep arr에 참조하는 state를 적어놓으면, 해당 state의 이전값과 바뀐 값을 비교해 state의 값이 변화하면 그 state로 파생되는 여러 변수들도 실제로 값이 바뀌는 유무에 상관없이 리렌더링 하며 추가적인 연산 과정을 거치게 된다. 예를 들어 다음 코드를 봐보자.
const diaryEmotionAnalyasis = () => {
const happyCount = dummyData.filter((data) => data.emotion >= 3).length;
const unhappyCount = dummyData.length - happyCount;
const hapinessRatio = Math.ceil((happyCount / dummyData.length) * 100);
return { happyCount, unhappyCount, hapinessRatio };
};
const { happyCount, unhappyCount, hapinessRatio } = diaryEmotionAnalyasis;
다음 코드는 dummyData 안의 데이터 길이나 긍/부정도를 계산해 비율값을 연산해주는 코드이다. 하지만 dummyData안의 데이터의 길이, 긍/부정도가 아니라 본문 내용을 수정하는 등의 다른 이벤트라면?
그래도 dummyData 값이 바뀌긴 하므로 리렌더링이 된다. 의미없는 연산이 반복되는 것이다. 이 친구들은 데이터의 길이에만 영향을 받기 때문에, 다른 요소의 변화로 인해 불필요한 연산이 일어난다.
이때 useMemo를 이용해 dep arr에 해당 데이터의 길이를 참조하게 되면, 데이터의 길이가 바뀔 때만 해당 연산을 하고, 같다면 미리 연산한 값을 가지고 오게 된다. 즉, 참조하는 값이 달라지지 않으면 "메모이제이션 된 값"을 반환하는 것이다. 다음과 같이 쓸 수 있다.
const diaryEmotionAnalyasis = useMemo(() => {
const happyCount = dummyData.filter((data) => data.emotion >= 3).length;
const unhappyCount = dummyData.length - happyCount;
const hapinessRatio = Math.ceil((happyCount / dummyData.length) * 100);
return { happyCount, unhappyCount, hapinessRatio };
}, [dummyData.length]);
const { happyCount, unhappyCount, hapinessRatio } = diaryEmotionAnalyasis;
또한 useMemo는 useEffect와 달리 렌더링 중에 실행된다. 공식문서에서도
useMemo로 전달된 함수는 렌더링 중에 실행된다는 것을 기억하세요. 통상적으로 렌더링 중에는 하지 않는 것을 이 함수 내에서 하지 마세요. 예를 들어, 사이드 이펙트(side effects)는 useEffect에서 하는 일이지 useMemo에서 하는 일이 아닙니다.
리액트 공식문서
useMemo에 사실 렌더링되지 않는 것들을 넣어도 잘 작동할 것이다. 하지만 성격에 어울리지 않는다는 말이 어울리겠다. 그러면 넣을 수는 있으니까 useMemo를 useEffect 쓰는 자리에 쓰겠다고?
useMemo는 메모이제이션 된 값을 반환한다. 이에 비해 useEffect는 콜백 함수를 실행시킬 뿐이다. 그러므로 값을 받아와야만 하는 측면에서는 useMemo를 쓰는 것이 더 적절하다고 얘기할 수 있겠다.
한줄평 : 연산 '결과'를 재사용
React.memo는 고차 컴포넌트(Higher Order Component)입니다.
리액트 공식문서
React.memo는 컴포넌트를 재사용하게 만든다. 고차 컴포넌트라는 말은, 쉽게 말하면 새로운 컴포넌트를 반환하게 하는 컴포넌트라고 할 수 있다.
이 React.memo에는 컴포넌트 단위가 들어가게 되고, 해당 컴포넌트를 렌더링하지 않고 재사용하는 기준은 prop으로 받은 인자들의 변경 유무다. 위에서 계속 dummyData를 예로 들었다. dummyData의 state가 변경되면 사실상 부모 컴포넌트에서 state가 변경되었기 때문에 dummyData와 아무런 관련이 없음에도 자식 컴포넌트라는 이유로 모두 불필요하게 렌더링되게 된다.
그러므로 해당 컴포넌트의 prop을 기점으로 '컴포넌트'의 재사용을 고려하게 되는 것이다.
하지만 prop으로 받는 것에 한 번 집중을 해보자. prop에 단순히 원시타입, 참조타입의 데이터가 들어간다고 생각하면, 다음과 같이 적을 수 있다.
const [count, setCount] = useState();
const [obj, setObj] = useState();
const CounterA = React.memo(({ count }) => {
console.log("hi")
return <div onClick={() => {setCount(1);}}>{count}</div>;
});
count의 state가 변하면 컴포넌트를 반환하는 CounterA 함수이다. 안의 컴포넌트는 클릭할 때 count의 state를 1로 바꾸는데, 과연 그러면 CounterA 안의 console.log("hi")는 출력이 될까?
출력이 되지 않는다. 왜냐하면 React는 얕은 복사, 즉 해당 state의 주소값이 변경되었을 때인데, 계속 1로 재할당을 하는 것은 콜 스택 상의 같은 주소를 가르킬 뿐이다. 그러므로 state가 변경되지 않아서 CounterA 안의 console.log("hi")도 출력되지 않는다. React.memo의 컴포넌트 prop의 state가 변경되지 않았기 때문이다.
그렇다면 다음 코드는 어떨까?
const [count, setCount] = useState();
const [obj, setObj] = useState({count : 1});
const CounterB = React.memo(({ obj }) => {
console.log("hi")
return <div onClick={() => {setObj(obj.count);}}>{obj}</div>;
});
다음 코드는 객체의 count 값을 이전의 state 값으로 똑같이 바꾸는 것이다. 값이 바뀌지 않고 같은 값이 재할당되었기 때문에 state 변화는 일어나지 않고 console.log("hi")도 찍히지 않을까?
콘솔 아주 잘 찍힌다...ㅋㅋ 왜냐하면 객체는 참조타입, 즉 데이터를 setState로 재할당하는 행위는 Heap Memory에 {count : 1} 이라는 데이터를 새로 만들고, 새로운 콜 스택의 주소값을 가르키게 된다. 즉 state 변경이 일어나 콘솔이 찍히게 되는 것이다. 원시타입, 참조타입 게시물은 다음 것을 참조하라.
그러면 어떻게 하냐고? state의 주소값이 아니라, 실제 값을 비교하게 만들면 된다!! React.memo의 두 번째 인자에는 true와 false를 반환하는 함수를 실행시킬 수 있다. 다음 코드를 봐보자.
const [count, setCount] = useState();
const [obj, setObj] = useState({count : 1});
const areEqual = (prevProp, nextProp) => {
return prevProp.obj.count === nextProp.obj.count ? true : false;
};
const CounterB = React.memo(({ obj }) => {
console.log("hi")
return <div onClick={() => {setObj(obj.count);}}>{obj}</div>;
},areEqual);
해당 코드는 state의 주소값에 의한 참조가 아니라, 실제 값을 비교하게 만드는 콜백 함수를 두 번째 인자로 걸어서 false, true 값에 따라 정상적으로 React.memo가 작동하도록 만들 수 있다. 다음 코드는 이렇게 정리해줄 수도 있다.
const [count, setCount] = useState();
const [obj, setObj] = useState({count : 1});
const areEqual = (prevProp, nextProp) => {
return prevProp.obj.count === nextProp.obj.count ? true : false;
};
const CounterB = ({ obj }) => {
console.log("hi")
return <div onClick={() => {setObj(obj.count);}}>{obj}</div>;
});
const NewCounterB = React.memo(CounterB, areEqual)
하지만 이렇게 쉬운 데이터타입만 있다면 React.memo 쓰기 참 쉬울텐데... 만약 부모에서 함수를 프롭으로 자식 컴포넌트로 내려준다면? 그 함수는 데이터타입이 변경되는 것과는 좀 더 복잡한 형태일 것이다. 그러므로 prop으로 내려주는 함수도 부모의 리렌더링에 따라 모두 다시 작성되므로 useMemo의 두 번째 인자로 areEqual를 단순 비교하기 힘들 것이다. 이때 useCallback이 등장한다.
메모이제이션된 콜백을 반환합니다
리액트 공식문서
useMemo와의 차이점이라면, useMemo는 메모이제이션된 값을 반환한다면, useCallback은 메모이제이션된 콜백을 반환한다.
useEffect도 콜백을 반환하는거니까 똑같은거라고? 아니... useCallback은 렌더링 중에 실행되는 차이가 있다. 사실 useCallback도 useMemo를 기반으로 만들어져서 사실상 구조는 다음과 똑같다
const onToggle = useMemo(
() => () => {
/* ... */
},
[users]
);
그러므로 useCallback을 이용하면 해당 함수를 재사용하게 만들어주어서, 해당 함수가 자식 컴포넌트에 prop으로 넘겨질 때에도 useCallback 내 함수가 dep arr에 정의된 참조값이 변하지 않는 이상 렌더링 되지 않기 때문에, 컴포넌트 재사용도 가능하게 되는 것이다. (즉 props으로 함수를 내려줄 경우에는 최적화 시 React.memo를 컴포넌트에 걸고, 내려주는 함수에 useCallback을 걸어준다)
그리고 useCallback의 두 번째 인자에 빈 배열을 넣을 경우, 해당 콜백은 마운트 될 때의 정보만을 가지고 있기 때문에 문제가 될 수 있다.
그러므로 이때는 state를 변경하는 방법으로 함수형 업데이트를 사용할 수 있다.
const onCreate = useCallback(({ author, content, emotion }) => {
const created_date = new Date().getTime();
const newItem = {
author,
content,
emotion,
created_date,
dataId: ++dataId.current,
};
setDummyData((dummyData) => [newItem, ...dummyData]);
}, []);
다음과 같이 함수형으로 업데이트를 시켜주면 빈 배열이므로 렌더링이 되지 않으면서도 callback이 실행되는 시점의 dummyData를 가져와 작업할 수 있다.
정말로 갈 길이 멀다....
참고자료
https://velog.io/@ckvelog/react-memoryleak