📘 학습 후기
너무 어려워서 눈물난다. 상태 관리 리랜더링... 눈물난다. 그치만 프로젝트에 적용 할 유용한 정보인 거 같아서 신난다..
input 상태값, 아이템 체크리스트, 리뷰리스트 등등.. 상태 관리로 성능 개선할 점이 보인다.
우아한 타입 스크립트 with 리액트 학습 내용을 정리했다.
리액트 내부 API만을 사용하여 상태를 관리할 수 있지만 성능 문제와 상태의 복잡성으로 인해 Redux, MobX, Recoil 같은 외부 상태 관리 라이브러리를 주로 활용한다.
useState 훅을 가장 많이 사용하며 때에 따라 useReducer와 같은 훅을 사용하기도 한다.Prop drilling
props를 통해 데이터를 전달하는 과정에서 중감 컴포넌트는 해당 데이터가 필요하지 않음에도 자식 컴포넌트에 전달하기 위해 props를 전달해야 하는 과정을 말한다. 컴포넌트의 수가 많아지면 prop drilling으로 인해 코드가 훨씬 복잡해질 수 있다.
상태는 애플리케이션의 복잡성을 증가시키고 동작을 예측하기 어렵게 만든다. 또한 상태가 업데이트될 때마다 리랜더링이 발생하기 때문에 유지보수 및 성능 관점에서 상태의 개수를 최소화하는 것이 좋다.
가능하다면 상태가 없는 stateless 컴포넌트를 활용하는 게 좋다.
어떤 값을 상태로 정의할 때는 다음 2가지 사항을 고려해야 한다.
시간이 지나도 변하지 않는 값이라면, 객체 참조 동일성을 유지하는 방법을 고려해볼 수 있다.
즉 리액트의 기능을 활용하여 컴포넌트 라이프사이클 내에서 마운트될 때 인스턴스가 생성되고, 랜더링될 때마다 동일한 객체 참조가 유지되도록 구현해줘야 한다.
const Component: React.VFC = () => {
const store = new Store();
return (
<StoreProvider store={store}>
<Chlildren>
</StoreProvider>
);
};
객체 참조 동일성이란 메모리 상에서 같은 객체를 참조하고 있는지를 의미한다.
리액트는 리랜더링이 발생할 때 컴포넌트 내부에서 생성된 객체가 새로운 인스턴스로 평가될 수 있다.
const Component = () => {
const store = new Store(); // 리렌더링될 때마다 새로운 객체 생성된다.
};
useEffect(() => {
console.log("스토어 변경 감지");
}, [store]); // store가 매번 변경되므로 useEffect가 불필요하게 실행된다.
객체 참조 동일성을 유지하면, 이러한 불필요한 리랜더링을 줄일 수 있다.
객체의 참조 동일성을 유지하기 위해 널리 사용되는 방법의 하나는 메모이제이션이다.
useMemo를 활용하여 컴포넌트가 마운트될 때만 객체 인스턴스를 생성하고 이후 랜더링에서는 이전 인스턴스를 재활용할 수 있도록 구현할 수 있다.
const store useMemo(() => new Store(), []);
useMemo 없이도 올바르게 동작하도록 코드를 작성하고 나중에 성능 개선을 위해 useMemo를 추가하는 것이 적절한 접근 방식이다.
const [store] = useState(new Store()); // ❌ 리렌더링될 때마다 new Store()가 실행되어 초깃값 설정에 큰 비용이 소요될 수 있다.
const [store] = useState(() => new Store()); // 최초 한 번만 실행된다.
따라서 초깃값을 계산하는 콜백을 지정하는 방식(지연 초기화 방식)을 사용한다.
하지만 useState는 의미론적으로 봤을 때는 좋은 방법이 아니다.
처음에는 상태를 시간이 지나면서 변화되어 랜더링에 영향을 주는 데이터로 정의했지만, 현재의 목적은 모든 랜더링 과정에서 객체의 참조를 동일하게 유지하고자 하는 것이기 때문이다.
useRef가 동일한 객체 참조를 유지하려는 목적으로 사용하기에 가장 적합한 훅이다.
useRef의 인자로 직접 new Store()를 사용하면 useState와 마찬가지로 랜더링마다 불필요한 인스턴스가 생성되므로 아래와 같이 작성해야 한다.
const store = useRef<Store>(null);
if (!store.current) {
store.current = new Store();
}
부모에게서 props로 전달받을 수 있는 props 이거나 기존 상태에서 계산될 수 있는 값은 상태가 아니다.
SSOT는 어떠한 데이터도 단 하나의 출처에서 생성하고 수정해야 한다는 원칙을 의미하는 방법론이다.
다른 값에서 파생된 값을 상태로 관리하게 되면 기존 출처와는 다른 새로운 출처에서 관리하게 되는 것이므로 해당 데이터의 정확성과 일관성을 보장하기 어렵다.
import { useState } from "react";
type UserEmailProps = {
initialEmail: string;
}
const UserEmail: React.VFC<UserEmailProps> = ({initialEmail}) => {
// 1. useState를 사용하여 email 상태를 관리한다.
// 초기값은 props로 전달된 initialEmail
const [email, setEmail] = useState(initialEmail);
// 2. 사용자가 input 필드에 값을 입력할 때 호출되는 함수이다.
// event 객체에서 입력된 값을 가져와서 email 상태를 업데이트 한다.
const onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.target.value);
};
return(
<div>
<input type="text" value={email} onChange={onChangeEmail} />
</div>
);
}
useEffect로 props와 상태를 동기화하면 사용자의 입력이 무시될 수 있다.
props와 상태를 동기화하기 위해 useEffect를 사용한 해결책을 떠올릴 수 있지만, 좋은 방법은 아니다.
만약 사용자가 값을 변경한 뒤에 initialEmail prop이 변경된다면 input 태그의 value는 어떻게 설정될까?
이럴 때는 사용자의 입력을 무시하고 부모 컴포넌트로부터 전달된 initialEmail prop의 값을 value로 설정할 것이다.
const [email, setEmail] = useState(initialEmail);
// initialEmail이 변경될 때마다 email 상태도 업데이트되도록 설정
useEffect(() => {
setEmail(initialEmail);
}, [initialEmail]);
현재 email 상태에 대한 출처는 prop으로 받는 initialEmail과 useState로 생성한 email state이다.
문제를 해결하기 위해 두 출처 간의 데이터를 동기화하기보다 단일한 출처에서 데이터를 사용하도록 변경해줘야 한다.
일반적으로 리액트에서는 상위 컴포넌트에서 상태를 관리하도록 해주는 상태 끌어올리기 기법을 사용한다.
UserEmail에서 관리하던 상태를 부모 컴포넌트로 옮겨서 email 데이터의 출처를 props 하나로 통일할 수 있다.
import { useState } from "react";
type UserEmailProps = {
email: string;
setEmail: React.Dispatch<React.SetStateAction<string>>;
};
const UserEmail: React.VFC<UserEmailProps> = ({email, setEmail}) => {
const onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
setEmail(event.targer.value);
};
return (
<div>
<input type="text" value={email} onChange={onChangeEmail} />
</div>
);
};
두 컴포넌트에서 동일한 데이터를 "상태"로 갖고 있을 때는 두 컴포넌트 간의 상태를 동기화하는 방법을 사용하면 안된다.
대신 가까운 공통 부모 컴포넌트로 상태를 끌어올려서 SSOT를 지킬 수 있도록 해야 한다.
아이템 목록과 선택된 아이템 목록을 가지고 있는 코드다.
이 코드는 아이템 목록이 변경될 때마다 선택된 아이템 목록을 가져오기 위해 useEffect로 동기화 작업을 하고 있다.
이 코드의 문제점은 무엇일까?
const [items, setItems] = useState<Item[]>([]);
const [selectedItems, setSelectedItems] = useState<Item[]>([]);
useEffect(() => {
setSelectedItems(items.filter((item) => item.isSelected));
}, [items]);
내부의 상태끼리 동기화하는 방법이 아니라 여러 출처를 하나의 출처로 합치는 방법을 고민해야 한다.
items가 변경될 때마다 컴포넌트가 새로 랜더링되며, 매번 랜더링될 때마다 selectedItems를 다시 계산하게 된다. 이런 식으로 단일 출처를 가지면서 원하는 동작을 수행하게 할 수 있다.
const [items, setItems] = useState<Item[]>([]);
const selectedItems = items.filter((item) => item.isSelected);
items와 selectedItems 2가지 상태를 유지하면서 useEffect로 동기화하는 과정을 거치면 selectedItems 값을 얻기 위해서 2번의 랜더링이 발생한다.
계산할 수 있는 값을 상태로 관리하지 않고, 직접 자바스크립트 변수에 계산 결과를 담으면 리랜더링 횟수를 줄일 수 있다.
하지만 이 경우에는 매번 랜더링될 때마다 계산을 수행하게 되므로 계산 비용이 크다면 성능 문제가 발생할 수도 있다.
useMemo를 사용하여 items가 변경될 때만 계산을 수행하고 결과를 메모이제이션하여 성능을 개선할 수 있다.
const [items, setItems] = useState<Item[]>([]);
const selectedItems = useMemo(() => veryExpensiveCalculation(items), [items]);
리액트에서 상태를 관리할 때 가장 많이 사용되는 훅이 useState와 useReducer 이다.
이 둘은 언제, 왜 사용하는지에 대해 차이가 있다.
useState 대신 useReducer 사용을 권장하는 경우는 크게 2가지가 있다.
예를 들어 배달의민족 리뷰 리스트를 필터링하여 보여주기 위한 쿼리를 상태로 저장해야 한다고 해보자.
이러한 쿼리는 단순하지 않고 검색 날짜 범위, 리뷰 점수, 키워드 등 많은 하위 필드를 가지게 된다. 페이지네이션을 고려한다면 페이지, 사이즈 등의 필드도 추가될 수 있다.
type DateRangePreset = "TODAY" | "LAST_WEEK" | "LAST_MONTH";
type ReviewRatingString = "1" | "2" | "3" | "4" | "5";
interface ReviewFilter {
// 리뷰 날짜 필터링
startDate: Date;
endDate: Date;
dateRangePreset: Nullable<DateRangePreset>;
// 키워드 필터링
keyword: string[];
// 리뷰 점수 필터링
ratings: ReviewRatingString[];
// ...
}
// Review List Query State
interface State {
filter: ReviewFilter;
page: string;
size: number;
}
useReducer는 '무엇을 변경할지'와 '어떻게 변경할지'를 분리하여 dispatch를 통해 어떤 작업을 할지를 액션으로 넘기고 reducer 함수 내에서 상태를 업데이트하는 방식을 정의한다.
이로써 복잡한 상태 로직을 숨기고 안전성을 높일 수 있다.
// Action 정의
type Action =
| {payload: ReviewFilter; type: "filter";}
| {payload: number; type: "navigate";}
| {payload: number; type: "resize";}
// Reducer 정의
const reducer: React.Reducer<State, Action> = (state, action) => {
switch(action.type) {
case "filter":
return {
};
case "navigate":
return{
};
case "resize":
return{
};
default:
return state;
}
};
// useReducer 사용
const [state, dispatch] = useReducer(reducer, getDefaultState());
// dispatch 예시
dispatch({payload: ReviewFilter; type: "filter";});
dispatch({payload: page; type: "navigate";});
dispatch({payload: size; type: "resize";});
boolean 상태를 토글하는 액션만 사용하는 경우의 예시도 봐보자
import { useReducer } from "react";
// Before
const [fold, setFole] = useState(true);
const toggleFold = () => {
setFole((preve) => !prev);
};
// After
const [fold, toggleFold] = useReducer((v) => !v, true);
| useState | useReducer | |
|---|---|---|
| 언제 사용 | 단순한 상태 관리 | 복잡한 상태 관리(이전 상태 기반 업데이트) |
| 상태 변경 방식 | setState로 직접 변경 | dispatch(action)을 통해 변경 |
| 로직 위치 | 컴포넌트 내부 | reducer 함수에서 관리 |
| 읽기 쉬운 코드 | 간단한 경우 적합 | 상태 로직이 복잡할 때 가독성이 더 좋음 |
| 성능 최적화 | 단순 상태에는 적합 | dispatch로 불필요한 리랜더링을 줄일 수 있음 |