리액트 상태 관리 심화 #4 Jotai와 원자(atom) 모델
#3의 Zustand는 상태를 하나의 스토어에 모으는 하향식 도구였습니다. 이번 글의 Jotai는 정반대입니다. 상태를 원자(atom)라는 작은 단위로 쪼갠 다음, 그 원자들을 조립해 더 큰 상태를 만드는 상향식 모델입니다. 같은 클라이언트 상태 문제를 전혀 다른 각도에서 푸는 방식이라, 둘을 비교하며 보면 상태 관리의 폭이 넓어집니다.
atom이라는 발상 #
Jotai의 출발점은 “상태를 잘게 쪼개진 원자 단위로 두자"는 생각입니다. 하나의 원자는 값 하나를 담는 가장 작은 상태 조각입니다.
import { atom } from "jotai";
export const countAtom = atom(0);
export const nameAtom = atom("게스트");atom(0)은 “초기값이 0인 원자 하나"를 정의합니다. 주목할 점은 이 원자 자체에는 값이 들어 있지 않다는 것입니다. 원자는 일종의 설정(config)일 뿐이고, 실제 값은 컴포넌트가 이 원자를 구독하는 시점에 결정됩니다. 덕분에 같은 원자를 여러 곳에서 써도 서로 충돌하지 않습니다.
useAtom — useState처럼 쓰기 #
원자를 컴포넌트에서 쓸 때는 useAtom을 호출합니다. 사용 모양이 useState와 거의 같습니다.
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와 똑같습니다. 차이는 이 상태가 컴포넌트 지역이 아니라 전역으로 공유된다는 점입니다. 다른 컴포넌트에서 같은 countAtom을 useAtom하면 같은 값을 보고, 한쪽에서 바꾸면 양쪽이 함께 갱신됩니다. #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에서는 상태를 처음부터 원자로 쪼갠 구조 자체가 제공합니다.
atomWithStorage — 새로고침 후에도 유지 #
Zustand의 persist에 대응하는 도구로 Jotai에는 atomWithStorage가 있습니다.
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/useSetAtom—useState와 닮은 사용법, 다만 전역 공유- 파생 원자
atom((get) => ...)— 다른 원자를 읽어 계산, 의존 원자가 바뀌면 자동 갱신 - 리렌더가 원자 단위로 끊겨 정밀함
atomWithStorage—localStorage저장과 복원
여기까지 가벼운 클라이언트 상태 도구 두 가지(Zustand, Jotai)를 봤습니다. 다음 글인 “리액트 상태 관리 심화 #5 Redux Toolkit과 레거시 컨텍스트"에서는 한 시대를 지배했고 지금도 많은 코드베이스에 남아 있는 Redux를, 현재 권장 형태인 Redux Toolkit으로 정리합니다. 새 프로젝트에서의 위치도 함께 짚겠습니다.