회사에서 주로 상태관리를 할 때 zustand를 사용하고 있다. 개인적으로 top-down 형태로 중앙 상태를 내려 받아 처리하는 것을 선호한다.
일전에는 recoil를 사용했었는데 메모리 누수 이슈 및 유지보수가 되지 않는 이슈로 현재 사용하고 있지 않다.
recoil의 atomic적 사상을 참고해서 만든 라이브러리가 jotai(Bottom-up)이다. jotai, zustand 모두 같은 개발자가 개발을 했는데 어떤 차이점이 있는지 알아보고자 한다.
참고로 zustand는 redux를 많이 참고해서 만들었다고 한다.
zustand는 jotai에 비해 번들 사이즈가 작다.(https://bundlephobia.com/package/zustand@4.5.5)
Jotai는 Provider를 상단에 감싸줘야 하지만 zustand는 그냥 사용하면 된다.
기본적으로 쉽고 빠르게 적용할 수 있다.
zustand 공식 문서를 보면 state와 actions를 같은 곳에 두는 것을 권장하고 있다.(참고)
import create from 'zustand'
const useStore = create((set) => ({
bears: 0,
increasePopulation: () => set((state) => ({ bears: state.bears + 1 })),
removeAllBears: () => set({ bears: 0 }),
}))
// 아래 코드와 같이 사용하면 된다.
function BearCounter() {
const bears = useStore((state) => state.bears)
return <h1>{bears} around here...</h1>
}
function Controls() {
const increasePopulation = useStore((state) => state.increasePopulation)
return <button onClick={increasePopulation}>one up</button>
}
아래 내용부터는 내가 주로 사용하는 기능 중심으로 서술할 예정이다.
보통 스토어의 값을 가지고 올 때 selector를 통해서 가지고 올 것이다. 만약 참조하고 있는 값이 변경되면 리렌더링이 발생한다.(Object.is() 메서드를 기반으로 변경 여부를 판단)
useShallow는 객체나 배열과 같은 참조 타입의 상태를 다룰 때 얕은 비교(shallow comparison)를 수행한다. (내부 로직 참고) 이를 통해 상태의 내용이 실제로 변경되었을 때만 리렌더링이 발생한다.
import create from 'zustand'
import { useShallow } from 'zustand/shallow'
import React, { useEffect, useRef } from 'react'
// 스토어 정의
interface StoreState {
user: { name: string; age: number }
updateUser: (name: string, age: number) => void
posts: string[]
addPost: (post: string) => void
}
const useStore = create<StoreState>((set) => ({
user: { name: 'John', age: 30 },
updateUser: (name, age) => set((state) => ({ user: { ...state.user, name, age } })),
posts: ['First post'],
addPost: (post) => set((state) => ({ posts: [...state.posts, post] })),
}))
// useShallow를 사용하지 않은 컴포넌트 (불필요한 렌더링 발생)
// user의 내부값이 변경하지 않아도 계속해서 렌더링이 발생한다.
const UserInfoWithoutShallow: React.FC = () => {
const user = useStore((state) => state.user)
const renderCount = useRef(0)
useEffect(() => {
renderCount.current += 1
console.log('UserInfoWithoutShallow rendered:', renderCount.current)
})
return (
<div>
<h2>User Info (Without Shallow)</h2>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
<p>Render Count: {renderCount.current}</p>
</div>
)
}
// useShallow를 사용한 컴포넌트 (최적화된 렌더링)
// 내부 user 값이 변경하지 않았기 때문에 더이상 렌더링이 발생하지 않게 된다.
const UserInfoWithShallow: React.FC = () => {
const user = useStore(
useShallow((state) => ({
name: state.user.name,
age: state.user.age,
}))
)
const renderCount = useRef(0)
useEffect(() => {
renderCount.current += 1
console.log('UserInfoWithShallow rendered:', renderCount.current)
})
return (
<div>
<h2>User Info (With Shallow)</h2>
<p>Name: {user.name}</p>
<p>Age: {user.age}</p>
<p>Render Count: {renderCount.current}</p>
</div>
)
}
// 부모 컴포넌트
const App: React.FC = () => {
const updateUser = useStore((state) => state.updateUser)
const addPost = useStore((state) => state.addPost)
return (
<div>
<UserInfoWithoutShallow />
<UserInfoWithShallow />
<button onClick={() => updateUser('Heeje', 27)}>Update Age</button>
<button onClick={() => addPost('New post')}>Add Post</button>
</div>
)
}
export default App
필요한 상태 변화에만 반응하여 불필요한 리렌더링을 방지할 수 있다.
subscribe 기능은 특정 컴포넌트 외부에서 store의 값을 구독하여 해당 값이 변경되었을 때 동작해야 하는 로직을 구성할 수 있다.
import create from 'zustand'
import { subscribeWithSelector } from 'zustand/middleware'
// 스토어 정의
interface StoreState {
user: { name: string; isLoggedIn: boolean } | null
cart: { items: string[]; total: number }
setUser: (user: { name: string; isLoggedIn: boolean } | null) => void
addToCart: (item: string, price: number) => void
}
const useStore = create<StoreState>()(
subscribeWithSelector((set) => ({
user: null,
cart: { items: [], total: 0 },
setUser: (user) => set({ user }),
addToCart: (item, price) =>
set((state) => ({
cart: {
items: [...state.cart.items, item],
total: state.cart.total + price,
},
})),
}))
)
// 로그인 상태 변화 감지 및 로깅
useStore.subscribe(
(state) => state.user?.isLoggedIn,
(isLoggedIn, previousIsLoggedIn) => {
if (isLoggedIn && !previousIsLoggedIn) {
console.log('User just logged in')
} else if (!isLoggedIn && previousIsLoggedIn) {
console.log('User just logged out')
}
}
)
하나의 스토어가 점점 커지게 되면 원활한 관리를 위해 관심사에 따라 스토어를 분리할 수 있다. 이를 Slices Pattern이라고 한다.
import { create } from 'zustand'
// 할 일 항목 타입 정의
type Todo = {
id: number
text: string
completed: boolean
}
// 할 일 목록 슬라이스
type TodoSlice = {
todos: Todo[]
addTodo: (text: string) => void
toggleTodo: (id: number) => void
}
const createTodoSlice = (set): TodoSlice => ({
todos: [],
addTodo: (text) => set((state) => ({
todos: [...state.todos, { id: Date.now(), text, completed: false }]
})),
toggleTodo: (id) => set((state) => ({
todos: state.todos.map((todo: Todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
})),
})
// 필터 슬라이스
type FilterSlice = {
filter: 'all' | 'active' | 'completed'
setFilter: (filter: 'all' | 'active' | 'completed') => void
}
const createFilterSlice = (set): FilterSlice => ({
filter: 'all',
setFilter: (filter) => set({ filter }),
})
// 전체 스토어 타입
type TodoStore = TodoSlice & FilterSlice
// 스토어 생성
const useTodoStore = create<TodoStore>((set) => ({
...createTodoSlice(set),
...createFilterSlice(set),
}))
export default useTodoStore
위 코드를 기반으로 보면 Todo와 관련된 state, action에 대해 관심사 별로 분리 후에 useTodoStore에서 합친다.
이 패턴을 통해 다음과 같은 장점을 얻을 수 있다.
관심사 분리: 각 슬라이스는 특정 기능이나 도메인에 관련된 상태와 로직을 캡슐화한다. 이를 통해 코드를 더 논리적으로 구성하고 관리할 수 있다.
확장성: 새로운 기능을 추가해야 할 때, 기존 코드를 수정하지 않고 새로운 슬라이스를 만들어 추가할 수 있다. 이는 개방-폐쇄 원칙(Open-Closed Principle)을 따르면서 중앙 상태 관리를 할 수 있는 것이다.
코드 가독성 향상: 관련된 상태와 액션이 하나의 슬라이스에 모여있어, 코드를 이해하고 유지보수하기 쉬워질 수 있다.
테스트 용이성: 각 슬라이스를 독립적으로 테스트할 수 있어, 단위 테스트를 작성하고 관리하기 쉬워질 것이다.
Jotai는 bottom-up 방식으로 상태를 관리할 수 있다. zustand에 비해 번들 사이즈가 살짝 더 크다. (참고)
예시 코드를 살펴보자.
atom을 통해 상태의 가장 작은 단위를 생성할 수 있다. 그리고 상태값과 업데이트 함수를 반환하는 useAtom 훅을 통해 상태값 접근 및 업데이트를 할 수 있다.
import { atom, useAtom } from 'jotai'
// 기본 atom 생성
const countAtom = atom(0)
// 파생 atom 생성
const doubleCountAtom = atom(
(get) => get(countAtom) * 2
)
// 쓰기 가능한 파생 atom 생성
const countryAtom = atom('Japan')
const languageAtom = atom((get) => {
if (get(countryAtom) === 'Japan') return 'Japanese'
return 'Unknown'
})
const writerAtom = atom(
(get) => get(languageAtom),
(get, set, newLanguage: string) => {
if (newLanguage === 'Japanese') {
set(countryAtom, 'Japan')
} else {
set(countryAtom, 'Unknown')
}
}
)
// React 컴포넌트에서 사용
function Counter() {
const [count, setCount] = useAtom(countAtom)
const [doubleCount] = useAtom(doubleCountAtom)
return (
<div>
<p>Count: {count}</p>
<p>Double Count: {doubleCount}</p>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
</div>
)
}
특정 컴포넌트 내에서 상태값 또는 업데이트하는 함수만 참조하는 경우가 있을 때는 각각 useAtomValue, useSetAtom 훅을 사용하면 된다.
import React from 'react'
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai'
// Todo 타입 정의
interface Todo {
id: number
text: string
completed: boolean
}
// Atoms 정의
const todosAtom = atom<Todo[]>([])
const newTodoTextAtom = atom('')
// 파생 atom: 완료된 할 일 개수
const completedCountAtom = atom((get) => {
const todos = get(todosAtom)
return todos.filter(todo => todo.completed).length
})
// TodoInput 컴포넌트 - 새 할 일 입력
const TodoInput: React.FC = () => {
const [newTodoText, setNewTodoText] = useAtom(newTodoTextAtom)
const setTodos = useSetAtom(todosAtom)
const addTodo = () => {
if (newTodoText.trim()) {
setTodos((prev) => [...prev, { id: Date.now(), text: newTodoText, completed: false }])
setNewTodoText('')
}
}
return (
<div>
<input
value={newTodoText}
onChange={(e) => setNewTodoText(e.target.value)}
placeholder="Add new todo"
/>
<button onClick={addTodo}>Add</button>
</div>
)
}
// TodoItem 컴포넌트 - 개별 할 일 항목
const TodoItem: React.FC<{ todo: Todo }> = ({ todo }) => {
const setTodos = useSetAtom(todosAtom)
const toggleTodo = () => {
setTodos((prev) =>
prev.map((t) => (t.id === todo.id ? { ...t, completed: !t.completed } : t))
)
}
return (
<li>
<input type="checkbox" checked={todo.completed} onChange={toggleTodo} />
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
</li>
)
}
// TodoList 컴포넌트 - 할 일 목록
const TodoList: React.FC = () => {
const todos = useAtomValue(todosAtom)
return (
<ul>
{todos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
)
}
// CompletedCount 컴포넌트 - 완료된 할 일 개수 표시
const CompletedCount: React.FC = () => {
const completedCount = useAtomValue(completedCountAtom)
return <div>Completed todos: {completedCount}</div>
}
// App 컴포넌트
const App: React.FC = () => {
return (
<div>
<h1>Todo List</h1>
<TodoInput />
<TodoList />
<CompletedCount />
</div>
)
}
export default App
atom을 전역적으로 사용할 경우에 그 범위를 Provider를 통해 결정할 수 있다. Provider로 감싸진 하위 컴포넌트들은 모두 Provider에 설정되 store에 접근해서 상태값을 사용할 수 있다.
const myStore = createStore() // 빈 스토어를 생성한다.
const Root = () => (
<Provider store={myStore}>
<Component />
</Provider>
)
// 하위 컴포넌트 내에서 다음과 같이 사용
const Component = () => {
const store = useStore()
// ...
}
다음 글에서는 zustand에서 중첩 객체의 불변성을 유지하며서 데이터를 편리하게 업데이트 해주는 immer에 대해서 알아볼 예정이다.