평소에 자주 사용해오던 리액트 Hook을 전체적으로 정리해본 적은 없어서 이번 기회에 한 번 전체적으로 정리해보려고 합니다🫠
React에서는 16.8 버전부터 Hook이란 개념이 새로 추가되었습니다. Hook은 함수형 컴포넌트에서 React state와 생명주기 기능을 연동할 수 있게 해주는 함수입니다.
리액트에서 제공하는 내장 훅(useState, useEffect, ... ) 과 사용자가 직접 정의할 수 있는 Custom Hooks가 있습니다.
함수형 컴포넌트에 Hook이 나오기 전까지는, 일반적으로 클래스형 컴포넌트를 많이 사용했습니다. 하지만 클래스형 컴포넌트에서 상태(state)를 사용하고, 생명주기 메서드를 사용하는 방식은 많은 문제들과 불편함을 가지고 있었습니다.
컴포넌트 간의 상태 로직을 재사용하기 어렵다.
복잡한 컴포넌트들의 이해가 어렵다.
=> render props나 HOC(Higher Order Component)와 같은 패턴들
Class 문법 자체(ex) Class 함수 내에서의 this)가 어렵다.
이러한 문제들을 해결할 수 있는 함수형 컴포넌트에서의 Hook이 등장했습니다!
같은 훅을 여러 번 호출할 수 있습니다.
function Form() {
// useState 여러번 호출 가능
const [name, setName] = useState('Mary');
const [surname, setSurname] = useState('Poppins');
// 이하 생략
컴포넌트 최상위(at the top level)에서만 호출할 수 있습니다. 반복문, 조건문, 중첩된 함수 내에서 훅을 사용할 수 없습니다.
=> 컴포넌트 최상위에서 훅을 호출하면, 컴포넌트가 렌더링 될 때마다 항상 동일한 순서로 Hook이 호출되는 것이 보장됩니다.
이는 React가 useState와 useEffect 등의 훅이 여러 번 호출되는 중에도 훅의 상태를 올바르게 유지할 수 있도록 해줍니다.
훅의 호출 순서가 같아야 하는 이유는?
=> React가 특정 state가 어떤
useState
호출에 해당하는지 알 수 있는 이유는 React가 Hook이 호출되는 순서에 의존하기 때문입니다. 모든 렌더링에서 Hook의 호출 순서는 같기 때문에 state를 구분할 수 있습니다. 더 자세한 내용은 여기를 참고해주세요.
Hook은 React 함수 내에서만 호출할 수 있습니다. 리액트 훅은 함수형 컴포넌트에서 호출해야하며, 추가적으로 custom hooks에서 또한 호출할 수 있습니다.
비동기 함수(async 키워드가 붙은 함수)는 콜백함수로 사용할 수 없습니다.
export default function App(){
// useEffect Hook 내부에 비동기 함수가 들어가므로 에러 발생
useEffect(async () => {
await Promise.resolve(1)
}, [])
return {
<div>
<div>Test</div>
</div>
}
❓ 그 이유는?
리액트는 기본적으로 컴포넌트 상태를 관리할 수 있는 useState
와 컴포넌트 생애주기에 개입할 수 있는 useEffect
, 컴포넌트 간의 전역 상태를 관리하는 useContext
훅을 제공하고 있습니다. 추가로 제공하는 Hook은 기본 Hook의 동작 원리를 모방해서 만들어졌습니다.
추가적인 훅들의 기본적인 역할은 다음과 같습니다.
useReducer
: 상태 업데이트 로직을 reducer
함수에 따로 분리합니다. useRef
: 컴포넌트나 HTML 엘리먼트를 레퍼런스로 관리합니다. useImperativeHandle
: useRef
의 레퍼런스를 상위 컴포넌트로 전달합니다. useMemo
, useCallback
: 의존성 배열에 적힌 값이 변할 때만 값 또는 함수를 다시 정의합니다. useLayoutEffect
: 모든 DOM 변경 후 브라우저가 화면을 그리기 이전 시점에 effect를 동기적으로 실행합니다. useDebugvalue
: 사용자 정의(custom) Hook의 디버깅을 도와줍니다. 리액트 훅의 기본이자 가장 많이 사용하는 훅인 useState
는, 컴포넌트의 상태(state)를 관리할 수 있는 훅입니다.
const [number, setNumber] = useState(1);
위 코드에서는 number
라는 상태를 useState
훅을 통해 관리하고 있습니다. useState
훅의 인자로 전달된 1
이라는 값이 number
의 초기값이 되고, 훅을 통해 반환되는 [number, setNumber]
에서 number
를 통해 상태의 값에 접근할 수 있고, setNumber
메서드를 호출함으로써 상태 값을 변경할 수 있습니다.
const [number, setNumber] = useState(1);
setNumber(2);
useState
에서 반환된 배열의 두번째 값인 setter 함수를 호출하면 상태 값을 변경할 수 있고, 상태 값이 변경되면 해당 컴포넌트는 다시 렌더링됩니다.
=> useState(초기값)
에서 인자로 전달된 값을 상태의 초기값으로 사용합니다. 하지만 이후 setter 함수에 의해 상태의 값이 변경되었다면, 다음 렌더링에서는 그 상태를 유지합니다.
setState 호출 => 상태 변경 => 리렌더링(변경된 상태값 사용)
useState 이것만은 알고 쓰자
=> useState의 기본적인 사용법 외에도 useState 사용 시 주의할 점에 대해 자세히 정리한 글입니다.
컴포넌트 내의 상태의 변화가 있을 때, 이를 감지하여 특정 작업을 해줄 수 있는 훅입니다.
일반적으로 sideEffect의 처리를 위해서 사용된다고 말합니다.
sideEffect란?
=> 컴포넌트가 화면에 렌더링된 이후에 비동기로 처리되어야 하는 부수적인 효과들을 말합니다. 예를 들어 API를 호출하는 경우 데이터를 비동기적으로 가져와야 하는데, 만약 그렇지 않다면 데이터를 가져오는 시간 동안 렌더링이 지연될 수도 있기 때문입니다.
sideEffect란 다음과 같은 경우를 모두 포함합니다.
- 함수에서 함수 안의 내용물만으로 결과값을 만드는 것 외에 다른 행위들
- 함수의 output를 만들기 위해 외부에 값을 사용하는 것
- 외부 변수를 함수 안에서 변경시키는 것
useEffect(()=>{
//
}, []) // 의존성 배열
useEffect
의 두번째 인자로 어떤 값을 전달하는 지에 따라, 첫번째 인자로 전달된 콜백 함수(effect)가 언제 실행되는 지가 결정됩니다.
useEffect(()=>{
}) // 아무것도 전달 x
기본적으로는 첫 렌더링(마운트)과 그 이후의 모든 업데이트에 대해서 effect를 수행하게 됩니다. 이는 클래스 컴포넌트의 componentDidMount
, componentDidUpdate
를 합쳐놓은 것과 같습니다.
=> 불필요한 렌더링을 줄이고 싶다면 useEffect
의 두 번째 인자인 의존성 배열 을 넣어주면 됩니다.
[]
전달 시,useEffect(() => {
console.log("Component Loaded");
}, []);
// 컴포넌트가 마운트 됐을때만 실행된다. (componentDidMount)
맨 처음 컴포넌트 생성 시, 즉 마운트 될 때만 실행됩니다.
=> 물론, 마운트 될 때 실행된다는 것의 의미는 마운트 시 발생하는 렌더링 이후에 실행된다는 의미입니다.
[count]
useEffect(() => {
document.title = `You clicked ${count} times`;
}, [count]);
// count가 바뀔 때만 effect를 재실행
맨 처음 마운트 되었을 때와, count
state의 값이 바뀔 때만 실행됩니다.
useEffect(()=>{
console.log("Component Loaded");
const handleScroll = () => {
console.log(window.scrollY);
};
document.addEventListener("scroll", handleScroll);
return () => document.removeEventListener('scroll', handleScroll);
}, []);
window
에 스크롤에 대한 event를 등록하고, 컴포넌트가 사라질 때, 이를 해제해줄 수 있습니다.
=> useEffect 안의 첫 번째 인자인 콜백함수 내 return 문은 해당 컴포넌트가 제거될 때(unmount될 때) 실행됩니다.
❓ 왜 컴포넌트가 제거 될 때 return 문이 실행될까?
=>
useEffect
는 원래 컴포넌트가 살아있는 동안 컴포넌트 내 특정 변수나 상태가 변화될 때마다 실행되야 하므로, 이를 감지하기 위해서는 컴포넌트가 살아있는 동안 계속 감지하면서 있다가, 컴포넌트가 제거될 때 return 하는 건가 ?
=> 의존성 배열로 아무것도 전달하지 않던, []
를 전달하던, [count]
를 전달하던, useEffect
내의 return 문은 무조건 컴포넌트가 제거(unmount) 될 때 한번만 실행됩니다.
useEffect로 전달된 함수는 컴포넌트 렌더링 - 화면 업데이트 - useEffect 함수 실행
순으로 실행됩니다. 즉, useEffect실행이 최초 렌더링 이후 에 된다는 것을 유의해야합니다.
만약 화면을 다 그리기 이전에 동기화 되어야 하는 경우에는, useLayoutEffect()
를 활용하여 컴포넌트 렌더링 - useLayoutEffect 실행 - 화면 업데이트
순으로 effect를 실행시킬 수 있습니다.
2가지 경우에 useRef를 사용할 수 있습니다.
DOM에 직접 접근할 때 사용합니다.
=> 리액트는 DOM으로의 직접 접근을 막고 있기 때문에 ref
를 통해 접근해야합니다.
변수의 값이 변하더라도 리렌더링을 유발하고 싶지 않을 때 사용합니다.
=> ref 안에 저장되어 있는 값이 변경되어도 컴포넌트는 다시 렌더링되지 않습니다. 또한, 컴포넌트가 아무리 렌더링 되어도 ref 안에 저장되어 있는 값은 변화되지 않고 그대로 유지됩니다.
=> ref의 값은 컴포넌트의 전 생애주기를 통해 유지되기 때문입니다. 컴포넌트가 브라우저에 마운팅 된 시점부터 마운트가 해제될 때까지 같은 값을 계속해서 유지할 수 있습니다.
=> 변경 시 렌더링을 발생시키지 말아야 하는 값을 다룰 때 편리합니다.
useState를 통해 생성된 state는 그 값이 변경될 때마다 다시 렌더링됩니다.
VS
useRef는 값이 변경되더라도 다시 렌더링되지 않습니다.
const inputRef = useRef();
<input ref = {inputRef}/>
그럼 이제 inputRef.current
로 input
태그에 접근할 수 있습니다.
하위 컴포넌트의 dom에 접근하고 싶다면
EX)
App
컴포넌트에서 => 하위 컴포넌트인 Input
컴포넌트의 dom에 접근하고 싶다면,
=> App
컴포넌트에서 Input
컴포넌트의 dom에 ref를 통해 접근할 수 있다.
=> forwardRef
이용
function App() {
const inputRef = useRef(); // useRef 선언
return (
<div>
<Input ref={inputRef}/> // ref 전달
<button onClick={()=>inputRef.current.focus()}> Focus </button>
</div>
);
}
const Input = React.forwardRef(( _, ref ) => {
return (
<>
Input: <input ref={ref} />
</>
);
});
export default Input;
useRef
함수는 current
속성을 가지고 있는 객체를 반환하는데, 인자로 넘어온 초기값을 current
속성에 할당합니다. 이 current
속성은 값을 변경해도 상태를 변경할 때 처럼 React 컴포넌트가 다시 랜더링되지 않습니다. React 컴포넌트가 다시 랜더링될 때도 마찬가지로 이 current
속성의 값이 유실되지 않습니다.
import React, { useState, useRef } from "react";
function ManualCounter() {
const [count, setCount] = useState(0);
const intervalId = useRef(null);
console.log(`랜더링... count: ${count}`);
const startCounter = () => {
intervalId.current = setInterval(
() => setCount((count) => count + 1),
1000
);
console.log(`시작... intervalId: ${intervalId.current}`);
};
const stopCounter = () => {
clearInterval(intervalId.current);
console.log(`정지... intervalId: ${intervalId.current}`);
};
return (
<>
<p>자동 카운트: {count}</p>
<button onClick={startCounter}>시작</button>
<button onClick={stopCounter}>정지</button>
</>
);
}
위의 예제에서는 intervalId
라는 data가 렌더링 시마다 변하지 않도록 하고 싶습니다. 그래서 ntervalId
를 useRef
훅을 이용해 초기화해주었습니다.
❓ useRef 작동 원리 알아보기
useRef는 선언된 컴포넌트 내에서 그냥 변수에 값을 할당하는 게 아니라, 변수에 참조값을 할당하고, 그 참조값이 값을 가리키고 있어서, 즉 한 단계 더 거쳐서 저장해서 값이 변하더라도 참조값은 변하지 않으므로 그래서 렌더링이 일어나지 않는 것 아닐까
=>
const intervalId = useRef(null)
에서IntervalId
가 참조값이고,intervalId.current
를 통해 그 실제 값을 접근할 수 있는 것 같다 !
원래, 컴포넌트 내의 변수는 리렌더링 시에 다시 정의됩니다. 하지만 useMemo
를 사용하면, 매 리렌더링 시마다 다시 정의되는 것이 아니라, 특정 값이 변할 때만 정의됩니다.
즉, useMemo
는 변수의 최적화를 위한 훅입니다.
컴포넌트는 사실 함수입니다. 즉, 컴포넌트란 JSX를 반환하는 함수에 불과합니다. 컴포넌트가 렌더링 된다는 것은 누군가가 이 함수 컴포넌트를 호출해서 실행하는 것을 의미합니다. 이는 함수가 실행될 때마다 내부에 선언되어있던 변수, 함수 등이 매번 다시 선언되거나 다시 실행이 된다는 것을 의미합니다.
컴포넌트가 리렌더링 되는 조건
컴포넌트의 state가 변경
컴포넌트의 props가 변경
부모 컴포넌트 리렌더링
=> 부모 컴포넌트가 리렌더링될 때 새로운 prop이 들어오지 않더라도 부모 컴포넌트가 리렌더링되면 자식 컴포넌트 역시 리렌더링 된다.
만약 컴포넌트를 최적화하지 않는다면, 부모 컴포넌트가 변경되기만 하더라도 리렌더링 될 수 있음을 의미합니다. 그리고 리렌더링 시, 연산이 많이 걸린다면 이는 비효율적일 것입니다. 따라서 이를 useMemo
를 사용함으로써 해결이 가능합니다.
const ShowSum = ({label, n]}) => {
// const result = sum(n);
const result = useMemo(()=>sum(n), [n]);
return (
<span>
{label}: {result}
</span>
)
}
export default ShowSum;
기록해 둘 표현식인 sum(n)
을 적어두고, 어떤 값이 변할 때만 이를 다시 변경할 것인지를 [ ]
안에 넣어줌 => [n]
=> n
값이 변할 때만 다시 계산
prop인
label
의 값이 계속 바뀐다면, 바뀔 때마다 컴포넌트는 계속 리렌더링되지만, 대신 result는 렌더링 시마다 계산하지 않는 것=> 의존성 배열에 들어있는
n
이 변할 때만 다시 계산
=> 즉, 특정 변수를 언제 다시 계산(정의)할 것인지를 지정해줄 수 있습니다.
그럼 useMemo를 이용한 변수는 state 인가 ?
useMemo
를 사용하여 선언된 변수는 컴포넌트의 state와는 조금 다릅니다.useMemo
는 메모이제이션된 값을 반환하는 훅으로, 특정 값이나 연산의 결과를 기억하여, 의존성이 변경되지 않으면 이전에 계산된 값을 재사용합니다.컴포넌트의 상태(State)는
useState
훅을 사용하여 관리되며, 상태가 변경되면 컴포넌트가 리렌더링됩니다.useMemo
는 주로 계산 비용이 높은 연산의 결과를 저장하고 싶을 때 사용됩니다. 이 때, 의존성 배열(dependency array)을 통해 해당 값이 의존하는 변수가 변경될 때만 다시 계산됩니다.요약하면,
useMemo
로 선언된 변수는 상태(State)가 아니며, 컴포넌트의 리렌더링과 직접적인 관련이 없습니다. 대신, 특정 값을 메모이제이션하여 필요할 때만 계산을 수행하는 용도로 사용됩니다.
useMemo
훅과 비슷하지만 약간 다른 개념인 React.memo
에 대해서도 알아보겠습니다.
위에서 컴포넌트가 리렌더링 되는 조건을 살펴봤습니다.
컴포넌트가 리렌더링 되는 조건
- 컴포넌트의 상태가 변경
- 부모로부터 받는 props가 변경
- 부모 컴포넌트의 상태가 변경
위에서 알 수 있는 건, 부모 컴포넌트의 상태가 변경되기만 하더라도 자식 컴포넌트가 리렌더링 될 수 있다는 것입니다. 하지만, 부모 컴포넌트의 상태만 변경되고 자식 컴포넌트는 변경된 것이 없는데, 리렌더링된다면 이는 비효율적일 것입니다.
여기서 React.memo
를 사용한다면, 부모 컴포넌트의 상태가 변경되더라도 자식 컴포넌트가 변경되지 않았을 경우 자식 컴포넌트의 리렌더링을 막을 수 있습니다.
단순히 함수 컴포넌트 식을 React.memo()
로만 감싸주면서 사용할 수 있습니다.
Box.jsx
const Box = React.memo(()=>{
console.log("Box 컴포넌트 렌더링");
return <div/>
})
export default Box;
App.jsx
function App(){
const [count, setCount] = useState(0);
return (
<div>
{count}
<button onClick={()=>setCount(count+1)}>+</button>
<Box/>
</div>
)
}
export default App;
=-> 만약 React.memo
를 사용하지 않았다면 button
클릭으로 인해 부모 컴포넌트의 상태인 count
값이 증가할 때마다, 자식 컴포넌트인 Box
컴포넌트가 리렌더링되어 console.log("Box 컴포넌트 렌더링");
가 계속 찍혔을 것입니다.
컴포넌트가 렌더링 될 때마다 함수가 재정의되지 않고, 의존성 배열 내의 값이 변할 때만 재정의되도록 만들어주는 훅입니다.
위에서도 말했듯이, 컴포넌트가 렌더링 된다는 것은 컴포넌트 함수가 다시 호출되는 것이고, 그 안의 함수도 다시 정의됩니다.
하지만, 함수가 재정의된다면 함수는 객체 타입이므로 그 참조값이 바뀔 것입니다. 이 때, 함수가 다시 정의되는 것을 막기 위해 useCallback
훅을 사용할 수 있습니다.
함수가 재정의되는 것을 막아줌으로써, 이 함수가 하위 컴포넌트에게
prop
으로 전달된다면 그 하위 컴포넌트의 리렌더링도 방지할 수 있습니다.=> 참조값이 변하지 않기 때문에
prop
으로 전달되는 값도 변하지 않습니다.
CheckBox.jsx
const CheckBox = React.memo(({ label, on, onChange})) => {
console.log(label, on);
return (
<label>
{label}
<input type="checkbox" defaultChecked={on} onChange={onChange}/>
</label>
)
}
export default CheckBox;
App.jsx
function App(){
const [foodOn, setFoodOn] = useState(false);
const [clothesOn, setClothesOn] = useState(false);
const [shelterOn, setShelterOn] = useState(false);
const foodChange = useCallback((e)=> setFoodOn(e.target.checked), []);
const clothesChange = useCallback((e)=> setClothesOn(e.target.checked), []);
const shelterChange = useCallback((e)=> setShelterOn(e.target.checked), []);
return (
<div>
<CheckBox label="Food" on={foodOn} onChange={foodChange} />
<CheckBox label="Clothes" on={clothesOn} onChange={clothesChange} />
<CheckBox label="Shelter" on={shelterOn} onChange={shelterChange} />
</div>
)
}
export default App;
Food check box의 체크를 눌렀을 때, props
로 전달된 onChange
인 foodChange
가 실행되고, 이를 통해 App
컴포넌트의 state인 foodOn
이 변경될 것입니다.
App
컴포넌트의 state가 변경되었으므로, App
컴포넌트는 다시 렌더링 될 것입니다. 여기서 만약 useCallback
으로 각 함수 foodChange
, clothesChange
, shelterChange
를 감싸주지 않았다면, 이 함수들은 모두 재정의 될 것입니다.
또한, 이 함수들은 자식 컴포넌트들에게 props로 전달되고, 각 자식 컴포넌트 입장에서는 props가 변경된 것이기 때문에 자식 컴포넌트들도 리렌더링될 것입니다.
=> React.memo
로 감쌋지만, props가 변경되는 것이므로 렌더링됩니다.
하지만 useCallback
으로 각 함수를 감싸줬기 때문에, App
컴포넌트 내의 함수들인 foodChange
, clothesChange
, shelterChange
은 App
컴포넌트가 리렌더링되어도 재정의되지 않습니다.
💡
useCallback 을 사용할 때 , 2번째 인자에 defendency array 를 넘기고, setData 로 state를 초기화 해줄 때, 함수형 업데이트로 초기화를 해주어야 합니다.
함수형 업데이트로 초기화를 해주어야, defenden array를 비워도 항상 최신의 state를 함수형 업데이트의 인자를 통해서 참조할 수 있게 됩니다.
자주 사용되는 훅부터 메모이제이션까지 깔끔하게 정리하신 것 같아요!! 👍👍 좋은 글 잘 보고 가요!