React Hooks에는 많은 기능들이 있는데요.
그 중 많이 사용되는 useState, useEffect, useRef, useContext, 최적화 hooks(React.memo, useCallback, useMemo) 등에 대해 알아보겠습니다.
1. useState
(1) 정의
useState는 가장 기본적인 hook이며, 함수 컴포넌트에서 가변적인 상태를 가지게 해줍니다.
useState의 기본적인 형태는 아래 처럼 생겼습니다.
const [state, setState] = useState(initialState);
원래는 useState 라는 함수가 배열을 반환하고, 이것을 구조 분해 문법으로 꺼내놓은 모습일 뿐입니다.
2. 함수형 업데이트
(1) 함수형 업데이트란?
setState에는 함수형 업데이트 방식이 있습니다.
// 기존에 우리가 사용하던 방식
setState(number + 1);
// 함수형 업데이트
setState(() => {});
위 코드와 같이 setState의 ( ) 안에 수정할 값이 아니라, 함수를 넣을 수 있습니다. 그리고 그 함수의 인자에서는 현재의 state을 가져올 수 있고, { } 안에서는 이 값을 변경하는 코드를 작성할 수 있습니다.
// 현재 number의 값을 가져와서 그 값에 +1을 더하여 반환한 것 입니다.
setState((currentNumber)=>{ return currentNumber + 1 });
(2) 두 방식의 차이점은?
일반 사용법과 함수형 업데이트 방식의 차이점이 무엇일까요? 두개의 코드를 비교해봅시다.
먼저 일반 업데이트 방식으로 onClick안에서 setNumber(number + 1) 를 3번 호출했습니다. number가 1씩 증가하는군요.
// src/App.js
import { useState } from "react";
const App = () => {
const [number, setNumber] = useState(0);
return (
<div>
{/* 버튼을 누르면 1씩 플러스된다. */}
<div>{number}</div>
<button
onClick={() => {
setNumber(number + 1); // 첫번째 줄
setNumber(number + 1); // 두번쨰 줄
setNumber(number + 1); // 세번째 줄
}}
>
버튼
</button>
</div>
);
}
export default App;
이번에는 함수형 업데이트 방식으로 동일하게 작동시켜보겠습니다. number가 3씩 증가하네요.
// src/App.js
import { useState } from "react";
const App = () => {
const [number, setNumber] = useState(0);
return (
<div>
{/* 버튼을 누르면 3씩 플러스 된다. */}
<div>{number}</div>
<button
onClick={() => {
setNumber((previousState) => previousState + 1);
setNumber((previousState) => previousState + 1);
setNumber((previousState) => previousState + 1);
}}
>
버튼
</button>
</div>
);
}
export default App;
왜 다르게 동작할까요?
일반 업데이트 방식은 버튼을 클릭했을 때 첫번째 줄 ~ 세번째 줄의 있는 setNumber가 각각 실행되는 것이 아니라, 배치(batch)로 처리합니다. 즉 우리가 onClick을 했을 때 setNumber 라는 명령을 세번 내리지만, 리액트는 그 명령을 하나로 모아 최종적으로 한번만 실행을 시킵니다. 그래서 setNumber을 3번 명령하던, 100번 명령하던 1번만 실행됩니다.
반면에 함수형 업데이트 방식은 3번을 동시에 명령을 내리면, 그 명령을 모아 순차적으로 각각 1번씩 실행시킵니다. 0에 1더하고, 그 다음 1에 1을 더하고, 2에 1을 더해서 3이라는 결과가 우리 눈에 보이는 것이죠.
3. 왜 리액트에서는 useState가 위 방식으로 동작하도록 만들었을까?
공식문서의 설명
📢 리액트는 성능을 위해 setState()를 단일 업데이트(batch update)로 한꺼번에 처리할 수 있습니다.
공식문서의 설명처럼, 불필요한 리-렌더링을 방지(렌더링 최적화)하기 위해 즉, 리액트의 성능을 위해 한꺼번에 state를 업데이트 한다고 하네요.
다음으로는 useEffect 에 대해 알아보겠습니다.
2. useEffect
(1) useEffect는 언제 사용할까?
useEffect는 리액트 컴포넌트가 렌더링될 때마다 특정 작업을 수행하도록 설정할 수 있는 Hook입니다. 쉽게 말해 어떤 컴포넌트가 화면에 보여졌을 때 내가 무언가를 실행하고 싶다면? 또는 어떤 컴포넌트가 화면에서 사라졌을 때 무언가를 실행하고 싶다면? useEffect를 사용합니다.
(3) useEffect와 리렌더링(re-rendering)
useEffect는 useEffect가 속한 컴포넌트가 화면에 렌더링 될 때 실행됩니다. 이런 useEffect의 특징에 의해 우리가 의도치않은 동작을 경험할수도 있는데요.
다음 코드를 볼까요? input이 있고 value 라는 state를 생성하여 input과 연결시켰습니다. 이렇게 구현하고 브라우저에 input에 어떤 값을 입력하면 useEffect가 계속 실행되는 것을 볼 수 있습니다.
import React, { useEffect, useState } from "react";
const App = () => {
const [value, setValue] = useState("");
useEffect(() => {
console.log("hello useEffect");
});
return (
<div>
<input
type="text"
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
</div>
);
}
export default App;
이렇게되면 console.log("hello useEffect"); 가 계속 실행이 될 것입니다. 왜 input에 값을 입력한 것 뿐인데, useEffect가 계속 실행되는 것일까요?
전체 흐름은 아래와 같습니다.
- input에 값을 입력한다.
- value, 즉 state가 변경된다.
- state가 변경되었기 때문에, App 컴포넌트가 리렌더링 된다.
- 리렌더링이 되었기 때문에 useEffect가 다시 실행된다.
- 1번 → 5번 과정이 계속 순환환다.
하지만 이런 문제는 의존성 배열로 해결이 가능합니다.
2. 의존성 배열
(1) 의존성 배열(dependency array) 이란?
useEffect에는 의존성 배열이라는 것이 있습니다. 쉽게 풀어 얘기하면 “이 배열에 값을 넣으면 그 값이 바뀔 때만 useEffect를 실행할게” 라는 것 입니다.
// useEffect의 두번째 인자가 의존성 배열이 들어가는 곳 입니다.
useEffect(()=>{
// 실행하고 싶은 함수
}, [의존성배열])
(2) 의존성 배열이 빈 배열인 경우
// src/App.js
import React, { useEffect, useState } from "react";
const App = () => {
const [value, setValue] = useState("");
useEffect(() => {
console.log("hello useEffect");
}, []); // 비어있는 의존성 배열
return (
<div>
<input
type="text"
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
</div>
);
}
export default App;
의존성 배열에 빈 배열 [ ] 을 넣은 경우
input에 어떤 값을 입력하더라도, 처음에 실행된 hello useEffect외에는 더 이상 실행 되지 않을 것입니다. 이렇게 useEffect를 사용하는데, 어떤 함수를 컴포넌트가 렌더링 될 때 단 한번만 실행하고 싶으면 의존성 배열을 [ ] 빈 상태로 넣으면 됩니다.
(3) 의존성 배열에 값이 있는 경우
// src/App.js
import React, { useEffect, useState } from "react";
const App = () => {
const [value, setValue] = useState("");
useEffect(() => {
console.log("hello useEffect");
}, [value]); // value를 넣음
return (
<div>
<input
type="text"
value={value}
onChange={(event) => {
setValue(event.target.value);
}}
/>
</div>
);
}
export default App;
의존성 배열에 값(value) 가 있는 경우
위 코드의 같은 경우는 의존성 배열에 존재하는 value 값이 바뀔 때마다 useEffect가 실행될 것입니다.다음으로는 useRef에 대해 알아보겠습니다.
3. useRef
(1) useRef 소개
DOM 요소에 접근할 수 있도록 하는 React Hook 이에요. HTML과 javascript를 사용했을 때 특정 DOM을 선택하기 위해서 다음과 같이 했었죠.
// (1) getElementById 이용
const divTag = document.getElementById('#myDiv');
// (2) querySelector 이용
const divTag2 = document.querySelector('#myDiv');
리액트에서도 DOM을 선택해야 할 상황이 생기기 마련이에요. 예를 들면 화면이 렌더링 되자마자 특정 input 태그가 focusing이 돼야 하는 경우 등이요. 그럴 경우에 우리는 useRef hook을 사용합니다.
ref 값은 컴포넌트가 계속해서 렌더링 되어도 unmount 전까지 값을 유지한다는 특징이 있습니다.
또한 ref에 저장한 값은 렌더링을 일으키지 않아요. 즉, ref의 값 변화가 일어나도 렌더링으로 인해 내부 변수들이 초기화 되는 것을 막을 수 있죠.
(2) DOM 접근
<input /> 태그에는 ref라는 속성이 있습니다. 이걸 통해 우리는 해당 DOM 요소로 접근할 수 있어요.
import { useEffect, useRef } from "react";
import "./App.css";
function App() {
const idRef = useRef("");
// 렌더링이 될 때
useEffect(() => {
idRef.current.focus();
}, []);
return (
<>
<div>
아이디 : <input type="text" ref={idRef} />
</div>
<div>
비밀번호 : <input type="password" />
</div>
</>
);
}
export default App;
다음과 같이 화면이 렌더링 되자마자 ref를 설정해준 아이디에 focus가 되는 것을 확인할 수 있습니다.
다음으로는 useContext에 대해 알아보겠습니다.
4. useContext
(1) react context의 필요성
우리는 일반적으로 부모컴포넌트 → 자식 컴포넌트로 데이터를 전달해 줄 때 props 를 이용했습니다.
그러나 부모 → 자식 → 그 자식 → 그자식의 자식 이렇게 너무 깊어지게 되면 prop drilling 현상이 일어나요.
이는 prop이 어떤 컴포넌트로 부터 왔는지 파악이 어려워지고 오류가 발생했을 때 어떤 컴포넌트에서 발생했는지 추적하기 힘들어 진답니다.
그래서 등장한 것이 바로 react context API라는 것입니다. useContext hook을 통해 쉽게 전역 데이터를 관리할 수 있게 되었습니다.
(2) context API 필수 개념
- createContext : context 생성
- Consumer : context 변화 감지
- Provider : context 전달(to 하위 컴포넌트)
2. 코드로 구현해보기
(1) useContext를 사용하지 않았을 때
- 구조는 다음과 같아요
- App.jsx
import "./App.css";
import GrandFather from "./components/GrandFather";
export function App() {
return <GrandFather />;
}
export default App;
- GrandFather.jsx
import React from "react";
import Father from "./Father";
function GrandFather() {
const houseName = "스파르타";
const pocketMoney = 10000;
return <Father houseName={houseName} pocketMoney={pocketMoney} />;
}
export default GrandFather;
- Father.jsx
import React from "react";
import Child from "./Child";
function Father({ houseName, pocketMoney }) {
return <Child houseName={houseName} pocketMoney={pocketMoney} />;
}
export default Father;
- Child.jsx
import React from "react";
function Child({ houseName, pocketMoney }) {
const stressedWord = {
color: "red",
fontWeight: "900",
};
return (
<div>
나는 이 집안의 막내에요.
<br />
할아버지가 우리 집 이름은 <span style={stressedWord}>{houseName}</span>
라고 하셨어요.
<br />
게다가 용돈도 <span style={stressedWord}>{pocketMoney}</span>원만큼이나
주셨답니다.
</div>
);
}
export default Child;
- 결과 화면
- GrandFather 컴포넌트는 Child 컴포넌트에게 houseName과 pocketMoney를 전달해주기 위해 Father 컴포넌트를 거칠 수 밖에 없었네요. 단적인 예시였지만 중간 컴포넌트가 100개라면 엄청나게 비효율이겠죠. 자, 이제 useContext hook을 적용해봅시다.
(2) useContext를 사용했을 때
- #1. context > FamilyContext.js 생성
import { createContext } from "react";
// 여기서 null이 의미하는 것은 무엇일까요?
export const FamilyContext = createContext(null);
- #2. GrandFather.jsx 수정
import React from "react";
import Father from "./Father";
import { FamilyContext } from "../context/FamilyContext";
function GrandFather() {
const houseName = "스파르타";
const pocketMoney = 10000;
return (
<FamilyContext.Provider value={{ houseName, pocketMoney }}>
<Father />
</FamilyContext.Provider>
);
}
export default GrandFather;
- #3. Father.jsx 수정(props를 제거해요!)
import React from "react";
import Child from "./Child";
function Father() {
return <Child />;
}
export default Father;
- #4. Child.jsx 수정
import React, { useContext } from "react";
import { FamilyContext } from "../context/FamilyContext";
function Child({ houseName, pocketMoney }) {
const stressedWord = {
color: "red",
fontWeight: "900",
};
const data = useContext(FamilyContext);
console.log("data", data);
return (
<div>
나는 이 집안의 막내에요.
<br />
할아버지가 우리 집 이름은 <span style={stressedWord}>{data.houseName}</span>
라고 하셨어요.
<br />
게다가 용돈도 <span style={stressedWord}>{data.pocketMoney}</span>원만큼이나
주셨답니다.
</div>
);
}
export default Child;
- 자, Child.jsx에서 console.log를 찍어보면 어떻게 나올까요?
- 오…! GrandFather → Context(중앙 관리소) → Child 순서로 잘 전달이 됐군요!
- 이제 이 object를 이용해서 뿌려보죠.
<span style={stressedWord}>{data.houseName}</span>
<span style={stressedWord}>{data.pocketMoney}</span>
3. 주의해야 할 사항
- 렌더링 문제
- useContext를 사용할 때, Provider에서 제공한 value가 달라진다면 useContext를 사용하고 있는 모든 컴포넌트가 리렌더링 됩니다. 따라서 value 부분을 항상 신경써줘야 해요!
다음으로는 최적화 hooks(React.memo, useMemo, useCallback)에 대해 알아보겠습니다.
5. 최적화 hooks(React.memo, useMemo, useCallback)
1. 시작하기에 앞서
(1) 리-렌더링의 발생 조건
- 컴포넌트에서 state가 바뀌었을 때
- 컴포넌트가 내려받은 props가 변경되었을 때
- 부모 컴포넌트가 리-렌더링 된 경우 자식 컴포넌트는 모두 리-렌더링
(2) 최적화
리액트에서 리렌더링이 빈번하게, 자주 일어난다는 것은 그렇게 좋은 소식은 아니에요. 비용이 발생하는 것은 최대한 줄여야 합니다. 이런 작업을 우리는 최적화(Optimization)이라고 불러요. 리액트에서 불필요한 렌더링이 발생하지 않도록 최적화하는 대표적인 방법이 바로
- memo(React.memo) : 컴포넌트를 캐싱
- useCallback : 함수를 캐싱
- useMemo : 값을 캐싱
2. memo(React.memo)
(1) memo란?
리-렌더링의 발생 조건 중 3번째 경우. 즉, 부모 컴포넌트가 리렌더링 되면 자식컴포넌트는 모두 리렌더링 된다는 것은 그림으로 보면
- 1번 컴포넌트가 리렌더링 된 경우, 2~7번이 모두 리렌더링 된다.
- 4번 컴포넌트가 리렌더링 된 경우, 6, 7번이 모두 리렌더링 된다.라는 의미와 같아요.
자녀 컴포넌트의 입장에서는 “나는 바뀐게 없는데 왜 다시 렌더링 돼야하지?”라고 할 수 있겠죠. 이 부분을 돕는 도구가 바로 React.memo입니다.
(2) 코드를 통해 문제상황 살펴보기
- 디렉토리 구성
- App.jsx
import React, { useState } from "react";
import Box1 from "./components/Box1";
import Box2 from "./components/Box2";
import Box3 from "./components/Box3";
const boxesStyle = {
display: "flex",
marginTop: "10px",
};
function App() {
console.log("App 컴포넌트가 렌더링되었습니다!");
const [count, setCount] = useState(0);
// 1을 증가시키는 함수
const onPlusButtonClickHandler = () => {
setCount(count + 1);
};
// 1을 감소시키는 함수
const onMinusButtonClickHandler = () => {
setCount(count - 1);
};
return (
<>
<h3>카운트 예제입니다!</h3>
<p>현재 카운트 : {count}</p>
<button onClick={onPlusButtonClickHandler}>+</button>
<button onClick={onMinusButtonClickHandler}>-</button>
<div style={boxesStyle}>
<Box1 />
<Box2 />
<Box3 />
</div>
</>
);
}
export default App;
- Box1.jsx
import React from "react";
const boxStyle = {
width: "100px",
height: "100px",
backgroundColor: "#91c49f",
color: "white",
// 가운데 정렬 3종세트
display: "flex",
justifyContent: "center",
alignItems: "center",
};
function Box1() {
console.log("Box1이 렌더링되었습니다.");
return <div style={boxStyle}>Box1</div>;
}
export default Box1;
- Box2.jsx
import React from "react";
const boxStyle = {
width: "100px",
height: "100px",
backgroundColor: "#4e93ed",
color: "white",
// 가운데 정렬 3종세트
display: "flex",
justifyContent: "center",
alignItems: "center",
};
function Box2() {
console.log("Box2가 렌더링되었습니다.");
return <div style={boxStyle}>Box2</div>;
}
export default Box2;
- Box3.jsx
import React from "react";
const boxStyle = {
width: "100px",
height: "100px",
backgroundColor: "#c491be",
color: "white",
// 가운데 정렬 3종세트
display: "flex",
justifyContent: "center",
alignItems: "center",
};
function Box3() {
console.log("Box3가 렌더링되었습니다.");
return <div style={boxStyle}>Box3</div>;
}
export default Box3;
자, plus 버튼 또는 minus 버튼을 누른 순간 어떻게 되나요?
- chrome 브라우저
모든 하위 컴포넌트가 리렌더링 되고 있습니다. 실제로 변한 것은 부모컴포넌트, App.jsx 뿐인데도 말이죠.
(3) memo를 통해 해결해보기
우리는 정말 간단히 React.memo를 이용해서 컴포넌트를 메모리에 저장해두고 필요할 때 갖다 쓰게 됩니다. 이렇게 하면 부모 컴포넌트의 state의 변경으로 인해 props가 변경이 일어나지 않는 한 컴포넌트는 리렌더링 되지 않아요. 이것을 컴포넌트 memoization 이라고 합니다.
Box1.jsx, Box2.jsx, Box3.jsx 모두 동일
export default React.memo(Box1);
export default React.memo(Box2);
export default React.memo(Box3);
자, 그러면 최초 렌더링 이외에는 App.jsx 컴포넌트의 state가 변경되더라도 자식 컴포넌트들은 렌더링이 되지 않습니다. App.jsx 컴포넌트만 렌더링이 됐어요.
3. useCallback
(1) useCallback이란?
React.memo는 컴포넌트를 메모이제이션 했다면, useCallback은 인자로 들어오는 함수 자체를 기억(메모이제이션)해요.
(2) 예제를 통해 보는 useCallback의 필요성
Box1이 만일, count를 초기화 해 주는 코드라고 가정해봅시다.
- App.jsx
...
// count를 초기화해주는 함수
const initCount = () => {
setCount(0);
};
return (
<>
<h3>카운트 예제입니다!</h3>
<p>현재 카운트 : {count}</p>
<button onClick={onPlusButtonClickHandler}>+</button>
<button onClick={onMinusButtonClickHandler}>-</button>
<div style={boxesStyle}>
<Box1 initCount={initCount} />
<Box2 />
<Box3 />
</div>
</>
);
}
...
- Box1.jsx
...
function Box1({ initCount }) {
console.log("Box1이 렌더링되었습니다.");
const onInitButtonClickHandler = () => {
initCount();
};
return (
<div style={boxStyle}>
<button onClick={onInitButtonClickHandler}>초기화</button>
</div>
);
}
...
(3) useCallback 사용을 통한 함수 메모이제이션
const onInitButtonClickHandler = () => {
initCount();
};
이 함수를 메모리 공간에 저장해놓고, 특정 조건이 아닌 경우엔 변경되지 않도록 해야겠어요.
useCallback hook의 적용
- App.jsx
// 변경 전
const initCount = () => {
setCount(0);
};
// 변경 후
const initCount = useCallback(() => {
setCount(0);
}, []);
자, 이렇게 하고 나니 우리가 원하는 결과대로 Box1.jsx 컴포넌트는 리렌더링이 안되고 있네요 😎
4. useMemo
(1) useMemo란?
일단, 여기서 말하는 memo는 memoization을 뜻해요. 기억한다는 말이죠. 어떤 것을 기억한다는 말일까요?
동일한 값을 반환하는 함수를 계속 호출해야 하면 필요없는 렌더링을 한다고 볼 수 있겠죠? 맨 처음 해당 값을 반환할 때 그 값을 특별한 곳(메모리)에 저장해요. 이렇게 하면 필요할 때 마다 다시 함수를 호출해서 계산하는게 아니라 이미 저장한 값을 단순히 꺼내와서 쓸 수 있겠죠. 보통 이러한 기법을 캐싱을 한다. 라고 표현하기도 해요.
(2) 사용방법
// as-is
const value = 반환할_함수();
// to-be
const value = useMemo(()=> {
return 반환할_함수()
}, [dependencyArray]);
dependency Array의 값이 변경 될 때만 반환할_함수()가 호출됩니다.
그 외의 경우에는 memoization 해놨던 값을 가져오기만 해요.
(3) useMemo 적용해보기 - 1
HeavyComponent 안에서는 const value = heavyWork() 를 통해서 value값을 세팅해주고 있어요. 만약 heavyWork가 엄청나게 무거운 작업이라면 다른 state가 바뀔 때 마다 계속해서 호출이 되겠죠! 하지만 useMemo()로 감싸주게 되면 그럴 걱정이 없답니다.
- App.jsx
import "./App.css";
import HeavyComponent from "./components/HeavyComponent";
function App() {
const navStyleObj = {
backgroundColor: "yellow",
marginBottom: "30px",
};
const footerStyleObj = {
backgroundColor: "green",
marginTop: "30px",
};
return (
<>
<nav style={navStyleObj}>네비게이션 바</nav>
<HeavyComponent />
<footer style={footerStyleObj}>푸터 영역이에요</footer>
</>
);
}
export default App;
- components > HeavyComponent.jsx
import React, { useState, useMemo } from "react";
function HeavyButton() {
const [count, setCount] = useState(0);
const heavyWork = () => {
for (let i = 0; i < 1000000000; i++) {}
return 100;
};
// CASE 1 : useMemo를 사용하지 않았을 때
const value = heavyWork();
// CASE 2 : useMemo를 사용했을 때
// const value = useMemo(() => heavyWork(), []);
return (
<>
<p>나는 {value}을 가져오는 엄청 무거운 작업을 하는 컴포넌트야!</p>
<button
onClick={() => {
setCount(count + 1);
}}
>
누르면 아래 count가 올라가요!
</button>
<br />
{count}
</>
);
}
export default HeavyButton;
- 결과화면
(4) 주의해야 할 사항
useMemo를 남발하게 되면 별도의 메모리 확보를 너무나 많이 하게 되기 때문에 오히려 성능이 악화될 수 있습니다. 필요할 때만 쓰기로 합시다!
지금까지 React Hooks에 대해 알아봤는데요. React Hooks에는 정말 다양한 기능들이 존재하네요😮
다들 상황에 맞는 적절한 Hooks을 이용해 완성도 있는 코드를 짜봅시다!
'React' 카테고리의 다른 글
React - React Router Dom 기능 및 동적 라우팅 적용 방법 (1) | 2023.11.27 |
---|---|
React - Redux의 구조와 사용 방법 알아보기 (1) | 2023.11.27 |
React - Styled Components(feat. GlobalStyles) 이용해보기 (0) | 2023.11.24 |
React - 개발환경 세팅 (0) | 2023.11.23 |
React - 자주 사용되는 필수 ES6 문법 (0) | 2023.11.23 |