지난 포스팅에서 리액트 컴포넌트에 대해서 공부해봤습니다.
이번 포스팅에서는 컴포넌트의 상태를 관리하는 리액트 훅에 대해서 알아보겠습니다.
*이 포스팅 시리즈는 Next.js 공식 문서와 React 공식 문서, 타입스크립트, 리액트, Next.js로 배우는 실전 웹 애플리케이션 개발을 참고하여 작성했습니다.
예제에 사용된 버전은 다음과 같습니다.
- npm 9.5.0
- react 18.2.0
- typescript 4.9.5
훅(React Hooks)
React Hooks는 React 버전 16.8에서 소개되었습니다.
훅은 함수 컴포넌트 안의 상태(State)나 생명 주기(Lifecycle)를 다루기 위한 기능입니다.
훅을 공부하기 앞서 리액트에서 말하는 상태와 생명 주기에 대해 먼저 알아볼 필요가 있었습니다.
상태와 생명 주기
1. 상태(State)
상태란 컴포넌트 내부에서 변할 수 있는 값을 의미합니다. 이전 예제의 카드 컴포넌트를 다시 가져오겠습니다.
Props로 전달받은 title 아래로 카드를 클릭할 때마다 클릭 수가 증가하는 기능을 추가한다고 가정해보겠습니다.
*Props는 매개변수와 같이 컴포넌트간 전달이 가능하지만 State는 컴포넌트 안에서 관리됩니다.
각 카드에 onclick 이벤트가 추가되고 클릭할 때마다 숫자는 +1씩 증가됩니다.
여기서 숫자가 카드, 즉 컴포넌트의 상태를 의미합니다.
2. 생명 주기(Lifecycle)
컴포넌트의 생명 주기는 컴포넌트가 생성되고, 변경되고 제거되기까지 일련의 과정을 말합니다. 리액트는 컴포넌트의 이벤트를 세가지 유형으로 분류합니다.
- Mounting : 컴포넌트로 작성된 요소들이 DOM에 추가될 때
- Updating : DOM을 형성한 컴포넌트에 변화가 생겼을 때(State 혹은 Props 값을 변경)
- Unmounting : 컴포넌트를 DOM에서 제거할 때
각 생명 주기에서 호출되는 메소드들은 리액트 16.8버전 이전에서는 클래스 컴포넌트에서만 사용이 가능했습니다. 앞서 언급한 "훅은 함수 컴포넌트 안의 상태(State)나 생명 주기(Lifecycle)를 다루기 위한 기능입니다" 의 이유가 여기에 있습니다.
훅 사용해보기
리액트 훅의 종류는 다루는 대상, 기능에 따라 나눠집니다. 공식으로 제공된 리액트 훅은 10종류이며 훅을 조합해 커스텀 훅을 구현할 수 있습니다.
1. useState와 useReducer
위에서 설명한 카드 컴포넌트의 상태 관리를 리액트 훅을 사용해 구현해보겠습니다.
상태를 다루기 위한 리액트 훅으로 useState와 useReducer가 있습니다.
useState를 사용해 카드 클릭시 클릭 수에 숫자가 증가될 수 있도록 추가해보겟습니다.
useState()로 훅을 선언합니다. 인자값으로 초기값을 전달합니다.
useState()의 반환값은 배열로 존재하고, 첫 번째 인자값에는 상태를 유지할 변수, 두 번째 인자값에는 업데이트 함수를 입력합니다.
const [상태, 업데이트 함수] = useState(초기값)
Card.tsx
import { useState } from "react";
import styles from "./Card.module.scss";
type CardProps = {
title: string;
clickCount: number;
};
const Card = (props: CardProps) => {
const { title, clickCount } = props;
// Home에서 전달받은 clickCount값을 초기값으로 갖습니다.
// count는 현재 값을 의미하고, setCount 함수가 상태를 업데이트합니다.
const [count, setCount] = useState(clickCount);
return (
// div 영역 클릭시 onClick 이벤트를 통해 setCount를 호출합니다.
<div className={styles.cardItem} onClick={() => setCount(count + 1)}>
<div>
<p>{title}</p>
<p>
클릭 : <span>{count}</span>
</p>
</div>
</div>
);
};
export default Card;
Card.module.scss
.cardItem {
max-width: 416px;
height: 240px;
border-radius: 16px;
box-shadow: 4px 12px 30px 0 rgba(0, 0, 0, 0.09);
background-color: #fff;
overflow: hidden;
div {
display: flex;
height: 100%;
align-items: center;
justify-content: center;
flex-direction: column;
}
}
CardProps에 number타입의 clickCount 변수를 추가합니다. 이것은 초기 클릭수가 됩니다.
props지정시 Home 페이지에서 에러가 발생할 것입니다. Home 페이지에 아래와 같이 초기값을 설정합니다.
Home.tsx
const Home = () => {
return (
<div className={styles.container}>
<h1>프로젝트 </h1>
<div className={styles.cardList}>
{/* PROJECT_LIST만큼 루프를 돌며 카드 컴포넌트를 그립니다. */}
{PROJECT_LIST.map((item, index) => (
<Card key={item.id} title={item.title} clickCount={0} />
))}
</div>
</div>
);
};
예제를 실행해보겠습니다.
카드 영역을 클릭하면 테두리가 활성화되고 있습니다. 이 테두리는 컴포넌트가 리렌더링이 되는 것을 의미합니다.
onClick 이벤트 핸들러를 통해 setCount가 호출되어 클릭 횟수(count)를 증가시킵니다. 그리고 count의 상태가 증가하게 되고 이는 리렌더링을 트리거합니다.
이와같이 리액트의 리렌더링은 상태 변경에서 시작됩니다. 상태 관리를 어떻게 하느냐에따라 성능에 좋거나 나쁜 영향을 미칠 수 있는 것이죠.
이번엔 useReducer를 이용해보겠습니다. useReducer를 사용하면 복잡한 상태 전이를 간단히 표현할 수 있습니다.
useReducer()로 훅을 선언하고 첫 번째 인자값에는 reducer 함수, 두 번째 인자값에는 초기값을 전달합니다.
useReducer()의 반환값은 배열로 존재하고, 첫 번째 인자값에는 상태를 유지할 변수, 두 번째 인자값에는 dispatch 함수를 입력합니다. dispatch 함수에 action을 전달함으로써 상태를 변경할 수 있습니다. 이 action이 전달되는 곳이 reducer 함수입니다.
const [상태, dispatch] = useState(reducer, 초기값)
reducer 함수는 현재 상태와 액션 객체를 파라미터로 받아 다음 상태를 반환하는 함수입니다.
function reducer(현재 상태, action) {
return 다음 상태
}
이것을 소스에 적용해보겠습니다.
Card.tsx
import { useReducer } from "react";
import styles from "./Card.module.scss";
type CardProps = {
title: string;
clickCount: number;
};
type Action = "INCREMENT" | "DECREMENT";
const reducer = (clickCount: number, action: Action) => {
switch (action) {
case "INCREMENT":
return clickCount + 1;
case "DECREMENT":
return clickCount - 1;
default:
return clickCount;
}
};
const Card = (props: CardProps) => {
const { title, clickCount } = props;
const [count, dispatch] = useReducer(reducer, clickCount);
return (
<div className={styles.cardItem}>
<div>
<p>{title}</p>
<p>
<button onClick={() => dispatch("INCREMENT")}>+</button>
<button onClick={() => dispatch("DECREMENT")}>-</button>
</p>
<p>
클릭 : <span>{count}</span>
</p>
</div>
</div>
);
};
export default Card;
예제를 실행해보겠습니다.
(+), (-) 버튼을 추가해서 클릭 이벤트를 실행합니다.
Action 타입으로 "INCREMENT"와 "DECREMENT"를 정의되었고 dispatch 함수가 호출되면 reducer 함수가 실행되어 상태가 업데이트 됩니다.
useReducer를 사용하면 업데이트 로직이 더 구조적이 되어 복잡한 상태 로직을 다루는데 용이합니다. 더불어 코드를 효율적으로 관리할 수 있습니다.
2. useEffect
리액트 생명 주기를 다루는 훅으로 useEffect가 있습니다. 클래스 컴포넌트에서 사용하던 생명 주기 메소드(componentDidMount, componentDidUpdate, componentWillUnmount,...) 를 훅으로 대체할 수 있게 되었습니다.
useEffect를 사용하여 렌더링이 완료되었을 때, 상태가 변경(props나 state가 업데이트)되었을 때를 처리해보겠습니다.
useEffect()의 첫 번째 인자값에는 함수를 두 번째 인자값에는 배열을 전달합니다.
useEffect(함수, []);
위의 예제에 이어서 카드 컴포넌트가 렌더링 된 이후에 콘솔을 찍어보도록 하겠습니다.
useEffect를 import하고 콘솔을 찍고, 빈 배열을 받는 useEffect를 선언했습니다.
useEffect(() => {
console.log("Card click count : ", count);
});
Card.tsx
import { useReducer, useEffect } from "react";
import styles from "./Card.module.scss";
type CardProps = {
title: string;
clickCount: number;
};
type Action = "INCREMENT" | "DECREMENT";
const reducer = (clickCount: number, action: Action) => {
switch (action) {
case "INCREMENT":
return clickCount + 1;
case "DECREMENT":
return clickCount - 1;
default:
return clickCount;
}
};
const Card = (props: CardProps) => {
const { title, clickCount } = props;
const [count, dispatch] = useReducer(reducer, clickCount);
useEffect(() => {
console.log("Card click count : ", count);
});
return (
<div className={styles.cardItem}>
<div>
<p>{title}</p>
<p>
<button onClick={() => dispatch("INCREMENT")}>+</button>
<button onClick={() => dispatch("DECREMENT")}>-</button>
</p>
<p>
클릭 : <span>{count}</span>
</p>
</div>
</div>
);
};
export default Card;
3개의 카드가 렌더링될 때마다 로그가 찍혔습니다.
여기서 로그가 두 번 찍힌 이유는 index.tsx에 <App>을 감싸고 있는 <React.StrictMode> 때문입니다.
React.StrictMode란
React.StrictMode는 개발 모드에서만 활성화되는 애플리케이션 검사 도구입니다. 리액트에서 안전하지 않은 코드를 감지하기 위해 의도적으로 두 번 마운팅을 시키기 때문에 로그가 두 번 찍히게 됩니다.
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);
카드가 렌더링될 때 마다 로그가 찍히는 것을 보니 상태가 변할 때에도(리렌더링이 발생하게 되면서) 역시 로그가 찍힐 것 같습니다.
예상한대로 렌더링 이후에 상태가 변할 때도 로그가 찍힙니다.
그럼 카드의 카운트가 변하는 경우가 아닐 때에는 어떨까요?
useState를 추가해 새로운 버튼 X를 추가해봤습니다. 버튼 X는 카드 카운트와는 연관없는 버튼입니다. 하지만 useState 훅을 통해 상태가 변화합니다.
// 카운트 되지 않아야되는 상태
const [notCount, setNotCount] = useState(0);
useEffect(() => {
console.log("Card click count : ", count);
});
return (
<div className={styles.cardItem}>
<div>
<p>{title}</p>
<p>
<button onClick={() => dispatch("INCREMENT")}>+</button>
<button onClick={() => dispatch("DECREMENT")}>-</button>
<button onClick={() => setNotCount(notCount + 1)}>X</button>
</p>
<p>
클릭 : <span>{count}</span>
</p>
</div>
</div>
);
역시 로그가 찍힙니다.
useEffect의 두 번째 인자값이 빈 값일 때는 컴포넌트가 렌더링되는 경우에만 실행됩니다. 카드의 값이 변하지 않았어도 상태가 변하고 컴포넌트가 리렌더링되었기 때문에 로그가 찍힌 것이죠.
그럼 카드의 카운트값이 변경될 때에만 로그를 찍으려면 어떻게 하면 될까요?
두 번째 인자값에 count 변수를 추가합니다. 이를 의존성 배열이라고 합니다. 이렇게 되면 단순히 컴포넌트가 마운트될 때가 아닌 의존성 배열에 존재하는 데이터가 변경될 때에만 함수가 실행됩니다.
useEffect(() => {
console.log("Card click count : ", count);
}, [count]);
이번에는 최초 렌더링될 때만 로그가 찍히게 해보겠습니다. useEffect()에서 두 번째 인자값에 빈 배열, []을 전달합니다.
useEffect(() => {
console.log("Card created!");
}, []);
3. useMemo
기본적으로 부모 컴포넌트가 렌더링되면 자식 컴포넌트도 렌더링됩니다. 이 말은 부모 컴포넌트가 다시 그려지면 자식 컴포넌트도 다시 그려진다는 것을 의미합니다.
useMemo는 이런 상황에서 자식 요소의 불필요한 렌더링을 막기 위한 기능입니다. 여기서 Memo는 Memoized를 의미하는데 처음 계산된 결과값을 메모리에 저장해서 컴포넌트가 반복적으로 렌더링 되어도 이미 계산된 결과는 메모리에 꺼내와 재사용하게 해줍니다.
예제를 통해 확인해보겠습니다.
카드 컴포넌트 내부에 메뉴얼 컴포넌트를 추가해보겠습니다. 메뉴얼 컴포넌트는 카드 컴포넌트의 자식 컴포넌트로 버튼 클릭에 따라 로그를 확인할 수 있도록 했습니다.
기존 카드 컴포넌트의 [+][-] 버튼을 클릭하면 '카드가 클릭되었습니다' 라는 문구가 [x] 버튼을 클릭하면 '카드가 삭제됩니다'라는 문구가 조회됩니다.
Manual.tsx
type ManualProps = {
clickCount: number;
cardStatus: string;
};
const setClickCount = (clickCount: number) => {
console.log("카드가 클릭되었습니다.");
};
const setCardStatus = (cardStatus: string) => {
console.log("카드가 삭제됩니다.");
};
const Manual = (props: ManualProps) => {
const { clickCount, cardStatus } = props;
setClickCount(clickCount);
setCardStatus(cardStatus);
return (
<div>
<span>{clickCount}</span>
</div>
);
};
export default Manual;
Card.tsx
import { useState, useReducer, useEffect } from "react";
import styles from "./Card.module.scss";
import Manual from "../Manual/Manual";
type CardProps = {
title: string;
clickCount: number;
};
type Action = "INCREMENT" | "DECREMENT";
const reducer = (clickCount: number, action: Action) => {
switch (action) {
case "INCREMENT":
return clickCount + 1;
case "DECREMENT":
return clickCount - 1;
default:
return clickCount;
}
};
const Card = (props: CardProps) => {
const { title, clickCount } = props;
const [count, dispatch] = useReducer(reducer, clickCount);
const [cardStatus, setStatus] = useState("");
useEffect(() => {
// console.log("Card click count : ", count);
}, [count]);
return (
<div className={styles.cardItem}>
<div>
<p>{title}</p>
<p>
<button onClick={() => dispatch("INCREMENT")}>+</button>
<button onClick={() => dispatch("DECREMENT")}>-</button>
<button onClick={() => setStatus("X")}>X</button>
</p>
<Manual clickCount={count} cardStatus={cardStatus} />
</div>
</div>
);
};
export default Card;
하지만 예상과는 반대로 버튼 클릭시 모든 문구가 조회됩니다.
이는 부모 컴포넌트가 리렌더링되는 과정에서 자식 컴포넌트 역시 리렌더링되기 때문입니다.
useMemo를 사용해 메모이제이션할 값을 설정합니다.
useMemo()도 useEffect()와 동일한 구조입니다. 첫 번째 인자값에는 함수를 두 번째 인자값에는 배열을 전달합니다.
useMemo(함수, []);
useMemo를 임포트하고 setClickCount와 setCardStatus 함수를 useMemo로 감싸고 첫 번째 인자값으로 각 함수를 전달하고, 두 번째 인자값으로 비교할 값을 전달합니다.
import { useMemo } from "react";
useMemo(() => setClickCount(clickCount), [clickCount]);
useMemo(() => setCardStatus(cardStatus), [cardStatus]);
다시 실행해보겠습니다.
이번엔 버튼에 따라 다른 로그가 출력되는 것을 확인할 수 있습니다.
여기까지 컴포넌트와 훅에 대해서 알아봤는데요. 훅의 종류는 포스팅에 소개된 내용 외에도 여러가지가 있습니다. 필요에 따라 사용되는 훅이 다르고, 여러 훅을 조합하여 커스텀 훅을 만들 수도 있습니다.
다음 포스팅부터 Next.js에 대한 내용이 소개됩니다.
저희는 스터디를 통해 글을 기록하고 있습니다. 피드백은 언제나 환영입니다 :)
'프론트엔드' 카테고리의 다른 글
Next.js + MongoDB (1) | 2024.01.09 |
---|---|
자 이제 시작이야, Next 세계로 (3) | 2024.01.04 |
Hello Typescript - part.2 (1) | 2024.01.02 |
React 맛보기 - React Component (0) | 2023.12.29 |
Hello Typescript - part.1 (1) | 2023.12.27 |