리액트 애플리케이션에서의 상태는 렌더링 영향을 줄 수 있는 동적인 데이터 값을 말한다.
컴포넌트 내부에서 사용되는 상태로 체크박스의 체크 여부나 폼의 입력값 등이 해당.
주로 useState 훅을 가장 많이 사용, 때에 다라 useReducer와 같은 훅을 사용
앱 전체에서 공유되는 상태를 의미. Prop drilling 문제를 피하고자 지역 상태를 해당 컴포넌트들 사이의 전역 상태로 공유
사용자 정보, 글 목록 등 외부 서버에서 저장해야 하는 상태들을 의미한다.
UI 상태와 결합하여 관리하게 되며 로딩 여부나 에러 상태 등을 포함.
서버 상태는 지역 상태 혹은 전역 상태와 동일한 방법으로 관리된다.
어떤 값을 상태로 정의할 때는 2가지 사항을 고려
시간이 지나도 변하지 않는다면 객체 참조 동일성을 유지하는 방법을 고려
컴포넌트가 마운트될 때만 스토어 객체 인스턴스를 생성하고, 컴포넌트가 언마운트될 때까지 해당 참조가 변하지 않는다고 가정, 이를 단순히 상수 변수에 저장하여 사용할 수도 있지만, 이러한 방식은 렌더링될 때마다 새로운 객체 인스턴스가 생성되기 때문에 컨텍스트나 props 등으로 전달 했을 시 불필요한 리렌더링이 자주 발생
따라서 리액트의 다른 기능을 활용하여 컴포넌트 라이프사이클 내에서 마운트될 때 인스턴스가 생성되고, 렌더링될 때마다 동일한 객체 참조가 유지되도록 구현
const Component:React.VFC=()=> {
const store = new Store();
return (
<StoreProvider store={store}>
<Children/>
</StoreProvider>
)
}
객체의 참조 동일성을 유지하기 위해 널리 사용되는 방법의 하나는 메모이제이션
useMemo를 활용하여 컴포넌트가 마운트될 떄만 객체 인스턴스를 생성하고 이후 렌더링에서는 이전 인스턴스를 재활용할 수 있도록 구현
const store = useMemo(()=>new Store(),[]);
객체 참조 동일성을 유지하기 위해 useMemo를 사용하는 것은 권장되는 방법이 아니다.
리액트 공식 문서를 보면 useMemo를 통한 메모이제이션은 의미상으로 보장된 것이 아니기 때문에 오로지 성능 향상을 위한 용도로만 사용되어야 한다고 언급.
또한 리액트에서는 메모리 확보를 위해 이전 메모이제이션 데이터를 삭제될 수 있다고 한다.
useMemo 없이도 올바르게 동작하도록 코드를 작성하고, 나중에 성능 개선을 위해 useMemo를 추가하는것이 적절한 접근 방식
원하는 대로 동작하게 하는 방법은 아래와 같이 2가지가 있다.
useRef가 동일한 객체 참조를 유지하려는 목적으로 사용하기에 가장 적합한 훅.
const store = useRef<Store>(null);
if(!store.current){
store.current = new Store();
}
상태라고 하는 것은 렌더링에 영향을 주며 변화하는 값을 의미하기 떄문에
의미론적으로 객체 참조 동일성을 휴지하기 위해 useState에 초깃값만 할당하는 것은 적절하지 않다.
가독성 등의 이유로 팀 내에서 합의된 컨벤션으로 지정된 것이 아니라면 동일한 객체 참조를 할 때는 useRef를 사용할 것을 권장
SSOT
어떠한 데이터도 단 하나의 출처에서 생성하고 수정해야 한다는 원칙을 의미하는 방법론
다른 값에서 파생된 값을 상태로 관라히게 되면 기존 출처와는 다른 새로운 출처에서 관리하게 되는 것이므로 해당 데이터의 정확성과 일관성을 보장하기 어렵다.
해당 컴포넌트는 초기 이메일 값을 부모 컴포넌트로부터 받아 input value로 렌더링하고 이후에는 사용자가 입력한 값을 input 태그의 value로 렌더링한다.
type UserEmailProps={
initalEmail:string;
}
export default function Test({initalEmail}) {
const [email,setEmail]= useState(initalEmail)
const onChangeEmail = (e:React.ChagneEvent<HTMLInputElement>)=>{
setEmail(e.target.value)
}
return (
<div><input type="text" value={email} onChange={onChangeEmail}</div>
)
}
위 컴포넌트에서는 전달받은 initalEmail prop의 값이 변경되어도 input 태그의 value는 변경되지 않는다. useState의 초기값으로 설정한 값은 컴포넌트가 마운트될 때 한 번만 email 상태의 값으로 설정되며 이후에는 독자적으로 관리
여기서 props와 상태를 동기화하기 위해 useEffect를 사용한 해결책을 떠올릴 수 있지만 좋은 방법은 아니다. 만약 사용자가 값을 변경한 뒤에 initalEmail prop이 변경된다면 input 태그의 value는 어떻게 설정될까? 이럴 때는 사용자의 입력을 무시하고 부모 컴포넌트로부터 전달된 initialEmail prop의 값을 value로 설정할 것이다.
아래와 같은 리액트 외부 데이터LocalStorage)와 동기화할 때만 사용해야 하며, 내부에 존재하는 데이터를 상태와 동기화하는 데는 사용하면 안됨,.
const [email,setEmail]= useState(initalEmail)
useEffect(()=>{
setEmail(initalEmail)
},[initalEmail])
두 출처 간의 데이터를 동기화하기보다 단일한 출처에서 데이터를 사용하도록 변경해줘야 함.
일반적으로 리액트에서는 상위 컴포넌트에서 상태를 관리하도록 해주는 상태 끌어올리기 기법을 사용한다.
type UserEmailProps={
email:string;
setEmail: React.Dispatch<React.SetStateAction<string>>;
}
const UserEmial:React.VFC<UserEmailProps> = ({email,setEmail}) => {
const [email,setEmail]= useState(initalEmail)
const onChangeEmail = (e:React.ChagneEvent<HTMLInputElement>)=>{
setEmail(e.target.value)
}
return (
<div><input type="text" value={email} onChange={onChangeEmail}</div>
)
}
가까운 공통 부모 컴포넌트로 상태를 끌어올려서 SSOT를 지킬 수 있도록 해야 한다.
다음 예시는 아이템 목록과 선택된 아이템 목록을 가지고 있는 코드다. 이 코드는 아이템 목록이 변경될 때마다 선택된 아이템 목록을 가져오기 위해 useEffect로 동기화 작업을 하고 있다.
import { useEffect, useState } from "react";
export default function Test() {
const [items, setItem] = useState<Item[]>([]);
const [seletedItems, setSelectedItems] = useState<Item[]>([]);
useEffect(() => {
setSelectedItems(items.filter((item) => item.isSelected));
}, []);
return <div>T</div>;
}
새로운 상태로 정의함으로써 단일 출처가 아닌 여러 출처를 가지게 되었고 이에 따라 동기화 문제가 발생하게 된다는 것.
내부의 상태끼리 동기화하는 방법이 아니라 여러 출처를 하나의 출처로 합치는 방법을 고민해야 한다. 아주 간단한 방법은 상태로 정의하지 않고 계산된 값을 자바스크립트 변수로 담는 것이다.
const [items, setItem] = useState<Item[]>([]);
const selectedItems = items.filter((item) => item.isSelected);
성능 측면에서 살펴보자. items와 selectedItems 2가지 상태를 유지하면서 useEffect로 동기화하는 과정을 거치면 selectedItems 값을 얻기 위해서 2번의 렌더링이 발생
직접 자바스크립트 변수에 계산 결과를 담으면 리렌더링 횟수를 줄일 수 있다. 다만 이 경우에는 매번 렌더링될 때마다 계산을 수행하게 되므로 계산 비용이 크다면 성능 문제가 발생.
이럴 때 useMemo를 사용하여 items가 변경될 때만 계산을 수행하고 결과를 메모이제이션하여 성능을 개선
const [items, setItem] = useState<Item[]>([]);
const selectedItems = useMemo(()=>veryExpensiveCalculation(items),[items])
useState 대신 useReducer 사용을 권장하는 경우는 크게 2가지가 있다.
예를 들어 배달의 민족 리뷰 리스트를 필터링하여 보여주기 위한 쿼리를 상태로 저장해야한다고 해보자.
이러한 쿼리는 단순하지 않고 검색 날짜 범위, 리뷰 점수, 키워드 등 많은 하위 필드를 가지게 된다.
이러한 데이터 구조르 useState로 다루면 상태를 업데이트할 때마다 잠재적인 오류 가능성이 증가
예를 들어 페이지값만 업데이트하고 싶어도 우선 전체 데이터를 가지고 온 다음 페이지값을 덮어쓰게 되므로 사이즈나 필터 같은 다른 필드가 수정될 수 있어 의도치 않은 오류가 발생할 수 있다.
특정한 업데이트 규칙이 있다면 useState만으로는 한계가 있다. 이럴 때는 useReducer를 사용하는게 좋다.
useReducer는 무엇을 변경할지와 어떻게 변경할지를 분리하여 dispatch를 통해 어떤 작업을 할지를 액션으로 넘기고 reducer 함수 내에서 상태를 업데이트하는 방식을 정의
이로서 복잡한 상태 로직을 숨기고 안정성을 높일 수 있다.
어떠한 상태를 컴포넌트 내부에서만 사용하는 게 아니라 다른 컴포넌트와 공유할 수 있는 전역 상태로 사용하는 방법은 크게 리액트 컨텍스트 API를 사용하는 방법과 외부 상태 관리 라이브러리를 사용하여 방법으로 나눌 수 있다.
다른 컴포넌트들과 데이터를 쉽게 공유하기 위한 목적으로 제공되는 API
깊은 레벨에 있는 컴포넌트 사이에 데이터를 전달하는 Prop Drilling 같은 문제를 해결하기 위한 도구로 활용
컨텍스트 API를 활용하면 전역적으로 공유해야 하는 데이터를 컨텍스트로 제공하고 해당 컨텍스트를 구독한 컴포넌트에서만 데이터를 읽을 수 있게 된다.
//현재 구현된 것 - TabGroup 컴포넌트뿐 아니라 모든 Tab 컴포넌트에도 type prop를 전달
<TabGroup tpye="sub">
<Tab nex="탭 레이블 1" type="sub">
<div>123</div>
</Tab>
<Tab nex="탭 레이블 2" type="sub">
<div>123</div>
</Tab>
</TabGroup>
//원하는 것 - TabGroup 컴포넌트에만 전달
<TabGroup tpye="sub">
<Tab nex="탭 레이블 1" >
<div>123</div>
</Tab>
<Tab nex="탭 레이블 2">
<div>123</div>
</Tab>
</TabGroup>
상위 컴포넌트의 props를 하위 컴포넌트에 편리하게 전달하기 위해서는 아래와 같이 상위 컴포넌트 구현 부에 컨텍스트 프로바이더를 넣어주고, 하위 컴포넌트에서 해당 컨텍스트를 구독하여 데이터를 읽어오는 방식을 사용할 수 있다.
const TabGroup:FC<TabGroupProps> = (props)=>{
const {type:'tab', ...thoerProps} = useTabGroupState(props)
return(
<TabGroupContext.Provider value={{...otherProps,type}}
{...}
</TabGroupContext.Provider>
)
}
const Tab:FC<TabProps> = ({children,name})=>{
const {type,...otherProps} = useTabGroupContext()
return<>{...}</>
}
type Consumer<C> = () => C;
export interface ContextInterface<S> {
state: S;
}
export default function createContext<S, C = ContextInterface<S>>(): readonly [
React.FC<C>,
Consumer<C>,
] {
const context = React.createContext<Nullable<C>>(null);
const Provider: React.FC<C> = ({ children, ...otherProps }) => {
return (
<context.Provider vlaue={otherProps as C}>{children}</context.Provider>
);
};
const useContext: Consumer<C> = () => {
const _context = React.useContext(context);
if (!_context) {
throw new Error(ErrorMessage.NOT_FOUND_CONTEXT);
}
return _context;
};
return [Provider, useContext];
}
import { useReducer } from 'react';
function App(){
const [state,dispatch] = useReducer(reducer,initalState);
return(
<StateProvider.Provider value={{state,dispatch}}>
<ComponenetA/>
<ComponenetB/>
</StateProvider.Provider>
)
}
위와 같이 사용하면 해당 컴텍스트를 구독하는 컴포넌트에서 앱에 정의된 상태를 읽고 업데이트할 수 있다. 그러나 컨텍스트 API를 사용하여 전역 상태를 관리하는 것은 대규모 어플리케이션이나 성능이 중요한 애플리케이션에서 권장되지 않는 방법이다. 그 이유는 컨텍스트 프로바이더의 props로 주입된 값이나 참조가 변경될 때마다 해당 컨텍스트를 구독하고 있는 모든 컴포넌트가 리렌더링되기 때문이다.
객체 지향 프로그래밍과 반응형 프로그래밍 패러다임의 영향을 받은 라이브러리다. Mobx를 활용하면 상태 변경 로직을 단순하게 작성할 수 있고, 복잡한 업데이트 로직을 라이브러리에 위임할 수 있다. 객체 지향 스타일로 코드를 작성하는 데 익숙하다면 Mobx를 사용하는 것을 추천
다만 데이터가 언제, 어떻게 변하는지 추적하기 어렵기 때문에 트러블 슈팅에 어려움움을 겪을 수 있다.
함수형 프로그래밍의 영향을 받은 라이브러리다. 특정 UI 프레임워크에 종속되지 않아 독립적으로 상태 관리 라이브러리를 사용할 수 있다. 오랜 기간 사용되어 왔기 때문에 다양한 요구 사항에 대해 충분히 검증되었다. 또한 상태 변경 추적에 최적화되어 있어, 특정 상황에서 발생한 어플리케이션 문제의 원인을 파악하는 데 용이하다.
하지만 단순히 상태 설정에도 많은 보일러플레이트가 필요하고, 사용 난도가 높다는 단점이 있다.
상태를 저장할 수 있는 Atom과 해당 상태를 변형할 수 있는 순수 함수 selector를 통해 상태를 관리하는 라이브러다. Redux에 비해 보일러플레이트 적고 난이도가 쉬워 배우기 쉽다. 다만 단점으로는 아직 실험적인 상태이기 때문에 다양한 요구 사항에 대한 충분한 검증이 이루어지지 않았다는 것.
Flux 패턴을 사용하며 많은 보일러플레이트를 가지지 않는 훅 기반의 편리한 API모듈을 제공.
클로즈를 활용하여 스토어 내부 상태를 관리함으로써 특정 라이브러리에 종속되지 않는 특징
그리고 상태와 상태를 변경하는 액션을 정의하고 반환된 훅을 어느 컴포넌트에서나 임포트하여 원하는 대로 사용할 수 있다.