우아한 타입스크립트 10장 정리

오현재·2024년 11월 27일

10.1 상태관리

10.1 상태

상태란 무엇일까? 리액트 앱에서의 상태는 렌더링에 영향을 줄 수 있는 동적인 데이터 값 을 말한다. 리액트 공식 문서에서는 상태를 아래와 같이 정의하고 있다.

  • 렌더링 결과에 영향을 주는 정보를 담은 순수 JS 객체

리액트에서의 상태는 시간이 지나면서 변할 수 있는 동적인 데이터이며, 값이 변경될 때마다 컴포넌트의 렌더링 결과물에 영향을 준다. 리액트 앱 내의 상태는 지역 상태, 전역 상태, 서버 상태로 분류할 수 있다. 리액트 내부 기능만을 사용하여 상태를 관리할 수 있지만, 성능 문제와 상태의 복잡성으로 인해 Redux, Mobx, Recoil 같은 외부 상태 관리 라이브러리를 주로 활용한다.

지역 상태 (Local State)

지역 상태는 컴포넌트 내부에서 사용 되는 상태로, 예를 들어 체크박스의 체크 여부나 폼의 입력값 등이 해당한다. 주로 useState 훅을 가장 많이 사용하며, 때에 따라 useReducer와 같은 훅을 사용하기도 한다.

전역 상태 (Global State)

전역 상태는 앱 전체에서 공유하는 상태를 의미한다. 여러 개의 컴포넌트가 전역 상태를 사용할 수 있으며, 상태가 변경되면 컴포넌트들도 업데이트된다. 또한 Props drilling 문제를 피하고자 지역 상태를 해당 컴포넌트들 사이의 전역 상태로 공유할 수도 있다.

Props drilling 은 props를 통해 데이터를 전달하는 과정에서 중간 컴포넌트는 해당 데이터가 필요하지 않음에도 자식 컴포넌트에 전달하기 위해 props를 전달해야 하는 과정을 말한다. 컴포넌트의 수가 많아지면 Props drilling 으로 인해 코드가 훨씬 복잡해질 수 있다.

서버 상태 (Server State)

서버 상태는 사용자 정보, 글 목록 등 외부 서버에 저장해야 하는 상태들을 의미한다. UI 상태와 결합하여 관리하게 되며, 로딩 여부나 에러 상태 등을 포함한다. 서버 상태는 지역 상태 혹은 전역 상태와 동일한 방법으로 관리되며, 최근에는 react-query, SWR과 같은 외부 라이브러리를 사용하여 관리하기도 한다.

10.1.2 상태를 잘 관리하기 위한 가이드

상태는 앱의 복잡성을 증가시키고 동작을 예측하기 어렵게 만든다. 또한 상태가 업데이트될 때마다 리렌더링이 발생하기 때문에, 유지보수 및 성능 관점에서 상태의 개수를 최소화하는 것이 바람직하며, 가능하다면 상태가 없는 Stateless 컴포넌트를 활용하는 게 좋다. 어떤 값을 상태로 정의할 때는 다음 2가지 사항을 고려해야 한다.

  • 시간이 지나도 변하지 않는다면 상태가 아니다.
  • 파생된 값은 상태가 아니다.

시간이 지나도 변하지 않는다면 상태가 아니다

시간이 지나도 변하지 않는 값이라면, 객체 참조 동일성 을 유지하는 방법을 고려해볼 수 있다.

객체 참조 동일성은 두 객체가 메모리 상에서 같은 위치를 차지하고 있는지, 즉 같은 객체인지를 판단하는 것을 의미 한다.

컴포넌트가 마운트될 때 스토어 객체 인스턴스를 생성하고, 컴포넌트가 언마운트될 때까지 해당 참조가 변하지 않는다고 가정해보자.

이를 단순히 상수 변수에 저장하여 사용하게 되면, 렌더링될 때마다 새로운 객체 인스턴스가 생성되기 때문에, 컨텍스트나 props 등으로 전달했을 시 매번 다른 객체로 인식된다. 그렇기에 불필요한 리렌더링이 자주 발생할 수 있다.

따라서 리액트의 다른 기능을 활용하여 컴포넌트 라이프사이클 내에서 마운트될 때 인스턴스가 생성되고, 렌더링될 때마다 동일한 객체 참조가 유지되도록 구현해야 한다.

import React from 'react';

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가지가 있다.

  • useState 의 초깃값만 지정하는 방법
  • useRef 를 사용하는 방법

먼저 useState 를 사용하여 초깃값만 지정함으로써 모든 렌더링 과정에서 객체 참조를 동일 하게 유지 할 수 있다.

useState(new Store());

하지만 렌더링 마다 생성되어 초깃값 설정에 큰 비용이 소요될 수 있다.

따라서 다음과 같이 초깃값을 계산하는 콜백을 지정하는 방식(지연 초기화 방식)을 사용한다.

usestate (() => new Store())

다만 useState 를 사용하는 것은 기술적으로는 잘 동작할 수 있지만, 의미론적으로 봤을 때 는 좋은 방법이 아니다. 현재의 목적은 모든 렌더링 과정에서 객체의 참조를 동일하게 유지하고자 하는 것이기 때문이다.

리액트 공식 문서에 따르면 useRef 가 동일한 객체 참조를 유지하려는 목적으로 사용하기에 가장 적합한 혹이다.

💡

useRef() 와 {current: …} 객체를 직접 생성하는 방법 간의 유일한 차이는 useRef 는 매번 렌더링할 때마다 동일한 ref 객체를 제공한다는 것이다.

useRef 의 인자로 직접 new Store() 를 사용하면 useState 와 마찬가지로 렌더링 마다 불필요한 인스턴스가 생성 되므로 아래와 같이 작성해줘야 한다.

const store = useRef<Store>(null);
if (!store.current) {
	store.current = new Store();
}

useRef는 기술적으로 useState({ children: initialValue })[0] 과 동일하다고 할 수 있다. 그러나 상태라고 하는 것이 렌더링에 영향을 주며 변화하는 값을 의미한다는 의미론적 관점에서 보면, 객체 참조 동일성을 유지하기 위해 useState 에 초깃값만 할당하는 것은 적절 하지 않다.

그러므로 가독성 등의 이유로 팀 내에서 합의된 컨벤션 으로 지정된 것이 아니라면 동일한 객체 참조를 할 때는 useRef 를 사용할 것을 권장한다.

파생된 값은 상태가 아니다

SSOT(Single Source Of Truth) 는 어떠한 데이터도 단 하나의 출처에서 생성하고 수정해야 한다는 원칙을 의미하는 방법론이다.

리액트 앱에서 상태를 정의할 때도 이를 고려해야 한다. 다른 값 에서 파생된 값을 상태로 관리하게 되면 기존 출처와는 다른 새로운 출처에서 관리하게 되는 것이므로 해당 데이터의 정확성과 일관성을 보장하기 어렵다.

리액트에서는 다음과 같은 값을 파생된 값이라고 본다.

  1. 부모에게서 props로 전달받으면 상태가 아니다.

아래와 같은 컴포넌트가 있다고 가정해보자. 해당 컴포넌트는 초기 이메일 값을 부모 컴포넌트로 부터 받아 input value 로 렌더링하고 이후에는 사용자가 입력한 값을 input 태그의 value 로 렌더링한다. 얼핏보면 문제가 없어보이지만, 문제가 발생한다.

import { useState } from "react";

type UserEmailProps = {
  initialEmail: string;
};

const UserEmail: React.VFC<UserEmailProps> = ({ initialEmail }) => {
  const [email, setEmail] = useState(initialEmail);

  const onChangeEmail = (event: React.ChangeEvent<HTMLInputElement>) => {
    setEmail(event.target.value);
  };

  return (
    <div>
      <input type="text" value={email} onChange={onChangeEmail} />
    </div>
  );
};

위 컴포넌트에서는 전달받은 propsinitialEmail 의 값이 변경되어도 input 태그의 value 는 변경되지 않는다. useState 의 초깃값으로 설정한 값은 컴포넌트가 마운트될 때 한 번만 설정되며 이후에는 독자적으로 관리되기 때문이다.

그렇다면 여기서 props 와 상태를 동기화하기 위해 useEffect 를 떠올릴 수 있지만 좋은 방법은 아니다. 만약 사용자가 값을 변경한 뒤에 initialEmail 이 변경된다면 사용자의 입력을 무시하고 부모 컴포넌트 로부터 전달된 propsvalue 로 설정할 것이다.

아래와 같은 useEffect 를 사용한 동기화 작업은 리액트 외부 데이터(e.g. LocalStorage) 와 동기화할 때만 사용해야 하며, 내부에 존재하는 데이터를 상태와 동기화하는 데는 사용하면 안된다. 그렇게되면 개발자가 추적하기 어려운 오류가 발생할 수 있기 때문이다.

const [email, setEmail] = useState(initialEmail);

useEffect(() => {
  setEmail(initialEmail);
}, [initialEmail]);

그렇다면 어떻게 해결할 수 있을까? 현재 email 상태에 대한 출처는 prop 으로 받는 initialEmailuseState 로 생성한 state 이다.

문제를 해결하기 위해서는 두 출처 간의 의 데이터를 동기화하기보다 SSOT 를 지킬수 있도록, 단일한 출처에서 데이터를 사용하도록 변경해줘야 한다. 일반적으로 리액트에 서는 상위 컴포넌트에서 상태를 관리하도록 해주는 상태 끌어올리기(Lift-ing State Up) 기법을 사용한다.

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.target.value);
  };

  return (
    <div>
      <input type="text" value={email} onChange={onChangeEmail} />
    </div>
  );
};

  1. props 혹은 기존 상태에서 계산할 수 있는 값은 상태가 아니다.

다음 예시는 이 코드는 아이템 목록(items) 이 변경될 때마다 선택된 아이템 목록(selectedItems) 을 가져오기 위해 useEffect 로 동기화 작업을 하고 있다. 이 코드의 문제점은 무엇일까?

const [items, setItems] = useState<Item[]>([]); // 초기값 빈 배열로 설정
const [selectedItems, setSelectedItems] = useState<Item[]>([]); // 초기값 빈 배열로 설정

useEffect(() => {
  setSelectedItems(items.filter(item => item.isSelected));
}, [items]); // items가 변경될 때마다 selectedItems 업데이트

이러한 방법의 가장 큰 문제는 itemsselectedItems 가 동기화되지 않을 수 있다는 것이다.

여러 상태가 복잡하게 얽혀있으면 흐름을 파악하기 어렵고, 의도치 않게 동기화 과정이 누락될 수도 있다. selectedItems 라는 새로운 상태로 정의 함 으로써 아이템 목록에 대해 단일 출처가 아닌 여러 출처를 가지게 되었고 이에 따라 동기화 문제가 발생하게 된다는 것이다.

앞서 살펴본 바와 같이, 내부의 상태끼리 동기화하는 방법이 아니라 여러 출처를 하나의 출처로 합치는 방법을 고민해야 한다. 아주 간단한 방법은 새로운 상태로 정의하지 않고 기존의 상태를 계산한 값을 JS 변수로 담는 것이다.

const [items, setItems] = useStat<Item[]>([]);
const selectedItems = items.filter((item) => item.isSelected);

위와 같이 selectedItems 를 변수로 선언하여 상태가 아닌 filter 함수로 계산한 값을 담게 되면, items 가 변경되어 컴포넌트가 새로 렌더링될 때마다, selectedItems 를 다시 계산하게 된다. 이런 식으로 단일 출처를 가지면서 원하는 동작을 수행하게 할 수 있다.

성능 측면에서 살펴보자. 우선 처음 방식이었던 itemsselectedItems 2가지 상태를 유지하면서 useEffect 로 동기화하는 과정을 거치면 selectedItems 값을 얻기 위해선 2번의 렌더링이 발생한다.

두번째 방식이었던 계산 결과를 JS 변수에 담는 방식은 렌더링 횟수를 줄일 수 있지만, 계산 비용이 크다면 성능 문제가 발생할 수 있다. 이럴 때 useMemo 를 사용하여 items 가 변경될 때만 계산을 수행하고 결과를 메모이제이션하여 성능을 개선할 수 있다.

const [items, setItems] = useState<Item[]>([]);
const selectedItems = useMemo (() => veryExpensiveCalculation(items), Litems);

useState vs useReducer, 어떤 것을 사용해야 할까

usState 대신 useReducer 사용을 권장하는 경우는 크게 2가지가 있다.

  • 다수의 하위 필드를 포함하고 있는 복잡한 상태 로직을 다룰 때
  • 다음 상태가 이전 상태에 의존적일 때

예를 들어, 배달의민족 리뷰 리스트를 필터링하여 보여주기 위한 쿼리를 상태로 저장해야 한다고 해보자. 이러한 쿼리는 단순하지 않고 검색 날짜 범위, 리뷰 점수, 키워드 등 많은 하위 필드를 가지게 된다.

type DateRangePreset = "TODAY" | "LAST WEEK" | "LAST MONTH";

type ReviewRatingString = "1" | "2" | "3" | "4" | "5"; // 리뷰 점수는 숫자나 문자열로 지정

interface ReviewFilter {
  // 리뷰 날짜 필터링
  startDate: Date;
  endDate: Date;
  dateRangePreset: DateRangePreset | null; // Nullable을 명시적으로 null로 설정

  // 키워드 필터링
  keywords: string;

  // 리뷰 점수 필터링
  ratings: ReviewRatingString;

  // ... 이외 기타 필터링 옵션
}

// Review List Query State
interface State {
  filter: ReviewFilter;
  page: string;
  size: number;
}

이렇게 하위 필드가 많은 데이터 구조를 useState 로 다루면 상태를 업데이트할 때마다 잠재적인 오류 가능성 이 증가한다.

예를 들어 특정값 하나만 업데이트하고 싶어도 우선 전체 데이터를 가지고 온 다음 덮어쓰게 되므로 다른 필드가 수정될 수 있어 의도치 않은 오류가 발생할 수 있다. 또한 특정한 업데이트 규칙이 있다면 useState 만으로는 한계가 있다. 이럴 때는 useReducer를 사용하는 게 좋다.

useReducer 는 '무엇을 변경할지' 와 ‘어떻게 변경할지' 를 분리하여 dispatch 를 통해 어떤 작업을 할지를 액션으로 넘기고 reducer 함수 내에서 상태를 업데이트하는 방식을 정의한다. 이로써 복잡한 상태 로직을 숨기고 안전성을 높일 수 있다. 아래는 리뷰 쿼리 상태에 대한 reducer를 정의하여 useReducer를 사용한 코드이다.

// 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 {
        ...state,
        filter: action.payload,
        page: 0, // 필터링이 변경되면 페이지를 0으로 리셋
        size: state.size,
      };
    case "navigate":
      return {
        ...state,
        filter: state.filter,
        page: action.payload,
        size: state.size,
      };
    case "resize":
      return {
        ...state,
        filter: state.filter,
        page: 0, // 사이즈가 변경되면 페이지는 0으로 리셋
        size: action.payload,
      };
    default:
      return state;
  }
};

// useReducer 사용
const MyComponent = () => {
  const [state, dispatch] = useReducer(reducer, getDefaultState());

  // dispatch 예시
  dispatch({ payload: filter, type: "filter" });
  dispatch({ payload: page, type: "navigate" });
  dispatch({ payload: size, type: "resize" });

/* ... */

이외에도 boolean 상태를 토글하는 액션만 사용하는 경우에는 useState 대신 useReducer를 사용하곤 한다.

// Before
const [fold, setFold] = useState(true);
const toggleFold = () => {
	setFold((prev) => !prev);
}

// After
const [fold, toggleFold] = useReducer((v) => !v, true);

10.1.3 전역 상태 관리와 상태 관리 라이브러리

  • 상태는 사용하는 곳과 최대한 가까워야 하며 사용 범위를 제한해야한다.

어떠한 상태를 컴포넌트 내부에서만 사용하는 게 아니라 다른 컴포넌트와 공유할 수 있는 전역 상태로 사용하는 방법은 크게 리액트 컨텍스트 APl를 사용하 는 방법과 외부 상태 관리 라이브러리를 사용하는 방법으로 나눌 수 있다.

  • 컨텍스트 API + useState 또는 useReducer
  • 외부 상태 관리 라이브러리 (Redux, MobX, Recoil 등)

컨텍스트 API는 다른 컴포넌트들과 데이터를 쉽게 공유하기 위한 목적으로 제공되는 API이다. 깊은 레벨에 있는 컴포넌트 사이에 데이터를 전달하는 Prop Drilling 같은 문제를 해결하 기 위한 도구로 활용된다.

컨텍스트 API를 활용하면 전역적으로 공유해야 하는 데이터를 컨텍스트로 제공하고 해당 컨텍스트를 구독한 컴포넌트에서만 데이터를 읽을 수 있게 된다. 따라서 UI 테마 정보나 Locale 데이터같이 전역적으로 제공하거나 컴포넌트의 props를 하위 컴포넌트에게 계속해서 전달해야 할 때 유용하게 사용할 수 있다.

아래와 같이 Tabtype 이라는 prop 을 전달하지않고, TabGroup 컴포넌트에만 이 prop을 전달하고 Tab 컴포넌트의 내에서도 사용할 수 있게 하려면 어떻게 해야 할까?

// 현재 구현된 것 - TabGroup 컴포넌트뿐 아니라 모든 Tab 컴포넌트에도 type prop 을 전달
<TabGroup type="sub">
  <Tab name="텝 레이블 1" type="sub">
    <div>123</div>
  </Tab>
  <Tab name="텝 레이블 2" type="sub">
    <div>123</div>
  </Tab>
</TabGroup>

// 원하는 것 - TabGroup 컴포넌트에만 전달
<TabGroup type="sub">
  <Tab name="텝 레이블 1">
    <div>123</div>
  </Tab>
  <Tab name="텝 레이블 2">
    <div>123</div>
  </Tab>
</TabGroup>

상위 컴포넌트(TabGroup)의 props 를 하위 컴포넌트(Tab)에 편리하게 전달하기 위해서는 아래와 같이 상위 컴포넌트에 컨텍스트 프로바이더(Context Prorider) 라는 것을 넣어주고, 하위 컴포넌트에서 해당 컨텍스트를 구독하여 데이터를 읽어오는 방식을 사용할 수 있다.

const TabGroup: FC<TabGroupProps> = (props) => {
  const { type = "tab", ...otherProps } = useTabGroupState(props);
  // ... (로직 생략)

  return (
    // TabGroupContext.Provider(Context Provider) 에서 type과 otherProps를 전달
    <TabGroupContext.Provider value={{ ...otherProps, type }}>
      {/* TabGroup 컴포넌트 내용 */}
      {props.children}
    </TabGroupContext.Provider>
  );
};

const Tab: FC<TabProps> = ({ children, name }) => {
  // props 에서가 아닌, 컨텍스트를 구독하여 데이터를 읽어옴
	const { type, ...otherProps } = useTabGroupContext();
	
	return <></>
};

컨텍스트 API 관련한 또 하나의 팁은 유틸리티 함수를 정의하여 더 간단한 코드로 컨텍스트 와 훅을 생성하는 것이다. 아래와 같이 createContext 라는 유틸리티 함수를 정의해서 자주 사용되는 프로바이더 와 해당 컨텍스트를 사용하는 훅을 간편하게 생성하여 생산성을 높일 수 있다.

type Consumer<C> = () => C;

export interface ContextInterface<S> {
  state: S;
}

export function createContext<S, C = ContextInterface<S>>(): [React.FC<C>, Consumer<C>] {
  const context = React.createContext<C | null>(null);

  // 프로바이더 
  const Provider: React.FC<C> = ({ children, ...otherProps }) => {
    return (
      <context.Provider value={otherProps as C}>
        {children}
      </context.Provider>
    );
  };

	// 컨텍스트
  const useContext: Consumer<C> = () => {
    const _context = React.useContext(context);
    if (!_context) {
      throw new Error('Context not found!');
    }
    return _context;
  };

  // 튜플 형태로 반환
  return [Provider, useContext];
}

// Example
interface StateInterface {}

const [context, useContext] = createContext<StateInterface>();

컨텍스트 API 는 엄밀하게 말해 전역 상태를 관리하기 위한 솔루션 이라기보다 여러 컴포넌트 간에 값을 공유하는 솔루션에 가깝다. 그러나 useStateuseReducer 같이 지역 상태를 관리하기 위한 API 와 결합하여 여러 컴포넌트 사이에서 상태를 공유하기 위한 방법으로 사용되기도 한다.

const App: React.FC = () => {
	const [state, dispatch] = useReducer(reducer, initialState);
  return (
    <StateProvider.provider value={{ state, dispatch }}>
      <ComponentA />
      <ComponentB />
    </StateProvider.provider>
  );
};

그러나 컨텍스트 API 를 사용하여 전역 상태를 관리하는 것은 대규모 애플리케 이션이나 성능이 중요한 애플리케이션에서 권장되지 않는 방법이다.

그 이유는 프로바이더의 props로 주입된 값이나 참조가 변경될 때마다 해당 컨텍스트를 구독하고 있는 모든 컴포넌트가 리렌더링되기 때문이다. 물론 관심사를 잘 분리하여 구성하면 리렌더링 발생을 최소화할 수는 있겠지만 앱이 커지고 전역 상태가 많아질수록 불필요한 리렌더링과 상태의 복잡도가 증가한다.

10.2 상태 관리 라이브러리

범용적으로 사용하는 상태 관리 라이브러리의 특징과 상황에 따라 어떤 라이브러리를 선택하면 좋을지 간단히 알아보자. 우아한형제들 에서 사용하는 전역 상태 관리 라이브러리로 MobX, Recoil, Redux, Zustand 가 있다.

10.2.1 MobX

객체 지향 프로그래밍과 반응형 프로그래밍 패러다임의 영향을 받은 라이브러리다. Mobx를 활용하면 상태 변경 로직을 단순하게 작성할 수 있고, 복잡한 업데이트 로직을 라이브러리에 위임할 수 있다. 객체 지향 스타일로 코드를 작성하는 데 익숙하다면 MobX를 사용하는 것을 추천한다.

다만 데이터가 언제, 어떻게 변하는지 추적하기 어렵기 때문에 트러블슈팅에 어려움을 겪을 수 있다. 예시 코드는 다음과 같다.

import { observer } from "mobx-react-lite";
import { makeAutoObservable } from "mobx";

class Cart {
  itemAmount = 0;

  constructor() {
    makeAutoObservable(this);
  }

  increase() {
    this.itemAmount += 1;
  }

  reset() {
    this.itemAmount = 0;
  }
}

const myCart = new Cart();

const CartView = observer(({ cart }: { cart: Cart }) => (
  <div>
    <button onClick={() => cart.reset()}>Reset</button>
    <p>Amount of cart items: {cart.itemAmount}</p>
  </div>
));

ReactDOM.render(<CartView cart={myCart} />, document.getElementById("root"));

setInterval(() => {
  myCart.increase();
}, 1000);

10.2.2 Redux

Redux는 함수형 프로그래밍의 영향을 받은 라이브러리다. 특정 UI 프레임워크에 종속되지 않아 독립적으로 상태 관리 라이브러리를 사용할 수 있다. 오랜 기간 사용되어왔기 때문에 다양한 요구 사항에 대해 충분히 검증되었다. 또한 상태 변경 추적에 최적화되어 있어, 특정 상황에서 발생한 애플리케이션 문제의 원인을 파악하는 데 용이하다.

하지만 단순한 상태 설정에도 많은 보일러플레이트가 필요하고, 사용 난이도가 높다는 단점이 있다. Redux 를 활용한 상태 관리 예시 코드는 아래와 같다.

import { createStore } from "redux";

function counter(state = 0, action) {
  switch (action.type) {
    case "PLUS":
      return state + 1;
    case "MINUS":
      return state - 1;
    default:
      return state;
  }
}

let store = createStore(counter);

store.subscribe(() => console.log(store.getState()));

store.dispatch({ type: "PLUS" });
store.dispatch({ type: "MINUS" });

10.2.3 Recoil

Recoil 은 상태를 저장할 수 있는 Atom 과 해당 상태를 변형할 수 있는 순수 함수 selector 를 통해 상태를 관리하는 라이브러리다. Redux에 비해 보일러플레이트가 적고 난이도가 쉬워 배우기 쉽다. 다만 단점으로는 라이브러리가 아직 실험적인 상태이기 때문에 다양한 요구 사항에 대한 충분한 검증이 이루어지지 않았다는 것이다. Recoil을 활용한 상태 관리 예 시 코드는 아래와 같다.

Recoil 상태를 공유하기 위해 컴포넌트들은 RecoiLRoot 하위에 위치해야 한다.

import React from "react";
import { RecoilRoot } from "recoil";
import { TextInput } from "./TextInput"; // Ensure this path is correct

function App() {
  return (
    <RecoilRoot>
      <TextInput />
    </RecoilRoot>
  );
}

export default App;

Atom은 상태의 일부를 나타내며 어떤 컴포넌트에서든 읽고 쓸 수 있도록 제공된다.

import { useRecoilState } from "recoil";

export const textState = atom({
	key: "textState,
	default: "",
});

import { textState } from "./";

export function TextInput() {
  const [text, setText] = useRecoilState(textState);

  const onChange = (event: React.ChangeEvent<HTMLInputElement>) => {
    setText(event.target.value);
  };

  return (
    <div>
      <input type="text" value={text} onChange={onChange} />
      <br />
      Echo: {text}
    </div>
  );
}

10.2.4 Zustand

Zustand 는 Flux 패턴을 사용하며 많은 보일러플레이트를 가지지 않는 훅 기반의 편리한 API 모듈을 제공한다.

Flux 패턴은 Client-Side 웹 애플리케이션을 만들기 위해 사용하는 디자인 패턴 으로써, 사용자 입력을 기반으로 Action 을 만들고 Store 의 데이터를 변경한 뒤 View 에 반영하는 단방향의 흐름으로 애플리케이션을 만든다.

클로저를 활용하여 스토어 내부 상태를 관리함으로써 특정 라이브러리에 종속되지 않는 특징이 있다. 그리고 상태와 상태를 변경하는 액션을 정의하고 반환된 훅을 어느 컴포넌트에서나 임포트하 여 원하는 대로 사용할 수 있다. Zustand를 활용한 상태 관리 예시 코드는 아래와 같다.

import React from "react";
import { create } from "zustand";

const useBearStore = create((set) => ({
  bears: 0,
  increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
  removeAllBears: () => set({ bears: 0 }),
}));

function BearCounter() {
  const bears = useBearStore((state) => state.bears);

  return <h1>{bears} around here ...</h1>;
}

// Controls Component
function Controls() {
  const increasePopulation = useBearStore((state) => state.increasePopulation);

  return <button onClick={increasePopulation}>Plus</button>;
}

// App Component
function App() {
  return (
    <div>
      <BearCounter />
      <Controls />
    </div>
  );
}

export default App;
profile
안녕하세요. 환영합니다. 프론트엔드 개발자 오현재입니다.

0개의 댓글