리액트와 상태 관리 라이브러리

리액트 딥다이브 스터디를 현재 진행하고 있습니다. 이 내용을 포스팅하여 공유하고자 합니다.

5.1 상태 관리는 왜 필요한가?

상태란 무엇일까요? 웹 애플리케이션을 개발할 때 이야기하는 상태는 어떠한 의미를 지닌 값이며 애플리케이션의 시나리오에 따라 지속적으로 변경될 수 있는 값을 의미합니다.

웹 애플리케이션에서 상태로 분류될 수 있는 것들은 대표적으로 다음과 같은 것들이 있습니다.

  • UI, URL, form, 서버에서 가져온 값

5.1.1 리액트 상태 관리의 역사

애플리케이션 개발을 위해 모든 것을 제공하는 프레임워크를 지향하는 Angular와 달리 리액트는 단순히 사용자 인터페이스를 만들기 위한 라이브러리 일 뿐입니다. 이에 상태를 관리하는 방법도 개발자에 따라, 시간에 따라 많은 차이가 있습니다.

Flux 패턴의 등장

순수 리액트에서 할 수 있는 전역 상태 관리 수단이라고 한다면 Context API를 떠올리겠지만 엄밀히 말하면 Context API는 상태 주입을 도와주는 역할입니다.

웹 애플리케이션이 비대해지고 상태도 많아짐에 따라 어디서 어떤 일이 일어나서 이 상태가 변했는지를 추적하고 이해하기가 매우 어려운 상황이였습니다. 이의 문제는 양방향 데이터 바인딩으로 봤기에 단방향으로 데이터 흐름을 변경하는 것을 제안하는 것이 Flux 패턴입니다.

Action -> Dispatcher -> Model -> View Flux의 기본적인 단방향 데이터 흐름

  • 액션: 어떠한 작업을 처리할 액션과 그 액션 발생 시 함께 포함시킬 데이터를 의미합니다. 액션 타입과 데이터를 각각 정의해 이를 디스패처로 보냅니다.
  • 디스패처: 액션을 스토어에 보내는 역할을 합니다. 콜백 함수 형태로 하여 액션이 정의한 타입과 데이터를 모두 스토어에 보냅니다.
  • 스토어: 여기에서 실제 상태에 따른 값과 상태를 변경할 수 있는 메서드를 가지고 있습니다.
  • 뷰: 리액트의 컴포넌트에 해당하는 부분으로 스토어에서 만들어진 데이터를 가져와 화면을 렌더링하는 역할을 합니다.
type StoreState = {
  count: number;
};
 
type action  {type: 'add'; payload: number }
 
function reducer(prevState: StoreState, action: Action) {
    const {type: ActionType} = action
    if(ActionType === 'add') {
        return {
            count: prevState.count + action.payload,
        }
    }
 
    throw new Error('error')
}
 
export default function App() {
    const [state, dispatcher] = useReducer(reducer, {count: 0})
 
    function handleClick() {
        dispatcher({type: 'add', payload: 1})
    }
 
    return (
        <div>
            <h1>{state.count}</h1>
            <button onClick={handleClick}>+</button>
        </div>
    )
}

스토어의 역할을 하는 것이 useReducer, reducer인데 각각 현재와 상태에 따른 값이 어떻게 변경되는지를 정의했습니다.

그리고 dispatcher로 이 액션을 실행했고 App에서 보여줍니다.

시장 지배자 리덕스의 등장

리액트와 단방향 데이터 흐름이 점점 두각을 드러내던 와중에 등장한 것이 Redux입니다. 최초에는 Flux 구조를 구현하기 위해

만들어진 라이브러리 중 하나입니다. 더 특별한 것은 Elm 아키텍처를 도입했다는 점입니다.

Elm 아키텍처의 핵심은 model, update, view입니다.

  • model: 애플리케이션의 상태를 의미합니다.
  • view: 모델을 표현하는 HTML을 말합니다.
  • update: 모델을 수정하는 방식을 얘기합니다.

Elm은 Flux와 마찬가지로 데이터 흐름을 세 가지로 분류하고 Flux 구조로 강제해 애플리케이션의 상태를 안정적으로 관리하고자 노력했습니다.

리덕스는 하나의 상태 객체를 스토어에 저장하고 업데이트하는 작업을 디스패치해 업데이트를 수행합니다. 이러한 작업은 reducer 함수로 발생시킬 수 있는데 이 함수의 실행은 웹 애플리케이션 상태에 대한 완전히 새로운 복사본을 반환합니다.

Context API와 useContext

부모에 있는 상태를 자식 컴포넌트에서 쓰기 위해서는 이른바 props 내려주기라 불리는 방식을 사용해야 했는데 컴포넌트 깊이가 깊어질수록 props가 컴포넌트를 관통해 버리는 현상이 발생했습니다. 리액트 16.3에 전역 상태를 하위 컴포넌트에 주입할 수 있는 새로운 Context API를 출시해습니다. props로 상태를 넘겨주지 않더라도 Context Provider가 주입하는 상태를 사용할 수 있게 됐습니다.

React-Query와 SWR

두 라이브러리는 모두 외부에서 데이터를 불러오는 fetch를 관리하는 데 특화된 라이브러리지만, API 호출에 대한 상태를 관리하고 있기 때문에 HTTP 요청에 특화된 상태 관리 라이브러리라 볼 수 있습니다.

// SWR
const fetcher = (url) => fetch(url).then((res) => res.json());
 
export default function App() {
  const { data, error } = useSWR(
    "https://api,github.com/repos/vercel/swr",
    fetcher
  );
 
  if (error) return "Error";
  if (!data) return "Loading";
 
  return (
    <div>
        <p>{JSON.stringify(data)}</p>
    </div>
  )
}

useSWR은 첫 번째 인수로 조회할 API 주소를, 두 번째 인수로 조회에 사용되는 fetch를 넘겨줍니다. 첫 번째 인수인 API 주소는 키로도 사용되며, 동일한 키로 호출하면 재조회하는 것이 아니라 useSWR이 관리하고 있는 캐시의 값을 활용합니다.

// React-Query
import { useQuery } from '@tanstack/react-query';
 
const fetcher = async () => {
  const response = await fetch("https://api.github.com/repos/vercel/swr");
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }
  return response.json();
};
 
export default function App() {
  const { data, error, isLoading } = useQuery({
    queryKey: ['data'],
    queryFn: () => fetcher(),
  })
 
  if (isLoading) return 'Loading'
  if (error) return 'error'
 
  return (
    <div>
      <p>{JSON.stringify(data)}</p>
    </div>
  );
}

Recoil, Zustand, Jotai, Valtio에 이르기까지

 
//Recoil
const counter = { key: "count", default: 0 };
const todoList = useRecoilValue(counter);
 
//Jotai
const countAtom = atom(0);
const [count, setCount] = useAtom(countAtom);
 
// Zustand
const useCounterStore = create((set) => ){
    count: 0,
    increase: () => set((state) => ({count: state.count + 1}))
}
 
const count = useCounterStore((state) => state.count)
 
// Valtio
const state = proxy({count : 0})
const snap = useSnapshot(state)
state.count++;

5.2 리액트 훅으로 시작되는 상태 관리

일부 개발자들은 리액트와 리덕스를 업계 표준으로 여기기도 했습니다만 현재는 많은 상태 라이브러리가 있어 다른 상태 관리 라이브러리를 선택하는 경우도 많아지고 있습니다.

5.2.4 상태 관리 라이브러리 Recoil, Jotai, Zustand 살펴보기

Recoil과 Jotai는 Context와 Provider, 그리고 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는 데 초점을 맞추고 있습니다.

Zustand는 리덕스와 비슷하게 큰 스토어를 기반으로 상태를 관리하는 라이브러리입니다.이 스토어의 상태가 변경되면 이 상태를

구독하고 있는 컴포넌트에 전파해 리렌더링을 알리는 방식입니다.

페이스북이 만든 상태 관리 라이브러리 Recoil

최소 상태 개념인 Atom을 리액트 생태계에 선보이기도 했으며 실험적으로 개발되고 운영되는 라이브러리 입니다.

RecoilRoot

가장 먼저 확인할 것은 RecoilRoot입니다. Recoil을 사용하기 위해서는 RecoilRoot를 애플리케이션의 최상단에 선언 해둬야 합니다.

export default function App() {
  return <RecoilRoot>{
    /*components */
  }</RecoilRoot>
}

RecoilRoot는 Recoil에서 생성되는 상태값을 저장하기 위해 스토어를 생성하고 있기에 최상단에서 선언해야 합니다.

atom은 Recoil의 핵심 개념이며 상태를 나타내는 최소 상태 관리입니다.

type StateMent = {
  name: string;
  amount: number;
};
 
const InitialStateMents: Array<StateMent> = [
  { name: "과자", amount: -500 },
  { name: "용돈", amount: 10000 },
  { name: "네이버페이 충전", amount: -5000 },
];
 
const stateMentsAtom = atom<Array<StateMent>>({
  key: "statements",
  default: InitialStateMents,
});

atom은 key값을 필수로 가지며 다른 atom과 구별하는 식별자가 되는 값입니다. 애플리케이션에서 유일한 값이어야 하기 때문에 주의를 기울여야 합니다.

useRecoilValue

atom의 값을 읽어오는 훅입니다.

import { useRecoilValue } from 'recoil';
import { statementsAtom } from './atoms'; // statementsAtom의 경로에 맞게 import 해줘야 합니다.
 
export default function StateMents() {
    const statements = useRecoilValue(statementsAtom);
 
    return (
        <div>
            <p>현재 statements :</p>
            <pre>{JSON.stringify(statements, null, 2)}</pre>
        </div>
    );
}

useRecoilState

useRecoilValue는 단순히 atom을 가져오기 위한 휘였다면 useRecoilState는 useState와 유사하게 값을 가져오고, 값을 변경할 수도 있는 훅입니다.

import { useRecoilState } from 'recoil';
import { statementsAtom } from './atoms'; // statementsAtom의 경로에 맞게 import 해주세요.
 
export default function StateMents() {
    const [statements, setStatements] = useRecoilState(statementsAtom);
 
    const addStatement = () => {
        setStatements([...statements, "새로운 항목"]);
    };
 
    return (
        <div>
            <p>현재 statements :</p>
            <pre>{JSON.stringify(statements, null, 2)}</pre>
            <button onClick={addStatement}>Add Statement</button>
        </div>
    );
}

Recoil에서 영감을 받은, 그러나 조금 더 유연한 Jotai

Jotai는 상향식 접근법을 취하고 있다고 나와 있는데 Redux처럼 하나의 큰 상태를 내려주는 방식이 아니라 작은 단위의 상태를 위로 전파할 수 있는 구조를 취하고 있음을 의미합니다.

atom

atom이라는 이름과 개념적인 원리는 Recoil에서 받았지만 구현 자체에는 약간의 차이가 있습니다.

atom을 생성할 때마다 고유한 key를 필요로 하였던 Recoil과는 다르게, Jotai는 atom을 생성할 때 별도의 key를 넘겨주지 않아도 됩니다.

import { atom, useAtom } from 'jotai';
 
// Atom 정의
const countAtom = atom<number>(0);
 
export default function Counter() {
    // Atom 사용
    const [count, setCount] = useAtom(countAtom);
 
    // 카운트 증가 함수
    const increment = () => {
        setCount((prev) => prev + 1);
    };
 
    return (
        <div>
            <p>현재 카운트 : {count}</p>
            <button onClick={increment}>Increment</button>
        </div>
    );
}

useAtomValue

jotai의 useAtom 구현입니다.

import { atom, useAtomValue } from 'jotai';
 
// Atom 정의
const countAtom = atom<number>(0);
 
export default function Counter() {
    // 읽기 전용으로 countAtom의 값 가져오기
    const count = useAtomValue(countAtom);
 
    return (
        <div>
            <p>현재 카운트 : {count}</p>
        </div>
    );
}

작고 빠르며 확장에도 유연한 Zustand

Jotai가 Recoil의 영감을 받아 만들어졌다면, Zustand는 리덕스에 영감을 받아 만들어졌습니다.

atom이라는 개념으로 최소 단위의 상태를 관리하는 것이 아니라 스토어를 중앙 집중형으로 활용해 스토어 내부에서 상태를 관리하고 있습니다.

import { create } from "zustand";
 
// Counter Store 정의
type CounterState = {
  count: number;
  increment: () => void;
  decrement: () => void;
};
 
const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));
 
export default useCounterStore;
 
import useCounterStore from './counterStore';
 
export default function Counter() {
    // Counter Store에서 상태와 함수를 가져옴
    const count = useCounterStore((state) => state.count);
    const increment = useCounterStore((state) => state.increment);
    const decrement = useCounterStore((state) => state.decrement);
 
    // const { count, increment, decrement } = useCounterStore(); 다음처럼 선언 할 수도 있습니다.
 
    return (
        <div>
            <p>현재 카운트 : {count}</p>
            <button onClick={increment}>Increment</button>
            <button onClick={decrement}>Decrement</button>
        </div>
    );
}