리액트는 훅(hook)의 등장으로 클래스형 컴포넌트에서 함수형 컴포넌트로 그 흐름이 바뀌었다. 중요한 내용인데 커리어코칭 받으러 간 사이 진도 나간 부분이라 코딩온 강의로 복습한 내용을 정리해보려 한다.
함수형 컴포넌트의 특징은 바로 자바스크립트의 함수 특징이기도 하다. 함수는 렌더링 되는 시점에 실행된다.
렌더링은 아래와 같은 상황에서 발생한다.
선언 -> state 초기화 -> 메모리 할당을 이때마다 반복하는데, 불필요한 렌더링은 성능 저하의 요인이다. 이를 방지하고자 useMemo와 useCallback을 사용한다.
이때 에러 없이 훅을 사용하기 위해 지켜야 할 규칙이 있다.
✅ 최상위에서 호출
함수 컴포넌트 내에서만 호출하되 다른 함수나 조건문, 반복문, 중첩 함수 등에서는 훅을 호출하면 안 된다. 그렇지 않으면 리액트가 훅의 호출 순서를 파악하기 어려워져 예상치 못한 문제가 발생할 수 있다.
✅ 의존성 배열은 상수로
useEffect, useCallback, useMemo 등에서 의존성 배열을 사용할 때 변하지 않는 값(상수)만 포함해야 한다. 변수를 사용 시 의도치 않은 동작이 발생할 수 있습니다.
✅ 커스텀 훅의 이름은 use
로 시작하기
커스텀 훅도 리액트 내장 훅과 마찬가지로 use
로 시작하는 함수 이름을 사용하는 게 관례다.
그럼 함수형 컴포넌트의 불필요한 리렌더링을 방지하는 useMemo와 useCallback, 그리고 때에 따라 useState보다 나은 useReducer를 간단한 예제와 함께 살펴보자.
const memoizedValue = useMemo(콜백 함수, [의존성 배열]);
인자로 받은 콜백 함수의 연산 결과를 저장해서 재사용할 수 있게 하는 hook이다. 의존성 배열이 바뀔 때에만 해당 콜백 함수를 실행하고, 그 결과를 저장한다.
결과값을 저장하기 때문에 함수가 똑같은 연산을 반복할 필요가 없다. '값'이라는 말이 다소 와닿지 않아서 예제를 적용해 보는 게 좋겠다.
입력한 문장에서 포함된 단어 개수를 띄어쓰기 기준으로 찾는다.
import { useState, useMemo } from 'react';
export default function SearchWord() {
const [text, setText] = useState('');
const [searchWord, setSearchWord] = useState('');
const countWord = useMemo(() => {
if (text.trim() && searchWord.trim()) {
const words = text.split(' ');
return words.filter((word) => word.includes(searchWord)).length;
}
return 0;
}, [text, searchWord]);
return (
<>
<h2>
문장 입력 :
<input
type="text"
value={text}
onChange={(e) => setText(e.target.value)}
/>
</h2>
<h2>
찾을 단어 :
<input
type="text"
value={searchWord}
onChange={(e) => setSearchWord(e.target.value)}
/>
</h2>
<h1>
{searchWord} 단어의 빈도수 : {countWord}{' '}
</h1>
</>
);
}
입력한 문장(text
)과 찾을 단어(searchWord
)가 변경될 때에만 연산(단어 찾기)하도록 작성한다. 즉 불필요한 연산 반복을 방지하기 위해 useMemo를 사용하는 게 적절하다.
length
로 단어 개수를 연산하고 있어서 빈 배열도 1개로 간주된다. 이를 방지하고자 trim
메서드로 '빈 문자열이 아닌 경우'를 조건으로 달아둔다.
useMemo와 생김새는 동일하다.
const memoizedCallback = useCallback(콜백 함수, [의존성 배열]);
다만 콜백 함수의 연산 결과가 아닌 콜백 함수 자체를 저장한다.
세 개의 아이템 리스트가 주어진다. edit 버튼을 누르면 input 창이 생기며 현재 value가 담기고, 버튼이 save로 바뀐다. save를 누르면 입력한 값대로 변경된다. Delete를 누르면 삭제된다.
import { useCallback, useState } from 'react';
export default function ItemList() {
const [items, setItems] = useState(['item 1', 'item 2', 'item 3']);
const [editing, setEditing] = useState(null);
const [editText, setEditText] = useState('');
const edit = useCallback((item) => {
setEditing(item);
setEditText(item);
}, []);
const del = useCallback(
(itemToDel) => {
setItems(items.filter((item) => item !== itemToDel));
},
[items]
);
const save = useCallback(
(itemToSave) => {
setItems(items.map((item) =>
(item === editing ? itemToSave : item)));
// 그냥 save 눌러도 동작하게끔
setEditing(null);
},
[items, editing]
);
return (
<>
<ul>
{items.map((item, i) => (
<li key={i}>
{editing === item ? (
<input
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
/>
) : (
item
)}
{editing === item ? (
<button onClick={() => save(editText)}>Save</button>
) : (
<button onClick={() => edit(item)}>Edit</button>
)}
<button onClick={() => del(item)}>Delete</button>
</li>
))}
</ul>
</>
);
}
바뀔 요소부터 생각한다. 아이템 리스트(items
)와 수정 텍스트(editText
). 그리고 아이템 리스트가 여럿이니까 하나하나를 특정할 수 있는 새로운 state(editing
)도 만든다.
상황에 따라 UI가 달라지고, 이를 처리하려면 각 요소를 특정할 수 있어야 한다. map으로 현재 item을 가리킬 수 있다. 삼항연산자로 editing
과 현재 item을 비교해 html 부분을 만든다.
모든 변화는 버튼으로 생긴다. onClick 이벤트가 3개 필요. 공통적으로 리스트 중 하나를 특정할 수 있어야 한다. 때문에 인자를 넘긴다.
edit : 현재 item을 setEditing
에 넣어 특정한다. 그리고 input의 value도 현재 item으로 변경(setEditText
)한다.
➡️ 특정 값의 변화와 무관하므로 의존성 배열은 빈 배열.
save : setItems
로 현재 변경한 값(editText
에 담긴 값)을 업데이트한다. 마찬가지로 현재 item을 특정해야 하니까 map을 순회하며 edit 버튼에서 설정한 editing
과 비교한다. 해당하면 바뀐 텍스트를, 해당하지 않으면 원래의 item을 저장한다.
➡️ 아이템 리스트와 현재 item에 무엇이 담겼는지에 따라 값이 달라지므로 두 가지를 의존성 배열에 넣었다.
delete : filter 메서드로 현재 item과 다른, 즉 삭제 버튼을 누르지 않은 item들로만 setItems
를 구성한다.
➡️ 아이템 리스트가 바뀌니까 items
를 의존성 배열에.
새 item이 업데이트된 버전으로 state 만들 생각했는데 (ex. newItemList
) 순회를 돌면 바뀐 부분도, 기존 부분도 state 없이 한꺼번에 저장할 수 있다. 바닐라 자바스크립트랑 접근 방식이 다르단 걸 느꼈다.
둘다 함수 메모이제이션인 건 동일한데 대상이 다르다. 전자는 연산 결과, 후자는 연산하는 함수를 기억한다. 다만 이 표현을 구분하기가 어려워서 어떨 때에 자주 쓰이는지를 알아두는 게 이해에 도움될 것 같다.
🔸 useMemo
자주 반복하는 연산의 결과를 기억하고, 의존성 배열에 있는 값이 변경되었을 때에만 해당 연산을 다시 수행.
🔸 useCallback
함수 자체를 저장. 때문에 자식 컴포넌트에 전달하는 콜백 함수로 인해 자식 컴포넌트가 불필요하게 리렌더링되는 것을 방지하는 용도.
useState는 상태를 개별적으로 관리한다. 하지만 컴포넌트 내에 여러 개의 상태를 관리해야 할 때도 발생한다. 예를 들어 회원가입의 경우 유효성 검사, 에러 메세지 등 여러 상태 변화가 일어나는데 각각을 useState로 관리하기엔 비효율적이다.
이렇게 다양한 액션 타입이 필요하거나 상태가 서로 연결되어 있어서 한 상태의 변경이 다른 상태에 영향을 줄 때, 계산 로직이 복잡할 때, 미들웨어를 추가할 때는 useReducer가 더 적절할 수 있다.
const [state, dispatch] = useReducer(reducer, initialValue);
숫자 카운터 기능을 만든다고 하자. 더하기, 빼기, 리셋 버튼이 존재한다.
// components/Counter.js
import { useReducer } from 'react';
const INCREASE = 'counter/INCREASE';
const DECREASE = 'counter/DECREASE';
const RESET = 'counter/RESET';
export default function Counter() {
const initialValue = { value: 0 };
const reducer = (prevState, action) => {
switch (action.type) {
case INCREASE:
return { value: prevState.value + 1 };
case DECREASE:
return { value: prevState.value - 1 };
case RESET:
return initialValue;
default:
return { value: prevState.value };
}
};
const [state, dispatch] = useReducer(reducer, initialValue);
const increase = () => dispatch({ type: INCREASE });
const decrease = () => dispatch({ type: DECREASE });
const reset = () => dispatch({ type: RESET });
return (
<>
<div>
<h2>{state.value}</h2>
<button onClick={increase}>+1</button>
<button onClick={decrease}>-1</button>
<button onClick={reset}>RESET</button>
</div>
</>
);
}
action(객체)에는 기본적으로 type을 작성해 이를 기준으로 case를 나눈다. 그런데 프로젝트 규모가 커질수록 구현할 기능 가짓수가 많아지고, 그만큼 type 이름이 중복될 가능성이 커진다.
이를 방지하고자 변수를 선언해 현재 reducer를 나타내는 부분과 기능을 함께(ex. counter/INCREASE
) 작성해주었다. 해당하는 모든 타입의 수정을 한번에 적용할 수 있어 편리성도 좋다.
state의 초기값이 객체({ value: 0 }
)라서 reducer나 JSX 부분 모두에서 state.value
로 객체를 타고 들어갔다.
리액트 훅 공부를 깊게 하지 않고 리덕스로 넘어갔더니 reducer 등을 쓰임에 맞게 나누는 예제에서 이해하기가 어려웠다. useReducer부터 다시 짚고 시작하니까 dispatch를 실행할 때 넘겨주는 인자가 action의 type과 그외 전달할 action의 key라는 걸 제대로 인지하게 되었다.
훅 사용하는 게 익숙하지 않은 것보단 로직 공부가 더 필요한 것 같다.
리덕스 정리하고 todo 토이로 만들려고 했는데 우선 로직 연습할 수 있도록 해보는 게 좋겠다.. 정리도 좋지만 많이 해보는 것도 중요하니까 우선순위 분배를 잘하자.