Zustand와 Jotai 비교

이희제·2024년 8월 26일
post-thumbnail

회사에서 주로 상태관리를 할 때 zustand를 사용하고 있다. 개인적으로 top-down 형태로 중앙 상태를 내려 받아 처리하는 것을 선호한다.

일전에는 recoil를 사용했었는데 메모리 누수 이슈 및 유지보수가 되지 않는 이슈로 현재 사용하고 있지 않다.

recoil의 atomic적 사상을 참고해서 만든 라이브러리가 jotai(Bottom-up)이다. jotai, zustand 모두 같은 개발자가 개발을 했는데 어떤 차이점이 있는지 알아보고자 한다.

참고로 zustand는 redux를 많이 참고해서 만들었다고 한다.

1. zustand

zustand는 jotai에 비해 번들 사이즈가 작다.(https://bundlephobia.com/package/zustand@4.5.5)

Jotai는 Provider를 상단에 감싸줘야 하지만 zustand는 그냥 사용하면 된다.

기본적으로 쉽고 빠르게 적용할 수 있다.

Store 만들기

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>
}

아래 내용부터는 내가 주로 사용하는 기능 중심으로 서술할 예정이다.

useShallow

보통 스토어의 값을 가지고 올 때 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

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

하나의 스토어가 점점 커지게 되면 원활한 관리를 위해 관심사에 따라 스토어를 분리할 수 있다. 이를 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)을 따르면서 중앙 상태 관리를 할 수 있는 것이다.

  • 코드 가독성 향상: 관련된 상태와 액션이 하나의 슬라이스에 모여있어, 코드를 이해하고 유지보수하기 쉬워질 수 있다.

  • 테스트 용이성: 각 슬라이스를 독립적으로 테스트할 수 있어, 단위 테스트를 작성하고 관리하기 쉬워질 것이다.


2. Jotai

Jotai는 bottom-up 방식으로 상태를 관리할 수 있다. zustand에 비해 번들 사이즈가 살짝 더 크다. (참고)

Atom과 useAtom

예시 코드를 살펴보자.

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

특정 컴포넌트 내에서 상태값 또는 업데이트하는 함수만 참조하는 경우가 있을 때는 각각 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

Provider, Store

atom을 전역적으로 사용할 경우에 그 범위를 Provider를 통해 결정할 수 있다. Provider로 감싸진 하위 컴포넌트들은 모두 Provider에 설정되 store에 접근해서 상태값을 사용할 수 있다.

const myStore = createStore() // 빈 스토어를 생성한다.

const Root = () => (
  <Provider store={myStore}>
    <Component />
  </Provider>
)

// 하위 컴포넌트 내에서 다음과 같이 사용
const Component = () => {
  const store = useStore()
  // ...
}

다음 글에서는 zustand에서 중첩 객체의 불변성을 유지하며서 데이터를 편리하게 업데이트 해주는 immer에 대해서 알아볼 예정이다.

profile
그냥 하자

0개의 댓글