리액트 상태 관리 심화 #4 Jotai와 원자(atom) 모델

5 분 소요

#3의 Zustand는 상태를 하나의 스토어에 모으는 하향식 도구였습니다. 이번 글의 Jotai는 정반대입니다. 상태를 원자(atom)라는 작은 단위로 쪼갠 다음, 그 원자들을 조립해 더 큰 상태를 만드는 상향식 모델입니다. 같은 클라이언트 상태 문제를 전혀 다른 각도에서 푸는 방식이라, 둘을 비교하며 보면 상태 관리의 폭이 넓어집니다.

atom이라는 발상 #

Jotai의 출발점은 “상태를 잘게 쪼개진 원자 단위로 두자"는 생각입니다. 하나의 원자는 값 하나를 담는 가장 작은 상태 조각입니다.

atoms.js
import { atom } from "jotai";

export const countAtom = atom(0);
export const nameAtom = atom("게스트");

atom(0)은 “초기값이 0인 원자 하나"를 정의합니다. 주목할 점은 이 원자 자체에는 값이 들어 있지 않다는 것입니다. 원자는 일종의 설정(config)일 뿐이고, 실제 값은 컴포넌트가 이 원자를 구독하는 시점에 결정됩니다. 덕분에 같은 원자를 여러 곳에서 써도 서로 충돌하지 않습니다.

useAtom — useState처럼 쓰기 #

원자를 컴포넌트에서 쓸 때는 useAtom을 호출합니다. 사용 모양이 useState와 거의 같습니다.

Counter.jsx
import { useAtom } from "jotai";
import { countAtom } from "./atoms";

function Counter() {
  const [count, setCount] = useAtom(countAtom);

  return (
    <div>
      <button onClick={() => setCount((c) => c - 1)}>-</button>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+</button>
    </div>
  );
}

[값, 설정함수] 쌍을 돌려주는 모양이 useState와 똑같습니다. 차이는 이 상태가 컴포넌트 지역이 아니라 전역으로 공유된다는 점입니다. 다른 컴포넌트에서 같은 countAtomuseAtom하면 같은 값을 보고, 한쪽에서 바꾸면 양쪽이 함께 갱신됩니다. #3의 Zustand처럼 Jotai도 기본 사용에서는 Provider로 트리를 감쌀 필요가 없습니다.

읽기만 하거나 쓰기만 한다면 더 좁은 훅을 씁니다.

읽기 전용 / 쓰기 전용
import { useAtomValue, useSetAtom } from "jotai";

const count = useAtomValue(countAtom);   // 값만 읽기
const setCount = useSetAtom(countAtom);  // 설정 함수만 (이 컴포넌트는 값 변화에 리렌더 안 됨)

useSetAtom은 값을 쓰기만 하고 구독하지는 않으므로, 값이 바뀌어도 그 컴포넌트는 리렌더되지 않습니다.

파생 원자 — 원자에서 원자를 만들기 #

Jotai의 진짜 힘은 파생 원자(derived atom)에서 나옵니다. 다른 원자의 값을 읽어 계산하는 새 원자를 정의할 수 있습니다.

파생 원자
import { atom } from "jotai";

export const countAtom = atom(0);

// countAtom을 읽어 두 배를 계산하는 읽기 전용 파생 원자
export const doubledAtom = atom((get) => get(countAtom) * 2);

atom((get) => ...)처럼 함수를 넘기면 파생 원자가 됩니다. get(countAtom)으로 다른 원자의 현재 값을 읽습니다. countAtom이 바뀌면 doubledAtom도 자동으로 다시 계산됩니다. 스프레드시트에서 한 셀을 바꾸면 그 셀을 참조하던 수식이 자동으로 갱신되는 것과 같은 모델입니다.

파생 원자 사용
function DoubledView() {
  const doubled = useAtomValue(doubledAtom);
  return <p> : {doubled}</p>;
}

여러 원자를 조합한 파생 원자도 만들 수 있습니다.

여러 원자 조합
const priceAtom = atom(1000);
const quantityAtom = atom(2);
const totalAtom = atom((get) => get(priceAtom) * get(quantityAtom));

priceAtom이나 quantityAtom 중 하나라도 바뀌면 totalAtom이 다시 계산됩니다. 기초 강좌 #14에서 useMemo로 손수 계산하던 파생 값을, Jotai에서는 원자 그래프가 알아서 관리합니다.

리렌더는 원자 단위로 끊긴다 #

Jotai의 리렌더 모델은 단순합니다. 컴포넌트는 자신이 useAtom한 원자(그리고 그 원자가 의존하는 원자)가 바뀔 때만 리렌더됩니다.

독립적으로 리렌더되는 컴포넌트들
function NameInput() {
  const [name, setName] = useAtom(nameAtom);
  return <input value={name} onChange={(e) => setName(e.target.value)} />;
}

function CountDisplay() {
  const count = useAtomValue(countAtom);
  return <p>{count}</p>; // nameAtom이 바뀌어도 여기는 리렌더 안 됨
}

nameAtom을 입력해도 countAtom만 보는 CountDisplay는 다시 그려지지 않습니다. Zustand에서 셀렉터로 얻던 정밀함을, Jotai에서는 상태를 처음부터 원자로 쪼갠 구조 자체가 제공합니다.

노트
Zustand와 Jotai는 둘 다 가벼운 클라이언트 상태 도구지만 접근이 반대입니다. Zustand는 하나의 스토어에서 셀렉터로 필요한 부분만 꺼내는 하향식, Jotai는 작은 원자를 정의하고 조립하는 상향식입니다. 서로 의존하는 파생 값이 많은 상태라면 Jotai의 원자 그래프가 깔끔하고, 한 덩어리로 묶인 전역 상태라면 Zustand의 단일 스토어가 직관적입니다.

atomWithStorage — 새로고침 후에도 유지 #

Zustand의 persist에 대응하는 도구로 Jotai에는 atomWithStorage가 있습니다.

localStorage에 저장되는 원자
import { atomWithStorage } from "jotai/utils";

export const themeAtom = atomWithStorage("theme", "light");
사용
function ThemeToggle() {
  const [theme, setTheme] = useAtom(themeAtom);
  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      현재: {theme}
    </button>
  );
}

첫 인자가 localStorage 키, 둘째가 초기값입니다. 일반 원자처럼 쓰면 값 변경이 자동으로 저장되고, 새로고침 후 복원됩니다.

마무리 #

Jotai는 상태를 작은 원자로 쪼개 조립하는 상향식 상태 관리 도구입니다.

  • atom(초기값) — 가장 작은 상태 조각, Provider 불필요
  • useAtom / useAtomValue / useSetAtomuseState와 닮은 사용법, 다만 전역 공유
  • 파생 원자 atom((get) => ...) — 다른 원자를 읽어 계산, 의존 원자가 바뀌면 자동 갱신
  • 리렌더가 원자 단위로 끊겨 정밀함
  • atomWithStoragelocalStorage 저장과 복원

여기까지 가벼운 클라이언트 상태 도구 두 가지(Zustand, Jotai)를 봤습니다. 다음 글인 “리액트 상태 관리 심화 #5 Redux Toolkit과 레거시 컨텍스트"에서는 한 시대를 지배했고 지금도 많은 코드베이스에 남아 있는 Redux를, 현재 권장 형태인 Redux Toolkit으로 정리합니다. 새 프로젝트에서의 위치도 함께 짚겠습니다.

X