React를 사용해보면서 React의 핵심은 무엇일까라고 생각을 해보면 가장 먼저 Hook이 떠오를 것입니다. Hook을 사용하고는 있지만 , Hook에 대해 잘 모르고 사용하고 있는 것 같아 Hook이 어떻게 동작하는지 어떨 때 사용하는 것이 좋은지 자세히 알아가보고자 합니다.
React를 사용하면서 가장 많이 사용하는 Hook은 useState이지 않을까 예상해봅니다.
useState는 함수 컴포넌트 내부에서 상태를 정의하고 , 이 상태를 관리할 수 있게 해주는 훅입니다.
const [state, useState] = useState(initialState);
useState Hook의 반환 값은 배열입니다. 따라서 , 구조 분해 할당으로 변수에 할당하고 있는데요.
배열의 첫 번째 원소로 state 값 자체를 사용할 수 있고 , 두 번째 원소인 setState 함수를 사용해 해당 state의 값을 변경할 수 있습니다.
실제로 useState 훅의 구현 모습은 어떨까요 ?
const MyReact = (function () {
const global = {};
let index = 0;
function useState(initialState) {
//애플리케이션 전체의 states 배열 초기화
// 최초 접근이라면 빈 매열
if (!global.states) {
global.states = [];
}
const currentState = global.states[index] || initialState;
global.states[index] = currentState;
const setState = (function () {
let currentIndex = index;
return function (value) {
global.states[currentIndex] = value;
};
})();
// 클로저를 사용하여 함수 실행이 끝나도 내부 변수 이용 가능.
++index;
return [currentState, setState];
}
function Component() {
const [value, setValue] = useState(0);
}
})();
(실제 코드는 아닙니다. 실제로는 useReducer로 구현돼 있다고 합니다.)
코드를 살펴보면 인덱스가 증가할 때마다 global 객체의 크기가 증가하는 것을 알 수 있습니다. useState훅을 계속 사용하면 낭비되는 메모리가 생길까 ?라는 궁금증이 생길 수도 있는데 , react 내부에서 해당 메모리 공간이 계속 증가하지 않게 관리해준다고 합니다.
일반적으로 useState의 기본값에 원시 값을 넣는 경우가 많은데 , 인수로 특정한 값을 넘기는 함수를 넣을 수도 있습니다. 이를 게으른 초기화(Lazy initialization)이라고 부릅니다.
const [count, setCount] = useState(() =>
Number.parseInt(window.localStorage.getItem(cacheKey))
);
게으른 초기화를 사용하면 최초 렌더링 이후에는 실행되지 않고 , 최초의 state값을 넣을 때만 실행됩니다. 이런 게으른 초기화를 사용하는 이유는 시간이 오래걸리는 연산을 진행하여 초깃값을 설정해야 할 때 , 리렌더링 될 때마다 해당 연산을 진행하지 않게 하기 위함입니다.
useEffect는 Application 내 컴포넌트의 여러 값들을 활용해 동기적으로 부수 효과를 만드는 메커니즘입니다.
useEffect(() => {}, [props,state]);
첫 번째 인수로는 실행할 부수 효과가 포함된 함수를 , 두 번째 인수로는 의존성 배열을 전달합니다. useEffect는 렌더링할 때마다 의존성에 있는 값을 보면서 이 의존성의 값이 이전과 다른 게 하나라도 있으면 부수 효과를 실행하는 함수입니다.
부수 효과 (Side Effect) : 함수 내의 실행으로 함수 외부가 영향을 받음 (ex. 함수 내부에서 전역 변수 변경 등)
useEffect 함수 내부에서 return 문을 작성하면 해당 부분이 clean up의 동작을 하는데요. 대게 클린업 함수는 이벤트를 등록하고 지울 때 사용한다고 알려져있습니다.
useEffect(() => {
function addMouseEvent() {
console.log(counter);
}
window.addEventListener("click", addMouseEvent);
// 클린업 함수
return () => {
console.log("클린업 함수 실행");
window.removeEventListener("click", addMouseEvent);
};
});
해당 함수를 실행해보면 클린업 함수는 이전 counter 값 , 즉 이전 state 값을 참조해 실행됩니다. 클린업 함수는 새로운 값을 기반으로 렌더링 뒤에 실행되지만 , 변경된 값을 읽는 것이 아니라 함수가 정의됐을 당시에 선언됐던 이전 값을 보고 실행된다는 것입니다.
그렇다면 왜 클린업 함수를 써야 할까요 ?
useEffect는 콜백 함수가 실행될 때마다 이전 값을 참조하여 실행됩니다. 만약 클린업 함수가 없다면 이전에 추가된 event에 더불어 event가 추가될 것인데 클린업 함수가 없다면 무한히 이벤트가 추가될 것입니다. 이를 방지하기 위해 클린업 함수를 사용하는 것입니다.
의존성 배열은 빈 배열이 들어올 수도 , 여러 변수들이 들어올 수도 , 아무런 값도 넣지 않을 수도 있습니다. 여기서 드는 의문은 아무런 값도 넣지 않으면 굳이 useEffect를 사용해야 할까 ?라는 의문이 들 수도 있습니다.
function Component() {
console.log("렌더링됨");
}
function Component() {
useEffect(() => {
console.log("렌더링됨.");
});
// 의존성에 아무것도 넣어주지 않음.
}
이렇게 말이죠. 하지만 위의 방식에는 문제가 있습니다.
useEffect는 컴포넌트의 렌더링이 완료된 이후에 실행됩니다. 반면 직접 실행 (첫 번째 함수)은 컴포넌트가 렌더링되는 도중에 실행됩니다. 만약 무거운 작업을 포함하는 함수가 렌더링되는 도중에 실행된다면 , 해당 작업이 끝나기 전에는 렌더링되지 않을 것이며 이는 렌더링 성능에 영향을 미칠 것입니다.
useEffect는 반드시 의존성 배열로 전달한 값의 변경에 의해 실행돼야 하는 훅입니다. 따라서 빈 배열을 의존성 배열로 전달하는 것은 부수 효과가 실제로 관찰되어 실행 돼야 하는 값과는 별개로 작동한다는 것을 의미합니다. 따라서 , 빈 배열을 넘기기 전에는 useEffect의 부수 효과가 컴포넌트의 상태와 별개로 작동해야만 하는지 , 혹은 useEffect를 작성한 컴포넌트의 위치가 적절한지 검토해봐야 합니다.
useMemo는 비용이 큰 연산에 대한 결과를 저장(Memoization)해두고 , 이 저장된 값을 반환하는 훅입니다.
const needToMeomorize = useMemo(() => compute(a, b), [a, b]);
첫 번째 인수로는 어떠한 값을 반환하는 생성 함수를 , 두 번째 인수로는 해당 함수가 의존하는 값의 배열을 전달합니다. 의존성 배열의 값이 변경됐다면 첫 번째 인수의 함수를 실행한 후에 그 값을 반환하고 , 그 값을 다시 기억해둘 것입니다. 이러한 메모이제이션은 단순히 값뿐만 아니라 컴포넌트도 가능합니다.(일급 객체는 값으로 표현될 수 있기 때문입니다.)
useMemo가 값을 기억했다면 , useCallback은 인수로 넘겨받은 콜백 자체를 기억합니다. (개인적으로 useEffect처럼 사용하기 어려운 Hook이라고 생각합니다.) 이 말은 즉슨 함수 자체를 메모이제이션한다는 것인데 , 함수를 메모이제이션 해야 하는 이유는 무엇일까요 ? 컴포넌트가 렌더링 될 때 컴포넌트 내부에 있는 변수 및 함수들을 재생성됩니다. 재생성될 필요가 없는데도 불필요한 재생성을 진행하고 있다면 , 성능 측면에서 문제가 생길 수도 있기에 메모이제이션을 활용하여 해당 함수를 기억해두는 것입니다.
const memorizeFunc = useCallback(() => {}, [props, state]);
useMemo와 마찬가지로 첫 번째 인수로는 함수를 , 두 번째 인수로는 의존하는 값들의 배열을 넣습니다. 일급 객체인 함수를 저장해두는 것이면 useCallback 말고 useMemo를 사용해도 되지 않나 ? 라는 의문이 드실 수도 있습니다. 답은 useMemo를 사용해도 됩니다 ! 다만 , useMemo를 사용하면 return 문으로 함수 선언문을 작성해야 학에 코드를 작성하거나 리뷰를 하는 도중에 혼란을 불러올 수도 있으니 , 간단한 useCallback을 사용하는 것이 좋습니다.
useState와 useRef는 컴포넌트 내부에서 렌더링이 일어나도 변경 가능한 상태값을 저장한다는 공통점이 있습니다. 다만 차이점이 있는데요.
1. useRef는 반환 값인 객체 내부에 있는 current로 값에 접근 또는 변경할 수 있다.
2. useRef는 값이 변하더라도 렌더링을 발생시키지 않는다.
그러면 굳이 useRef를 사용하는 것이 아닌 , 전역 변수를 선언하면 되는 거 아닌가 ? 에 대한 의문을 가질 수도 있습니다.
let value = 0;
function Component() {
function handleClick() {
value++;
}
}
이러한 방식에는 몇 가지 단점들이 있습니다.
1. 컴포넌트가 실행되어 렌더링되지 않았음에도 value라는 값이 기본적으로 존재하게 됩니다. -> 메모리 효율 낭비
2. 모든 컴포넌트가 가리키는 값이 value로 동일하게 됩니다. (임계 구역을 생각해보면 공유 자원에 모두가 접근하는 사태가 발생할 수도 있습니다.)
대부분의 경우에는 컴포넌트 인스턴스 하나 당 하나의 값을 필요로 하기 때문에 useRef를 사용하는 것이 좋습니다. useRef는 컴포넌트가 렌더링될 때만 생성되며 , 컴포넌트 인스턴스가 여러 개라도 각각 별개의 값을 바라봅니다.
function RefComponent() {
const inputRef = useRef();
console.log(inputRef.current); //undefined
// useEffect는 렌더링 된 이후에 실행
useEffect(() => {
console.log(inputRef.current); // 따라서 <input> DOM이 저장되는 것
}, [inputRef]);
return <input ref={inputRef}></input>;
}
useRef는 최초에 넘겨받은 기본값을 가지고 있습니다.
useRef의 최초 기본 값은 return문에 정의해둔 것이 아닌 useRef로 넘겨받은 기본값입니다. useRef가 선언된 당시에는 아직 컴포넌트가 렌더링되기 전이라 undefined가 저장되는 것입니다.
export function useRef(initialValue) {
currentHook = 5;
return useMemo(() => {
current: initialValue;
}, []);
// 값이 변경돼도 렌더링되면 안되기에 빈 배열 의존성
}
useReducer는 useState와 비슷한 형태를 띠지만 좀 더 복잡한 상태 값을 미리 정의해 놓은 시나리오에 따라 관리할 수 있습니다.
const initialState = {
count: 0,
};
function reducer(state, action) {
switch (action.type) {
case "up":
return { count: state.count + 1 };
case "down":
return { count: state.count - 1 };
case "reset":
return init(action.payload || { count: 0 });
default:
}
}
export default function App(){
const [state, dispatcher] = useReducer(reducer, initialState, init);
}
state는 현재 useReducer가 가지고 있는 값을 의미합니다.
dispatcher는 state를 업데이트하는 함수입니다. action을 넘겨줍니다.
useRedcuer의 인수는 2개 혹은 3개의 인수를 필요로 합니다.
reducer : useReducer의 기본 action을 정의하는 함수입니다.(첫 번째 인수)
initialState: useReducer의 초깃값을 의미합니다.
init : useState의 lazy initialization처럼 초깃값을 지연해서 생성시키고 싶을 때 사용합니다.
일반적으로 잘 사용되는 Hook은 아닙니다.
결과적으로 말하자면 useImperativeHandle은 부모로부터 내려받은 ref(useRef를 이용한)를 수정할 수 있는 훅입니다. -> 부모 컴포넌트에서 노출되는 값을 원하는대로 바꿀 수 있다.
리액트에서 ref를 props로 넣어주려고 하는 경우
<ChildComponent ref={inputRef}></ChildComponent>;
ref는 예약어이기에 props로 사용할 수 없다고 나옵니다. 그래서 내려주기 위해선 props 네이밍을 바꾸거나 (ex.parentRef) , forwardRef를 사용해줘야 합니다. 단순 props 네이밍을 바꾸는 건 해당 props가 어떤 내용을 전달하고자 하는지에 대한 예측이 쉽지 않기에 forwardRef 를 사용하면 ref를 전달해주는 것임을 알 수 있기에 forwardRef를 사용해주는 것이 가독성에 좋습니다.
const ChildComponent=forwardRef((props,ref)=>{
...
})
이후 useImperativeHandle을 사용하여 변경하게 되면 자식 컴포넌트에서 새롭게 설정한 객체의 키와 값에서도 접근할 수 있게 됩니다.
useEffect의 형태나 사용 예제가 동일합니다만 , useLayoutEffect는 모든 DOM의 변경 후(렌더링)에 useLayoutEffect의 콜백 함수 실행이 동기적으로 발생합니다.
function App() {
const [count, setCount] = useState(0);
// useLayoutEffect가 끝나고 렌더링 완료된 이후에 실행
useEffect(() => {
console.log("useEffect", count);
}, [count]);
// 먼저 실행
useLayoutEffect(() => {
console.log("useLayoutEffect", count);
});
}
useEffect와 같이 사용하게 되면 동작 과정은 이렇습니다.
1. 리액트가 DOM을 업데이트
2. useLayoutEffect를 실행
3. 브라우저에 변경 사항을 반영
4. useEffect 실행
-> useLayoutEffect가 브라우저에 변경 사항이 반영되기 전에 실행되는 반면 useEffect는 브라우저에 변경 사항이 반영된 이후에 실행되기 때문입니다.
따라서 useLayoutEffect는 DOM은 계산됐지만 이것이 화면에 반영되기 전에 하고 싶은 작업이 있을 때와 같이 필요한 상황에만 사용하는 것이 좋습니다. (useLayoutEffect가 실행돼야 브라우저에 변경 사항을 반영하기 때문에 성능 저하가 일어날 수 있기 때문입니다. )
출처 : Modern React Deep Dive