Zustand

Zustand state 사용법: 값 읽기와 변경 흐름 익히기

2026.05.05·수정 2026.05.12·약 16분

이 글에서 정리하는 내용

Zustand store를 만든 뒤 컴포넌트에서 state를 읽고 action으로 변경하는 흐름을 정리합니다. 단순히 값을 가져오는 문법보다, 컴포넌트가 어떤 값에 의존하는지 좁히고 변경 규칙을 store 안에 모으는 기준을 중심으로 봅니다.

Zustand state는 컴포넌트에서 어떻게 읽히는가

Zustand store에서 컴포넌트가 필요한 state와 action만 선택해 사용하는 구조 설명

Zustand store를 만들고 나면 다음으로 막히는 부분은 컴포넌트에서 값을 꺼내는 방식입니다. 는 같은 컴포넌트 안에서 값을 만들고 바로 사용하므로 흐름이 눈에 잘 보입니다. 반면 Zustand는 state가 store 파일에 있고, 컴포넌트는 그 store에서 필요한 값을 선택해 화면에 표시합니다.

처음에는 store를 하나의 전역 객체처럼 생각하기 쉽습니다. 그래서 컴포넌트에서 store 전체를 가져온 뒤 그 안의 값을 꺼내 쓰는 방식으로 접근하게 됩니다. 작은 카운터 예제에서는 이 방식도 문제가 없어 보입니다. 하지만 화면이 커지면 어떤 컴포넌트가 어떤 state 때문에 다시 렌더링되는지 추적하기 어려워집니다.

Zustand에서 먼저 잡아둘 기준은 명확합니다. 컴포넌트는 store 전체를 알 필요가 없고, 지금 화면을 그리는 데 필요한 state와 action만 알면 됩니다. 상태 변경도 컴포넌트 안에서 직접 계산을 흩뿌리기보다 store에 미리 정의한 action을 호출하는 흐름으로 두는 편이 유지보수에 유리합니다.

기본 store 예시

import { create } from "zustand"; type CounterStore = { count: number; increase: () => void; decrease: () => void; reset: () => void;
}; export const useCounterStore = create<CounterStore>((set) => ({ count: 0, increase: () => set((state) => ({ count: state.count + 1 })), decrease: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 })}));

이 store에는 숫자 상태인 와 값을 변경하는 함수들이 함께 들어 있습니다. 여기서은 React 컴포넌트의 이벤트 핸들러가 아니라 store에 들어 있는 action입니다. 컴포넌트는 이 action을 가져와 실행만 합니다.

이 구조를 처음 볼 때 헷갈리는 지점은 state와 action이 같은 객체 안에 있다는 점입니다. 는 화면에 표시할 값이고는 값을 바꾸는 행동입니다. 둘을 같은 store에 두면 관련된 값과 변경 규칙을 한곳에서 확인할 수 있습니다.

selector로 필요한 값만 가져오기

Zustand store는 hook처럼 사용할 수 있습니다. 컴포넌트 안에서 를 호출하면 store에 접근할 수 있습니다. 이때 가장 자주 쓰는 방식은 selector 함수를 넘기는 것입니다.

const count = useCounterStore((state) => state.count);

이 코드는 store 전체를 가져오는 코드가 아닙니다. store 안에서 만 선택해서 가져오는 코드입니다. 그래서 컴포넌트 입장에서는 “나는 count 값에 의존한다”는 관계가 코드에 바로 드러납니다.

관리자 화면을 예로 들면, 상단 요약 카드에서는 전체 필터 상태나 모달 상태를 모두 알 필요가 없습니다. 현재 선택된 탭 이름만 필요하다면 만 가져오면 됩니다. 필터 버튼이라면 과 정도만 필요할 수 있습니다. 이렇게 필요한 값만 고르는 습관을 들이면 store가 커졌을 때도 컴포넌트의 의존 범위가 흐려지지 않습니다.

컴포넌트에서 state 읽기

import { useCounterStore } from "./stores/counterStore"; export function CountViewer() { const count = useCounterStore((state) => state.count); return <p>현재 값: {count}</p>;
}

이 컴포넌트는 를 화면에 표시하는 역할만 합니다. 값을 증가시키거나 초기화하는 책임은 없습니다. 읽는 컴포넌트와 변경하는 컴포넌트를 반드시 분리해야 한다는 뜻은 아니지만, 예제를 나눠 보면 Zustand의 흐름이 더 선명해집니다.

수정할 때도 확인 지점이 좁아집니다. 화면에 숫자가 잘못 보이면 먼저 가 어떤 selector를 쓰는지 확인하면 됩니다. 값이 바뀌지 않는 문제라면 읽는 코드보다 action 연결이나 store의 로직을 봐야 합니다.

action을 통해 state 변경하기

state를 변경할 때는 store 안에 정의한 action을 가져와 호출합니다. 값을 컴포넌트에서 직접 바꾸는 것이 아니라같은 함수를 실행해서 store가 상태를 바꾸게 만드는 방식입니다.

import { useCounterStore } from "./stores/counterStore"; export function CounterControls() { const increase = useCounterStore((state) => state.increase); const decrease = useCounterStore((state) => state.decrease); const reset = useCounterStore((state) => state.reset); return ( <div> <button type="button" onClick={decrease}> -1 </button> <button type="button" onClick={increase}> +1 </button> <button type="button" onClick={reset}> 초기화 </button> </div> );
}

버튼 컴포넌트는 가 어떻게 계산되는지 알지 않아도 됩니다. 버튼은 를 호출하고, 초기화 버튼은 을 호출합니다. 이 구조가 단순해 보여도 실제 화면이 커지면 차이가 생깁니다. 변경 규칙이 컴포넌트마다 흩어지지 않고 store 안에 모이기 때문입니다.

예를 들어 필터 패널을 여는 기능이 여러 곳에 필요하다면, 각 컴포넌트에서 같은 로직을 따로 작성하는 대신 action을 가져와 호출할 수 있습니다. 이름이 있는 action은 코드만 봐도 어떤 행동인지 드러납니다. 나중에 열기 조건이 바뀌어도 버튼 컴포넌트를 전부 찾아다닐 필요가 줄어듭니다.

이전 state를 기준으로 변경하는 경우

상태를 바꿀 때 항상 고정된 값을 넣는 것은 아닙니다. 카운터 증가처럼 이전 값이 있어야 다음 값을 계산할 수 있는 경우가 있습니다. 이때는 에 객체를 바로 넘기기보다 함수를 넘겨 이전 state를 기준으로 새 값을 만듭니다.

increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),

은 현재 store에 들어 있는 값을 기준으로 다음 값을 만듭니다. 반대로 초기화처럼 이전 값이 필요 없는 경우에는 객체를 바로 넘겨도 됩니다.

reset: () => set({ count: 0 }),

Zustand의 은 기본적으로 전달한 객체를 기존 state와 얕게 병합합니다. 그래서 위 코드처럼 만 넘기면 다른 action까지 새로 작성할 필요는 없습니다. 다만 중첩 객체를 다룰 때는 얕은 병합이라는 점을 놓치면 안 됩니다. 깊은 객체를 일부만 바꾸려면 해당 객체를 새로 펼쳐서 만들어야 합니다.

type UserStore = { user: { name: string; role: "admin" | "member"; }; changeRole: (role: "admin" | "member") => void;
}; export const useUserStore = create<UserStore>((set) => ({ user: { name: "해비", role: "member"}, changeRole: (role) => set((state) => ({ user: { ...state.user, role}}))}));

이 예시에서는 만 바꾸고 싶지만, 객체 자체는 새로 만들어야 합니다. 처럼 직접 대입하는 방식으로 작성하면 상태 변경 흐름이 흐려집니다. immer 같은 도구를 별도로 붙이지 않는 기본 Zustand에서는 새 객체를 반환하는 방식으로 생각하는 것이 기준입니다.

action을 만들 때는 먼저 “이전 값이 필요한가?”를 보면 됩니다. 토글, 증가, 배열 추가, 중첩 객체 수정처럼 기존 상태를 참고해야 하는 작업은 함수형 업데이트가 어울립니다. 단순 초기화나 특정 값 지정은 객체를 바로 넘겨도 충분합니다.

store 전체를 가져오는 방식이 조심스러운 이유

아래처럼 store 전체를 가져오는 코드도 작성할 수 있습니다.

const store = useCounterStore(); return ( <div> <p>현재 값: {store.count}</p> <button type="button" onClick={store.increase}>+1</button> </div>
);

작은 예제에서는 이 코드가 더 편해 보입니다. 문제는 store에 값이 늘어났을 때입니다.같은 값이 한 store에 들어 있다면, 컴포넌트가 실제로 어떤 값에 의존하는지 코드만 보고 파악하기 어려워집니다.

selector를 사용하면 컴포넌트가 필요한 값만 고르게 됩니다. 화면을 수정할 때도 먼저 확인할 지점이 좁아집니다. “이 컴포넌트는 count만 읽는가?”, “action만 호출하는가?”, “여러 state를 한 번에 가져와야 하는가?”처럼 판단 기준이 생깁니다.

물론 모든 selector를 무조건 한 줄씩 분리해야 한다는 뜻은 아닙니다. 한 컴포넌트에서 와 가 함께 필요할 수 있습니다. 다만 처음부터 store 전체를 습관적으로 가져오는 방식은 피하는 것이 좋습니다. Zustand의 장점은 store를 간단히 만들 수 있다는 점에 있지만, 간단함이 의존 관계를 흐리게 만들면 유지보수에서 다시 비용이 생깁니다.

UI 상태 관리 예시로 다시 보기

관리자 UI 상태를 Zustand selector와 action으로 분리해 관리하는 예시 구조

카운터 예제만 보면 Zustand가 숫자를 올리고 내리는 도구처럼 느껴질 수 있습니다. 하지만 실제 React 화면에서는 탭, 모달, 필터, 정렬 기준처럼 여러 컴포넌트가 함께 참조하는 UI 상태에서 더 자주 필요해집니다.

import { create } from "zustand"; type AdminTab = "summary" | "orders" | "settings"; type AdminUiStore = { selectedTab: AdminTab; isFilterOpen: boolean; selectTab: (tab: AdminTab) => void; openFilter: () => void; closeFilter: () => void; toggleFilter: () => void;
}; export const useAdminUiStore = create<AdminUiStore>((set) => ({ selectedTab: "summary", isFilterOpen: false, selectTab: (tab) => set({ selectedTab: tab }), openFilter: () => set({ isFilterOpen: true }), closeFilter: () => set({ isFilterOpen: false }), toggleFilter: () => set((state) => ({ isFilterOpen: !state.isFilterOpen }))}));

이 store는 관리자 화면의 UI 상태만 다룹니다. 선택된 탭을 바꾸는 action, 필터 패널을 여닫는 action이 이름으로 분리되어 있습니다. 컴포넌트에서는 을 읽거나 을 호출하면 됩니다.

export function AdminTabs() { const selectedTab = useAdminUiStore((state) => state.selectedTab); const selectTab = useAdminUiStore((state) => state.selectTab); return ( <nav> <button type="button" aria-pressed={selectedTab === "summary"} onClick={() => selectTab("summary")} > 요약 </button> <button type="button" aria-pressed={selectedTab === "orders"} onClick={() => selectTab("orders")} > 주문 </button> <button type="button" aria-pressed={selectedTab === "settings"} onClick={() => selectTab("settings")} > 설정 </button> </nav> );
}

이 컴포넌트에서 봐야 할 부분은 과 입니다. 탭을 선택했을 때 어떤 문자열을 넣을지는 컴포넌트가 정하지만, store 상태를 실제로 바꾸는 일은 action이 맡습니다. 필터 패널 컴포넌트도 같은 방식으로만 골라서 사용할 수 있습니다.

이렇게 나누면 컴포넌트를 수정할 때 확인 순서가 생깁니다. 화면에 표시되는 값이 이상하면 selector로 읽는 state를 먼저 봅니다. 버튼을 눌렀는데 값이 바뀌지 않으면 action 연결을 확인합니다. 값 계산이 틀렸다면 store 안의 로직을 보면 됩니다.

반대로 같은 화면에서 서버 데이터, 폼 입력값, 임시 UI 상태가 모두 섞여 있다면 Zustand에 모두 넣기 전에 성격을 나눠야 합니다. 서버에서 받아오는 목록 데이터는 TanStack Query 같은 도구가 맡는 편이 자연스럽고, Zustand는 여러 컴포넌트가 공유해야 하는 클라이언트 UI 상태에 더 잘 맞습니다.

정리

Zustand에서 state를 읽고 변경하는 흐름은 복잡하지 않습니다. 다만 초반에 기준을 잡지 않으면 컴포넌트마다 store를 가져오는 방식이 달라지고, 변경 로직도 여기저기 흩어질 수 있습니다.

기본 기준은 selector로 필요한 state만 읽고, 변경은 store에 정의한 action으로 처리하는 것입니다. 이전 값이 필요한 변경은 형태로 작성하고, 고정된 값으로 바꾸는 작업은 처럼 단순하게 둘 수 있습니다. 중첩 객체를 바꿀 때는 직접 대입하지 말고 새 객체를 반환하는 흐름을 유지해야 합니다.

다음에 Zustand 코드를 작성할 때는 세 가지만 먼저 확인하면 됩니다. 이 컴포넌트가 실제로 읽어야 하는 값은 무엇인지, 클릭이나 입력으로 실행해야 하는 action은 무엇인지, 그리고 새 state를 만들 때 이전 state가 필요한지입니다. 이 기준이 잡히면 store가 조금 커져도 읽는 코드와 바꾸는 코드가 쉽게 섞이지 않습니다.

같이 읽으면 좋은 글

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

댓글 남기기