
FE에게 가장 중요한 것은 상태를 어떻게 관리하느냐이다. 사실 UI는 디자인 측면이 강하다보니 개발자로서 로직에 집중하다보면 그렇다. FE에서 state란 현재 시점의 렌더링을 위한 데이터를 말한다. 관건은, 어떻게 이 데이터를 랜더링하는 화면과 싱크를 잘 할까이다.
상태 관리 라이브러리를 사용하지 않으면 모든 react 컴포넌트는 상위 컴포넌트로부터 하위 컴포넌트로 계속해서 props 형태로 데이터를 넘겨주어야한다. 데이터를 공유하는 컴포넌트의 거리가 멀수록 상당히 비효율적이고 관리하기도 쉽지 않다. 오죽하면 props drilling problem 이라는 이름과 함께 밈이 붙었다.
이를 해결하기 위해서 등장한 것이 전역 상태 관리 라이브러리이다. 대표적인 상태관리 라이브러리인 Redux는 중앙에 상태를 저장해서 데이터를 공유하는 방식이다.
상태관리 라이브러리가 하는 일은 크게 두가지인데,
데이터를 어떻게 업데이트하고 어떻게 받아오는 지는 라이브러리마다 다르고, 취향을 좀 타기도 한다.
어쨋든, 상태관리의 흐름은 상태 변경 → 렌더링을 위한 구조 생성 요청 → 화면 갱신 순이다. 상태만 잘 관리하면 화면이 잘 나온다.
사실 상태 관리 개념은 꼭 리액트에 한정된 이야기가 아니다. 뭐든 UI가 있으면 데이터와 UI의 싱크를 맞춰야하기 때문이다. 그래도 React가 등장하면서 이 개념이 각광 받은 것은 사실이다.
아직까지도 널리 사용되고 있는 Redux는 React 출시와 맞물려 초창기에 등장한 상태 관리 라이브러리로 거의 표준처럼 사용된다. 오래된데다, 자료도 많아서 공부하기는 편하다.
그럼에도 Redux 이후로 수많은 상태 관리 라이브러리가 등장했고 각자의 방식으로 상태를 관리하는 로직을 가지고 있다. Meta에서 만든 Recoil이 어느 정도 주목을 받았지만 갑작스럽게 개발 팀이 해체되면서 현재 트렌드는 Zustand와 Jotai로 넘어갔다. 물론 Redux 사용 비율이 넘사벽이지만. 일부 Redux 사용자들도 Zustand로 넘어가는 경우가 있다고 하니 배워두면 좋을 게 분명하다.
이번에 React를 Typescript로 사용하는 김에, 상태관리 라이브러리도 최신 것을 시도해볼까 해서 Jotai와 Zustand를 사용해보고, 비교하려한다.
Jotai와 Zustand 모두 같은 사람(Kato Daishi)이 만들었다. 그래서 두 단어 모두 '상태'로 뜻이 동일하다.

Jotai는 React에 특화되어있는데, 개념이 간단해서 주목받는다. 특히 Hook과 유사하게 useAtom을 사용하기 때문에 useState를 useAtom으로 대체만 해줘도 작동한다.

실습을 위해서 creat-react-app으로 타입스크립트 프로젝트를 하나 만들어준다. 타입스크립트 버전이 3.8이상이어야 한다고 한다. 권장사항에 따라 tsconfig.json에 strictNullChecks와 strict를 설정해주자.
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"strictNullChecks": true,
"forceConsistentCasingInFileNames": true,
"noFallthroughCasesInSwitch": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": [
"src"
]
}
Jotai는 Primitive type에 대해서는 암시적으로 타입을 지정한다. 아래 그림처럼 값만 설정해주어도 되고, 직접 타입을 지정해주어도 된다.

Jotai의 atom에는 두가지 종류가 있는데 (참고) derived atom의 경우에도 암시적으로 타입을 지정해준다고 한다. 언뜻 편리해보인다.

앞서 잠깐 언급했던 useAtom 훅도 atom 타입을 그대로 따르기 때문에 별도로 지정해줄 필요는 없다.

비동기 Atom의 경우 Promise를 반환한다. explicit 하게 작성해줄 필요는 없지만 알아두자.

Jotai는 공식 홈페이지에서 document 뿐 아니라, Tutorial, Example, Playground를 제공한다. Tutorial을 따라가며 Jotai에 대해 알아보는 시간을 가질 수 있다.
Jotai에서는 atom이라는 개념을 사용한다. atomic에서 따온 말로 보이는데, 작은 독립적인 state로, 아주 작은 데이터를 담으라고 한다. 기존에 useState로 담던 양이면 딱 맞아보인다. 공식 튜토리얼에서도 useState 훅처럼 사용하면 된다고 소개한다.
간단한 clicker를 만들어보자.
일단 atom으로 counter를 만들어준다. 초기 값을 0으로 주면 자동으로 Number 타입이 된다.
import { atom } from "jotai"
const counter = atom(0);
이렇게 선언한 atom을 컴포넌트에서 사용하려면 useState 처럼 useAtom으로 변수와 setter를 받으면 된다.
const Counter = () => {
const [count, setCounter] = useAtom(counter)
const onClick = () => {
setCounter((prev) => prev + 1)
}
return (
<div>
<h3>Counter: {count}</h3>
<button onClick={onClick}> Click </button>
</div>
)
}
받은 count와 setCounter는 useState로 쓸 때와 동일하게 해주면 된다. setCounter에는 callback 함수를 넣어주면 되는데, 이전 값을 인자로 받아서 원하는데로 값을 변경해주면 된다.

Click 버튼을 누르면 이쁘게 카운트가 올라간다.
다음 세션까지 지속되어야 하는 값은 주로 localStorage에 저장하고는 한다. Jotai에서는 atomWithStorage 함수로 atom의 값이 localStorage나 sesssionStorage와 연동되어 관리할 수 있게 한다.
theme 값을 설정하는 예제를 만들어보자.
theme 값을 atomWithStorage로 선언한다. storage에는 dark 라는 이름으로 false 값이 들어갈 것이다. 이 값이 true가 되면 dark 모드로 테마가 바뀌게 할 수 있다.
const theme = atomWithStorage('dark', false);
...
const [appTheme, setAppTheme] = useAtom(theme);
const handleClick = () => setAppTheme(!appTheme);

local storage를 확인해보면 dark 값이 잘 들어가 있는 것을 볼 수 있다.

앞서 잠시 언급한 derived atom은 다른 atom의 값을 받아와서 사용하는 atom이다. 이 atom은 파라미터 get으로 가져와서 사용할 수 있으며 read only 이기 때문에 바꿀 수 없다. 부모 atom의 값이 바뀌면 derived atom의 값이 함께 변화한다.
derived atom을 사용하면 부모 atom의 값이 바뀌어도 filter나 sort를 적용한 값 그대로 사용할 수 있다는 점에서 매우 편리하다.
입력값을 대문자로 바꾸는 예제를 살펴보자.
먼저 입력값을 담을 textAtom을 선언한다. 그리고 이 atom을 get으로 가져와서 uppercase로 변환하는 uppercase atom을 만들어준다.
const textAtom = atom("readonly atoms");
const uppercase = atom((get) => get(textAtom).toUpperCase());
두 atom을 받아와서 각각 변수 lowercaseText와 uppercaseText로 받는데, uppercase의 경우 readonly 이기 때문에 setter를 받아올 필요가 없다.
const [lowercaseText, setLowercaseText] = useAtom(textAtom)
const [uppercaseText] = useAtom(uppercase)
input 핸들러에서 lowercaseText의 값만 변화시켜도 uppercaseText의 값이 함께 변화하는 것을 볼 수 있다.
const handleChange = (e:React.ChangeEvent<HTMLInputElement>) => {
setLowercaseText(e.target.value)
}

derived atom이 필요한데, derived atom를 수정했을 때 부모 atom도 반영되게 하고 싶은 경우가 있다. 이럴 때는 atom의 첫번째 인자에 null을 넣고 그 뒤에 콜백으로 get과 set이 담긴 함수를 주면 된다.
예를 들어,
const textAtom = atom('write only atoms')
const uppercase = atom(null, (get, set) => {
set(textAtom, get(textAtom).toUpperCase())
})
uppercase 예제를 이렇게 바꾸면 uppercase 값이 바뀔 때 부모 atom인 textAtom의 값도 바뀐다.
svg를 사용해서 점으로 그림을 그리는 예제가 있는데, 그려지는 point 값들을 dotsAtom에 넣어두는 방식이다. 코드가 조금 복잡한데, 하나씩 살펴보자.
먼저 그려진 점들의 x,y 값을 가지고 있는 dotsAtom과 그리고 있는 지 여부를 표시하는 drawingAtom을 선언한다.
const dotsAtom = atom<[number, number][]>([]);
const drawingAtom = atom(false);
컴포넌트 안에서는 마우스 동작을 저장할 dereived atom을 만들어주는데, 마우스 움직임에 따라서 마우스의 위치가 들어오면 dotsAtom의 값을 업데이트해준다. dereived atom이 자신의 값을 따로 저장한다기 보다는 handler 역할을 해주는 것이다.
const handleMouseMoveAtom = atom(
null,
(get, set, update:[number,number]) => {
if (get(drawingAtom)) {
set(dotsAtom, (prev) => [...prev, update]);
}
}
);
이후 그림을 그리는 부분에서 dotsAtom의 값들을 하나씩 빼서 그려주면 된다.
const SvgDots = () => {
const [dots] = useAtom(dotsAtom);
return (
<g>
{dots.map(([x, y], index) => (
<circle cx={x} cy={y} r="2" fill="#aaa" key={index} />
))}
</g>
);
};
예제 로직이 조금 복잡한데, 요지는 write only atom을 쓰면 handler 처럼 부모 atom의 값을 변화시키는 용도로 쓸 수 있다는 것이다.

read-only와 write-only 모두 가능한 read write atom도 당연히 존재한다. write-only에서는 자기 스스로의 값은 필요 없기 때문에 atom의 첫 인자를 null로 주었다면 이제 첫 인자에 부모 atom에서 가져올 값을 get으로 정의해주면 된다. 예를 들어 counter가 있을 때, 아래처럼 정의하면 제 자신의 값과 함께 부모 atom의 값을 한 번에 변화시킬 수 있다.
const count = atom(1);
export const readWriteAtom = atom((get) => get(count),
(get, set) => {
set(count, get(count) + 1);
},
);
write only atom 예제에서 아래와 같이 바꾸면 handleMouseMoveAtom 만으로 그림을 그릴 수 있다.
const handleMouseMoveAtom = atom(
(get) => get(dotsAtom),
(get, set, update: Point) => {
if (get(drawingAtom)) {
set(dotsAtom, (prev) => [...prev, update]);
}
}
);
const SvgDots = () => {
const [dots] = useAtom(handleMouseMoveAtom);
return (
<g>
{dots.map(([x, y], index) => (
<circle cx={x} cy={y} r="2" fill="#aaa" key={index} />
))}
</g>
);
};
Atom boilerplate를 만드는 방식에 관한 설명이다. 만약 동일한 action을 가진 서로 다른 atom들이 필요하다면, action을 미리 정해둔 함수를 만들어 재사용하는 것이 이득이다.
예를 들어 아래와 같이 counter가 두 개 필요할 때 creator 함수를 만들어 사용할 수 있다.
const createCountIncAtoms = (initialValue) => {
const baseAtom = atom(initialValue)
const valueAtom = atom((get) => get(baseAtom))
const incAtom = atom(null, (get, set) => set(baseAtom, (c) => c + 1))
return [valueAtom, incAtom]
}
//Usage
const [fooAtom, fooIncAtom] = createCountIncAtoms(0)
const [barAtom, barIncAtom] = createCountIncAtoms(0)
initialValue를 담은 atom을 부모로하는 derived atom인 valueAtom을 readonly로 만들고 Action 부분인 incAtom은 writeonly로 만들었다. [value, incAtom] 쌍을 반환하면 atom(initialValue)로 생성하는 효과와 동시에 여러 번 같은 형태의 atom을 찍어낼 수 있다.
아래와 같이 작성하면 동일한 기능을 하는 서로 다른 atom 두개를 만들 수 있다.
const createCountIncAtoms = (initialValue:number): [Atom<number>,WritableAtom<null,[],void>]=> {
const baseAtom = atom(initialValue)
const valueAtom = atom((get) => get(baseAtom))
const incAtom = atom(null, (get, set) => set(baseAtom, (c) => c + 1))
return [valueAtom, incAtom]
}
const [fooAtom, fooIncAtom] = createCountIncAtoms(0)
const [barAtom, barIncAtom] = createCountIncAtoms(0)
//In component
const [fooCount] = useAtom(fooAtom)
const [, incFoo] = useAtom(fooIncAtom)
const [barCount] = useAtom(barAtom)
const [, incBar] = useAtom(barIncAtom)
const onClick1 = () => {
incFoo()
}
const onClick2 = () => {
incBar()
}
Tutorial에서 제공하는 예제와 다르게 Typescript로 하려다보니 Type 때문에 많이 헤맸다. Atom Creator의 반환 값의 type을 지정해주어야하는데, Atom의 성질마다 Jotai에서 지원하는 Type이 다르다. 일반적인 Atom은 "Atom<[initValueType]>"의 형태로, write only atom이나 read write atom은 "WritableAtom<[initValue],[Args[]],[Result]>" 형태로 주면 된다. (Copilot은 오늘도 많은 도움이 된다.)

비동기적으로 데이터를 받아와야한다면 async로 atom을 사용할 수 있다.
아래처럼 derived atom을 선언할 때 async 콜백 함수를 넣어주면 된다.
const counter = atom(0);
const asyncAtom = atom(async (get) => get(counter) * 5);
비동기로 데이터가 들어올 때 데이터가 아직 오지 않았을 때를 처리해주는 Suspense 기능을 지원해주기 준다. 다만 tutorial에서는 utils의 loadable api를 사용하는 것이 좀 더 "조타이스럽다"고 표현한다.
만들어 둔 async atom을 loadable로 감싸주면 데이터 외에도, 로딩 상태(state: 'loading' | 'hasData' | 'hasError'), 오류(error)를 함께 처리할 수 있게 된다.
import { loadable } from "jotai/utils"
const countAtom = atom(0);
const asyncAtom = atom(async (get) => get(countAtom));
const loadableAtom = loadable(asyncAtom)
const AsyncComponent = () => {
const [value] = useAtom(loadableAtom)
if (value.state === 'hasError') return <div>{value.error}</div>
if (value.state === 'loading') {
return <div>Loading...</div>
}
return <div>Value: {value.data}</div>
Async Write Atom도 유사하게, 콜백 function의 자리에 async function을 넣어주면 된다. 단, Suspense를 지원해주지 않는다. 아래와 같이 작성하면 Suspense를 trigger 하도록 우회할 수 있다.
const request = async () => fetch('https://...').then((res) => res.json())
const baseAtom = atom(0)
const Component = () => {
const [value, setValue] = useAtom(baseAtom)
const handleClick = () => {
setValue(request()) // Will suspend until request resolves
}
// ...
}
Jotai의 기본적인 사용법을 tutorial로 알아봤다. 기존에 자주 쓰던 redux와 비교했을 때 확실히 직관적인 것이 마음에 든다. 그렇다고 가볍지도 않고, 트렌드에 맞춰서 readonly나 writeonly가 따로 나눠져 있는 것이 마음에 든다. 다만 typescript로 할 때는 type을 지정하는데서 꽤 애를 먹고 있다. 따로 공식 문서나 설명이 있는지 찾아보아야할 것 같다. 일단, 인상은 좋다. Zustand까지 써보고 둘을 비교해보자.
Zustand는 redux, redux-toolkit보다 간결한 것이 특징인데, 그동안 state와 action을 나누어서 정의하고, action을 일일이 export 해주어야했던 redux의 불편한 지점을 해소해준다. 기본적인 방식은 redux와 비슷해서 redux 사용자들이 왜 zustand로 갈아타는지 알 수 있다.
Jotai처럼 상세한 tutorial은 없지만 introduction에서 간단한 사용법을 설명한다. 공식 문서에 아예 다른 상태 관리 라이브러리와의 비교도 있어서 살펴보면 좋을 것 같다.
redux처럼 우선 store를 만들어준다. 앞서 말했듯, action과 state가 함께 담길 수 있는 것이 특징이다.
import { create } from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
updateBears: (newBears) => set({ bears: newBears }),
}))
Jotai로 만들었던 counter 예제를 zustand를 사용하면 아래와 같다.
interface CounterState {
count: number
inc: () => void
}
const useStore = create<CounterState>()((set) => ({
count: 0,
inc: () => set((state) => ({ count: state.count + 1 })),
}))
const Counter = () => {
const cnt = useStore((state) => state.count)
const onClick = useStore((state) => state.inc)
return (
<div className="flex justify-center">
<h3 className="text-xl p-3">Counter: {cnt}</h3>
<button className="bg-slate-200 border-black border-1 rounded-lg m-2 p-1" onClick={onClick}> Click </button>
</div>
)
}
Type 지정하기가 조금 빡세다... create에 interface를 만들어서 넘겨주는 건 이해하지만 왜 create()((set)=>{}) 형태인지 모르겠다. 나만 그런 것이 아닌지, 공식 문서에서도 이를 언급한다. (이해하지 못 했다...)
미들웨어 combine을 사용하면 조금 더 간결해진다.
import { create } from 'zustand'
import { combine } from 'zustand/middleware'
const useBearStore = create(
combine({ bears: 0 }, (set) => ({
increase: (by: number) => set((state) => ({ bears: state.bears + by })),
})),
)
하지만 combine은 state를 만드는 것이기 때문에 curried version은 사용하면 안된다는 경고가 따라붙는다. 번거롭더라도 create()((set)=>{})로 사용하는 게 정신 건강에 좋을 것 같다.
공식문서에서는 Type 지정을 위해 아래와 같은 방법을 추천한다. State와 Action별로 type을 만들어서 create에 & 연산으로 제공하는 것이다.
type State = {
firstName: string
lastName: string
}
type Action = {
updateFirstName: (firstName: State['firstName']) => void
updateLastName: (lastName: State['lastName']) => void
}
// Create your store, which includes both state and (optionally) actions
const usePersonStore = create<State & Action>((set) => ({
firstName: '',
lastName: '',
updateFirstName: (firstName) => set(() => ({ firstName: firstName })),
updateLastName: (lastName) => set(() => ({ lastName: lastName })),
}))
만약 state가 아래처럼 중첩되어 nested되어 있다면 어떻게 값을 수정할 수 있을까?
type State = {
deep: {
nested: {
obj: { count: number }
}
}
}
일반적인 방법은 ... 연산자를 사용하여 nested된 object만 수정할 수 있게 새로운 object하는 것이다. 이렇게 하면 코드가 아래와 같아진다. 깊이가 깊어질수록 코드는 더 복잡하고 길어질 것이다.
normalInc: () =>
set((state) => ({
deep: {
...state.deep,
nested: {
...state.deep.nested,
obj: {
...state.deep.nested.obj,
count: state.deep.nested.obj.count + 1
}
}
}
})),
이처럼 object의 immutable 속성 때문에 복잡해지는 경우 자주 사용하는 것이 Immer이다. Zustand도 Immer를 middleware로 지원한다.
Immer를 사용하면 아래처럼 훨씬 코드가 간결해진다.
immerInc: () =>
set(produce((state: State) => { ++state.deep.nested.obj.count })),
새로운 object를 생성하여 반환하는 것은 똑같지만 nested 과정을 immer가 단순하게 바꿔주어서 쓰기에도 보기에도 편하다.
공식 문서에는 Zustand와 Redux, Valtio, Jotai, Recoil을 비교하고 있다. State Model과 Render Optimization 두 가지면에서 읽어볼 수 있다.
Zustand는 Redux와 매우 유사하다. 두 라이브러리 모두 immutable state model을 사용한다. 다른 점이 있다면 Redux에는 context provider로 app을 감쌀 필요가 있다면 Zustand는 그런 것 없이 곧바로 모든 component에서 사용할 수 있다.
Render Optimization 측면에서는 두 라이브러리에 차이가 없다. 둘다 직접 selector를 사용하여 최적화를 해주어야한다.
Zustand는 Single store로 여러 state를 한 곳에서 관리하고, slice 단위로 나누는 반면, Jotai는 state를 독립적인 atom으로 구분한다. 또한 Jotai는 react 안에서만 사용 가능하지만 Zustand는 React 밖에서도 작동할 수 있다.
Jotai는 atom dependency를 사용하여 Render Optimization를 달성한다. depend 관계에 있는 atom 들만 render 요청을 하는 방식이다. 하지만 Zustand는 selector로 나누어주지 않으면 store가 통째로 render 된다.
Jotai와 Zustand를 Redux를 사용한 경험에 비추어 봤을 때 사용하기에 익숙한 것은 분명 Redux이다. 하지만 Jotai의 atom 방식도 나름 효율적으로 보인다. Redux에 익숙하다면 Zustand로 전환하는데 크게 힘들지 않을 것이다.
반대로, Store와 Slice에 질린 사람이라면 Jotai의 방식이 더 어울린다. Jotai의 tutorial이 매우 잘되어 있으니 익숙해지는데 오랜 시간이 걸릴 것 같지도 않다.
좋은 글 감사합니다~