비교적 오랜 기간, 리액트 생태계에서는 상태 관리를 리덕스에 의존했다. 과거 리액트 코드를 보면, 리액트와 리덕스가 함께 설치돼 있는 것을 흔히 볼 수 있었고, 일부 개발자들은 리액트와 리덕스를 마치 하나의 프레임워크 혹은 업계 표준 (de facto)으로 여기기도 했다. 그러나 현재는 새로운 Context API, useReducer, useState의 등장으로 컴포넌트에 걸쳐서 재사용하거나 혹은 컴포넌트 내부에 걸쳐서 상태를 관리할 수 있는 방법들이 점차 등장하기 시작했고, 덕분에 리덕스 이외ㅔ 다른 상태 관리 라이브러리를 선택하는 경우도 많아졌다.
다른 전역 상태 관리 : zustand(useStore로 로컬에서의 상태 및 변화를 관리), Tanstack Query(데이터 페칭, 로딩, 에러 처리와 로컬에서의 상태 및 변화 관리), recoil? zotai?
: 가장 기본적인 방법.
useState의 등장으로 리액트에서는 여러 컴포넌트에 걸쳐 손쉽게 동일한 인터페이스의 상태를 생성하고 관리할 수 있게 되었다.
context API가 선보인 지 1년이 되지 않아, 리액트는 16.8버전에서 함수 컴포넌트에 사용할 수 있는 다양한 훅 API를 추가했다. 이를 통해 제공된 기능 덕분에 기존에 무상태 컴포넌트를 선언하기 위해서 제한적으로만 사용되던 함수 컴포넌트가 클래스 컴포넌트 이상의 인기를 구가하게 되었다. 가장 큰 변경점 중 하나는 state를 매우 손쉽게 재사용 가능하게 만들 수 있다는 점이다.
🖥️ function useCounter() {
const [count, setCount] = useState(0)
function increase() {
setCount((preve) => prev + 1)
}
return { count, increase }
}
useCounter는 단순히 count state아 이를 1씩 올려주는 increase로만 구성돼 있지만 내부적으로 관리하고 있는 state도 있으며, 또 이를 필요한 곳에서 재사용할 수도 있게 됐다. 이는 클래스 컴포넌트보다 훨씬 간결하고 직관적인 방법이었으며, 리액트 개발자들은 앞다투어 자신만의 훅을 만들어내기 시작했다.
이러한 훅과 state의 등장으로 이전에는 볼 수 없던 방식의 상태 관리가 등장하는데, 바로 Tanstack Query와 SWR이다.
둘 다 모두 외부에서 데이터를 불러오는 fetch를 관리하는 데 특화된 라이브러리지만, API 호출에 대한 상태를 관리하고 있기 때문에, HTTP 요청에 특화된 상태 관리 라이브러리라고 볼 수 있다.
SWR과 Tanstack Query가 HTTP 요청에 대해서만 쓸 수 있다면, 좀 더 범용적으로 쓸 수 있는 상태 관리 라이브러리에는 어떤 변화가 있었을까?
요즘 새로 떠오르는 많은 상태 관리 라이브러리는, 기존의 리덕스 같은 라이브러리와는 차이점이 있는데, 바로 훅을 활용해 작은 크기의 상태를 효율적으로 관리한다는 점이다. Recoil, Jotai, Zustand, Valtio의 저장소를 방문해 보면 모두 peerDependencies로 리액트 16.8 버전 이상을 요구하고 있다. 물론 리덕스나 MobX도 react-redux나 mobx-react-lite 등을 설치하면 동일하게 훅을 상태를 가져올 수 있지만, 위 라이브러리는 애초에 리액트와의 연동을 전제로 작동해 별도로 다른 라이브러리를 설치하지 않아도 된다는 차이점이 있다.
이는 기존 상태 관리 라이브러리의 아쉬운 점으로 지적받던 전역 상태 관리 패러다임에서 벗어나 개발자가 원하는 만큼의 상태를 지역적으로 관리하는 것을 가능하게 만들었고, 훅을 지원함으로써 함수 컴포넌트에서 손쉽게 사용할 수 있다는 장점 또한 가지고 있다.
Recoil과 Jotai는 Context와 Provider, 그리고 훅을 기반으로 가능한 작은 상태를 효율적으로 관리하는 데 초점을 맞추고 있다.
🖥️ // Recoil
const counter = atom({ key: 'count', default: 0})
const todoList = useRecoilValue(counter)
: Recoil은 리액트에서 훅의 개념으로 상태 관리를 시작한 최초의 라이브러리 중 하나이며, 최소 상태 개념인 Atom을 처음으로 선보인다.
정식 출시가 아닌 실험적으로 개발되고 운영되는 라이브러리다.
🖥️ // Jotai
const countAtom = atom(0)
const [count, setCount] = useAtom(countAtom)
: Recoil에서 영감을 받았으나 조금 더 유연한 Jotai.
공식 홈페이지에도 나와있듯, Recoil의 atom 모델에 영감을 받아 만들어졌다. Jotai는 상향식(bottom-up) 접근법을 취하고 있다고 나와 있는데, 이는 리덕스와 같이 하나의 큰 상태를 애플리케이션에 내려주는 방식이 아니라, 작은 단위의 상태를 위로 전파할 수 있는 구조를 의미한다. 또한 앞서 언급했던 리액트 Context의 문제점인 불필요한 렌더링을 해결하고자 설계됐으며, 개발자들의 추가적인 메모이제이션이나 최적화를 거치지 않아도 리렌더링이 발생되지 않게 설계돼있다.
Zustand는 리덕스와 비슷하게 하나의 큰 스토어를 기반으로 상태를 관리한다. Recoil, Jotai와는 다르게 이 하나의 큰 스토어는 Context가 아니라 스토어가 가지는 클로저를 기반으로 생성되며, 이 스토어의 상태가 변경되면 이 상태를 구독하고 있는 컴포넌트에 전파해 리렌더링을 알리는 방식이다.
🖥️ // Zustand
const useCounterStore = create((set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
}))
const count = useCounterStore((state) => state.count)
: 작고 빠르며 확장에도 유연한 Zustand
Jotai가 Recoil의 영감을 받아 만들어졌다면, Zustand는 리덕스에 영감을 받아 만들어졌다. 즉, atom이라는 최소 단위의 상태를 관리하는 것이 아니라, 하나의 스토어를 중앙 집중형으로 활용해 이 스토어 내부에서 상태를 관리하고 있다. 따라서 Zustand를 이해하려면 하나의 큰 스토어가 어떻게 만들어지는지를 먼저 살펴봐야 한다. 스토어를 시작으로, 각 상태값을 어떻게 참조하는지, 또 리렌더링은 어떻게 유도하는지 알아봐야 한다.
Zustand로 간단하게 스토어를 만들고, 이를 사용하는 방법.
🖥️
import { create } from 'zustand'
const useCounterStore = create((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}))
function counter() {
const { count, inc, dec} = useCounterStore()
return (
<div class="counter">
<span>{count}</span>
<buttton onClick={inc}>up</buttton>
<button onClick={dec}>down</button>
</div>
)
}
위 예제에서는 Zustand의 create를 사용해 스토어를 만들고, 반환 값으로 이 스토어를 컴포넌트 내부에서 사용할 수 있는 훅을 받았다. 그리고 이 훅을 사용하면 스토어 내부에 있는 getter 모두에 접근해 사용할 수 있게 된다.
또 리액트 컴포넌트 외부에 store를 만드는 것도 가능하다.
import { createStore, useStore } from "zustand";
const counterStore = createStore((set) => ({
count: 1,
inc: () => set((state) => ({ count: state.count + 1 })),
dec: () => set((state) => ({ count: state.count - 1 })),
}));
function Counter() {
const { count, inc, dec } = useStore(counterStore);
return (
<div class="counter">
<span>{count}</span>
<buttton onClick={inc}>up</buttton>
<button onClick={dec}>down</button>
</div>
);
}
createStore를 사용하면 리액트와 상관 없는 바닐라 스토어를 만들 수 있으며, 이 바닐라 스토어는 useStore훅을 통해 접근해 리액트 컴포넌트 내부에서 사용할 수 있게 된다.
특징
🖥️ // Valtio
const state = proxy({ count: 0 })
const snap = useSnapshot(state)
state.count++
( 출처 : 모던 리액트 deep dive, 김용찬, 위키북스 )
1. Context API (React Context)
React의 내장 상태 관리 도구.
간단한 애플리케이션이나 작은 규모의 프로젝트에 적합.
Redux보다 보일러플레이트가 적고 가볍지만, 상태가 많아지면 리렌더링 성능 문제가 발생할 수 있음.
2. Recoil
React 팀의 실험적인 상태 관리 라이브러리.
React와 밀접하게 통합되어 있으며, 전역 상태를 Atom 단위로 관리.
비동기 상태 관리가 쉬우며, 동적 상태와 파생된 상태를 계산하기 용이.
Context API의 리렌더링 문제를 해결하고, 코드 가독성이 좋음.
3. Zustand
React Hook 기반의 경량 상태 관리 라이브러리.
기본적으로 Redux보다 보일러플레이트가 적고, 간단한 상태 관리에 적합.
직관적인 API와 좋은 성능을 제공.
전역 상태와 로컬 상태 모두 쉽게 관리 가능.
4. MobX
반응형 상태 관리 라이브러리.
상태가 변경되면 해당 상태를 사용하는 컴포넌트만 리렌더링.
더 자유롭고 간결한 코드를 작성할 수 있음.
리액티브한 방식으로 대규모 애플리케이션에 유용하지만, 복잡도가 높아질 수 있음.
5. Jotai
React에서 사용하는 매우 간결한 상태 관리 라이브러리.
Atom 기반으로 상태를 쪼개서 관리.
Recoil과 유사하지만 더 미니멀하고, 간단한 프로젝트에 적합.
비동기 상태와의 연동도 쉬움.
6. XState
상태 머신과 상태 차트 기반의 상태 관리 라이브러리.
상태가 명확하게 정의되며, 상태 전환과 워크플로우를 시각화할 수 있음.
복잡한 상태 전이와 비동기 로직을 다루기에 적합.
큰 애플리케이션의 상태 관리에 강력한 도구.
7. Akita
엔터프라이즈 애플리케이션용 상태 관리 도구.
Angular와 더 잘 맞지만, React에서도 사용 가능.
상태 관리뿐만 아니라 엔터티 관리, 캐싱, 서버 상태 동기화와 같은 고급 기능 제공.
데이터베이스와 비슷한 느낌의 상태 관리가 필요한 경우 유용.
8. Effector
간결하고 유연한 상태 관리 라이브러리.
비동기 작업이 많거나 복잡한 상태 전이가 필요할 때 유용.
재사용 가능한 유닛(Units)을 만들어 상태를 쉽게 조합하고 관리 가능.
9. Tanstack Query
비동기 서버 상태 관리에 최적화된 라이브러리.
클라이언트 상태보다는 주로 서버에서 가져온 데이터의 캐싱과 동기화에 중점.
GraphQL, REST API 같은 데이터 소스와의 통합이 쉬움.
10. Apollo Client
GraphQL 기반의 상태 관리 도구.
클라이언트 상태와 서버 상태를 모두 관리 가능.
GraphQL 사용 시 데이터를 관리하고 캐싱할 때 유용.
11. Vuex / Pinia (Vue.js용)
Vue.js 애플리케이션에서 주로 사용되는 전역 상태 관리 도구.
React와는 다르게 Vue의 반응형 시스템과 잘 통합되어 있음.
최근에는 Vuex 대신 더 경량화된 Pinia가 많이 사용됨.
이 외에도 프로젝트 요구사항과 아키텍처에 맞는 다양한 상태 관리 도구들이 존재합니다. 사용하려는 프레임워크나 라이브러리의 특징, 애플리케이션의 복잡도, 비동기 상태의 비율 등을 고려하여 선택하는 것이 좋습니다.