학습 목표·선수 지식: 필터 토글·부분 변경·초기화 규칙을 store action에 모으고 연속 호출에도 값이 유실되지 않는지 확인합니다. 선수 지식: create와 객체 전개, React 이벤트.
이 글에서 정리하는 내용
Zustand에서 action은 별도의 action 객체를 만드는 절차가 아니라, store 안에 상태 변경 규칙을 함수로 모아두는 방식입니다. 필터 상태 예시를 기준으로 컴포넌트에 흩어진 변경 로직을 action으로 옮기는 기준을 정리합니다.
실습 경로: 현재 ZIP → 전체 코드. 먼저 설명에서 지정한 파일만 읽고, 설정·테스트 파일은 필요한 때 확인하세요.
컴포넌트 안의 변경 로직이 길어지는 순간

상태 변경 로직은 처음에는 컴포넌트 안에 두는 쪽이 더 빠르게 느껴집니다. 버튼을 클릭하면 값을 바꾸고, 체크박스를 누르면 boolean 값을 뒤집고, 초기화 버튼을 누르면 기본값으로 되돌리면 됩니다. 코드 몇 줄로 끝나는 상황에서는 굳이 함수를 따로 빼야 하는지 잘 와닿지 않습니다.
문제는 같은 규칙이 두 군데 이상 생길 때부터입니다. 상품 목록 화면에 검색어, 카테고리, 품절 여부 필터가 있다고 하면, 필터 변경 로직은 검색 영역, 모바일 바텀시트, 상단 요약 칩, 초기화 버튼에서 반복될 수 있습니다. 이때 각 컴포넌트가 직접 상태 구조를 알고 값을 조립하면, 필터 항목이 하나 늘어날 때마다 여러 파일을 같이 고쳐야 합니다.
아래 코드는 겉보기에는 크게 나쁘지 않아 보입니다. 하지만 컴포넌트가 필터 객체의 구조를 알고 있고, 이전 상태를 복사한 뒤 어떤 필드만 바꿀지 직접 결정합니다. 작은 화면에서는 넘어갈 수 있지만, 같은 필터를 다른 컴포넌트에서도 건드리는 순간 수정 지점이 늘어납니다.
컴포넌트가 변경 규칙을 직접 들고 있는 형태
개념 부분 코드: src/examples/post-2934-1.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
// ProductFilterPanel 이벤트 안의 비교용 부분 코드
// filters는 이 렌더에서 읽은 값입니다.
const handleSoldOutChange = () => {
setFilters({ ...filters, onlySoldOut: !filters.onlySoldOut });
};
const handleReset = () => {
setFilters({ keyword: '', category: 'all', onlySoldOut: false });
};
이 방식의 불편함은 코드가 틀렸다는 데 있지 않습니다. 변경 규칙이 컴포넌트에 노출된다는 점이 문제입니다. onlySoldOut을 토글하는 규칙, 필터를 기본값으로 되돌리는 규칙은 화면 모양보다 상태 정책에 가깝습니다. 이런 규칙이 여러 컴포넌트에 흩어지면 같은 일을 하는 코드가 조금씩 다른 형태로 늘어납니다.
Zustand에서 action은 무엇을 의미하는가
Zustand에서 action은 어렵게 받아들일 필요가 없습니다. store 안에 들어 있는 상태 변경 함수라고 보면 됩니다. Zustand store에는 문자열, 숫자, 객체 같은 값뿐 아니라 함수도 함께 둘 수 있습니다. 이 함수 안에서 set을 호출하면 store의 상태가 변경됩니다.
Redux를 먼저 접한 경우에는 action이라는 단어 때문에 action type, payload, reducer, dispatch까지 떠올리기 쉽습니다. Zustand에서는 그 구조를 반드시 만들 필요가 없습니다. 프로젝트 스타일에 따라 비슷한 구조를 만들 수는 있지만, 기본 학습 단계에서는 “상태 변경 규칙에 이름을 붙인 함수”로 이해하는 편이 덜 헷갈립니다.
컴포넌트가 직접 상태 객체를 조립하는 대신, store가 변경 규칙을 갖게 만들면 화면 코드가 읽기 쉬워집니다. 화면은 “품절 필터를 토글한다”, “필터를 초기화한다”처럼 사용자 행동을 action으로 요청하고, 실제로 어떤 필드가 어떻게 바뀌는지는 store 내부에서 처리합니다.
상태와 action을 함께 둔 store
전체 프로젝트 src/store.ts의 useFilterStore가 setKeyword·setCategory·toggleSoldOut·resetFilters를 제공합니다. 글의 필터 예제를 같은 규칙으로 구현했습니다. store.ts 전체 코드
이제 필터 변경 규칙은 store 안으로 들어갔습니다. 컴포넌트는 필터 객체의 전체 구조를 몰라도 됩니다. 검색어를 바꾸고 싶으면 setKeyword를 호출하고, 품절 여부를 뒤집고 싶으면 toggleSoldOut을 호출하면 됩니다.
여기서 set에 객체를 바로 넘기는 경우와 함수를 넘기는 경우를 구분해서 보면 좋습니다. 이전 상태를 참고하지 않아도 되는 초기화는 set({ filters: initialFilters })처럼 바로 작성할 수 있습니다. 반대로 기존 값의 일부를 유지하거나 boolean 값을 반전해야 한다면 set((state) => ...) 형태로 이전 상태를 기준으로 새 상태를 만드는 쪽이 맞습니다.
중첩 객체를 다룰 때도 한 번 더 확인해야 합니다. set은 store의 최상위 상태를 갱신하는 흐름으로 쓰이기 때문에 filters만 바꾸고 싶다면 기존 내부 값을 직접 보존해줘야 합니다. 그래서 위 코드처럼 state.filters를 펼친 뒤 필요한 필드만 바꾸는 형태가 자주 나옵니다.
필터 상태로 보는 action 분리 기준
action을 만들 때 모든 함수를 같은 기준으로 나누면 오히려 store가 복잡해질 수 있습니다. 먼저 상태 변경의 성격을 나눠서 보면 판단이 쉬워집니다. 값 하나를 바꾸는 action, 현재 값을 기준으로 뒤집는 action, 여러 값을 한 번에 되돌리는 action은 서로 역할이 다릅니다.
값 하나를 바꾸는 action
검색어와 카테고리처럼 사용자가 선택한 값이 그대로 상태에 들어가는 경우에는 setKeyword처럼 값 중심 이름을 쓰기 좋습니다. 이런 action은 인자로 받은 값을 상태에 반영하는 역할만 맡습니다.
개념 부분 코드: src/examples/post-2934-2.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
setKeyword: (keyword) => set((state) => ({ filters: { ...state.filters, keyword } }));
이런 action은 단순하지만 의미가 있습니다. 컴포넌트마다 set((state) => ...)를 반복하지 않게 만들고, 검색어 변경 규칙이 나중에 바뀌어도 store 한 곳에서 수정할 수 있습니다.
현재 값을 기준으로 바꾸는 action
토글은 이전 상태를 반드시 봐야 합니다. 품절 여부, 모달 열림 여부, 선택 여부처럼 true와 false가 오가는 상태는 컴포넌트가 직접 값을 뒤집기보다 store action으로 옮기는 쪽이 깔끔합니다.
개념 부분 코드: src/examples/post-2934-3.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
toggleSoldOut: () =>
set((state) => ({
filters: { ...state.filters, onlySoldOut: !state.filters.onlySoldOut },
}));
이름도 setSoldOut보다 toggleSoldOut이 더 잘 맞는 경우가 있습니다. setSoldOut은 외부에서 값을 직접 넘긴다는 느낌이고 toggleSoldOut은 현재 값을 반대로 바꾼다는 규칙이 드러납니다. action 이름은 짧은 문법 문제가 아니라 나중에 코드를 읽는 기준이 됩니다.
여러 값을 한 번에 되돌리는 action
초기화는 action으로 분리했을 때 효과가 더 분명합니다. 필터 기본값이 여러 필드로 구성되어 있으면, 컴포넌트마다 초기값 객체를 반복해서 작성하기 쉽습니다. 기본값이 바뀌면 반복된 초기화 코드가 그대로 수정 대상이 됩니다.
개념 부분 코드: src/examples/post-2934-4.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.
const initialFilters = { keyword: '', category: 'all', onlySoldOut: false };
resetFilters: () => set({ filters: initialFilters });
초기값을 initialFilters로 분리해두면 action에서도 재사용할 수 있고, 테스트하거나 수정할 때도 기준점이 명확합니다. 특히 관리자 화면이나 검색 화면처럼 필터 항목이 늘어나는 UI에서는 초기화 규칙을 한 곳에 두는 것이 나중에 차이를 만듭니다.
컴포넌트는 action을 호출하는 쪽으로 가볍게 둔다
action을 store로 옮기면 컴포넌트의 역할이 바뀝니다. 컴포넌트는 사용자의 이벤트를 받고, 필요한 상태와 action을 꺼내서 연결합니다. 상태 객체를 어떻게 복사할지, 어떤 필드를 유지해야 할지, 초기값이 무엇인지는 컴포넌트의 주된 관심사가 아닙니다.
전체 프로젝트 src/App.tsx에서 SearchInput·Controls·ResetFilterButton이 각각 필요한 값과 action을 연결합니다. App.tsx 전체 코드
이 구조에서는 이벤트 핸들러 안에 상태 변경 규칙이 거의 남지 않습니다. 입력값을 읽어서 setKeyword에 넘기거나, 버튼 클릭 시 toggleSoldOut을 호출하는 정도만 남습니다. 화면 코드를 읽을 때도 “어떤 UI가 어떤 action을 호출하는지”가 먼저 보입니다.
다만 action을 분리한다고 해서 모든 상태를 전역 store로 옮겨야 하는 것은 아닙니다. 한 컴포넌트 내부에서만 쓰이는 드롭다운 열림 여부나 임시 입력값은 useState가 더 단순할 수 있습니다. Zustand action으로 옮길 만한 상태는 여러 컴포넌트에서 공유되거나, 변경 규칙을 한 곳에 모아야 할 이유가 있는 상태입니다.
action 이름과 책임 범위를 정하는 기준
이전 렌더에서 잡은 값과 action 실행 시점의 값
컴포넌트의 filters는 해당 렌더에서 읽은 값입니다. 같은 이벤트에서 이를 기준으로 두 번 토글하면 두 호출 모두 같은 값을 뒤집어 한 번만 바뀐 결과가 될 수 있습니다. store의 toggleSoldOut은 매번 set의 현재 state를 읽으므로 두 번 호출하면 원래 boolean으로 돌아옵니다. 전체 실습 테스트는 검색어를 보존하면서 두 번 토글하고 카테고리를 변경한 결과와 초기화를 검사합니다.
이 글의 action은 동기 필터 변경만 다룹니다. 비동기 저장을 추가한다면 await 전 스냅샷을 나중에 통째로 덮어쓰지 말고, 응답 시점의 현재 상태를 사용해야 합니다. 중복 제출 차단과 늦게 도착한 응답 무시 정책은 요청의 의미에 맞게 별도로 설계합니다. 이 예제에서 서버 요청을 검증했다고 해석하지 않습니다.
action 이름은 나중에 store를 다시 읽을 때 생각보다 큰 영향을 줍니다. 이름이 모호하면 컴포넌트에서는 짧아 보여도 store 내부를 계속 확인해야 합니다. 반대로 이름이 상태 변경 의도를 잘 드러내면, 화면 코드만 보고도 흐름을 어느 정도 따라갈 수 있습니다.
| 상황 | 이름 예시 | 판단 기준 |
|---|---|---|
| 값을 그대로 반영 | setKeyword, setCategory | 인자로 받은 값을 특정 상태에 넣는 경우 |
| 현재 값을 반대로 변경 | toggleSoldOut, toggleModal | 이전 상태를 기준으로 true/false를 바꾸는 경우 |
| 기본값으로 되돌림 | resetFilters, clearSelectedItems | 여러 상태를 정해진 기준으로 초기화하는 경우 |
| 여러 변경을 한 번에 처리 | applyFilterPreset | 단순 set보다 하나의 사용자 행동에 가까운 경우 |
setKeyword는 값 중심 이름입니다. 입력값, 선택값처럼 외부에서 받은 값을 그대로 반영할 때 어울립니다. toggleSoldOut은 현재 상태를 기준으로 반전할 때 어울립니다. resetFilters는 상태를 비우거나 초기값으로 되돌릴 때 의미가 분명합니다.
주의할 부분은 action 하나에 너무 많은 책임을 넣는 경우입니다. 예를 들어 updateFilter라는 action 하나에 검색어 변경, 카테고리 변경, 품절 토글, 초기화까지 모두 넣으면 컴포넌트 코드는 짧아질 수 있습니다. 하지만 action 내부에 조건문이 늘어나면서 결국 store가 읽기 어려워집니다.
처음에는 action을 너무 잘게 나누기보다, 실제 사용자 행동과 상태 변경 규칙을 기준으로 나누면 됩니다. “검색어를 바꾼다”, “품절 필터를 토글한다”, “필터를 초기화한다”처럼 화면에서 일어나는 일이 그대로 이름에 드러나면 적당한 출발점입니다.
action이 많아질 때 확인할 부분

action을 store 안에 모으다 보면 어느 순간 함수가 많아집니다. 이때 바로 복잡한 구조로 넘어가기보다 먼저 확인할 부분이 있습니다. action이 많아진 이유가 상태 자체가 많아서인지, 하나의 store가 여러 기능을 동시에 담당해서인지 나눠서 봐야 합니다.
필터와 모달, 선택된 상품, 장바구니 상태가 모두 하나의 store에 들어 있다면 action이 많아지는 것은 자연스러운 결과입니다. 이 경우에는 action 이름을 더 줄이는 것보다 store의 책임을 나누는 쪽을 고민해야 합니다. 필터는 필터 store로, 장바구니는 장바구니 store로 나누면 action도 기능 단위로 읽힙니다.
반대로 하나의 기능 안에서 action이 많아지는 경우도 있습니다. 필터 기능만 봐도 setKeyword, setCategory, toggleSoldOut, resetFilters처럼 늘어날 수 있습니다. 이때는 action이 많다는 사실보다 각 action이 서로 다른 사용자 행동을 표현하는지 확인하는 것이 우선입니다.
Zustand는 정해진 폴더 구조를 강제하지 않습니다. 이 자유도가 장점이지만, 처음 공부할 때는 기준이 없어서 오히려 헷갈릴 수 있습니다. action을 분리하는 단계에서는 먼저 한 store 안에서 상태와 변경 함수를 함께 관리해보고, 기능이 커질 때 store 파일을 나누는 식으로 넘어가면 흐름이 덜 끊깁니다.
설치부터 확인까지: 전체 실행 프로젝트
Node.js 24와 npm을 준비하고 ZIP을 푼 프로젝트 폴더에서 아래 순서로 실행합니다. 여러 파일을 함께 실행하는 완성형 실습입니다. 뷰어의 파일 경로는 ZIP 프로젝트 루트 기준이며, 짧은 개념 예제와 별개입니다.
npm install
npm run build
npm test
npm run dev
Vite가 출력한 로컬 주소를 여세요. 서버·인증·외부 API 없이 실행하는 브라우저 SPA입니다.
현재 실습 ZIP
README.md
# Zustand filters 실습
Node.js 24 및 npm. 이 폴더에서 `npm install`, `npm run build`, `npm test`, `npm run dev` 순서로 실행합니다. Vite가 표시한 로컬 주소를 여세요.
Zustand 5.0.15 / React 19.3.0 / TypeScript 7.0.2 기준입니다.
독립 브라우저 SPA이며 서버·인증·외부 API는 포함하지 않습니다. 자동 테스트는 Vitest와 jsdom 또는 메모리 저장소를 사용합니다. 브라우저 실제 새로고침·기기 성능 검사는 별도로 수행해야 합니다.
index.html
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Zustand 실습</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
package.json
{
"name": "zustand-filters",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"test": "vitest run"
},
"dependencies": {
"react": "19.3.0",
"react-dom": "19.3.0",
"zustand": "5.0.15"
},
"devDependencies": {
"typescript": "7.0.2",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"vite": "8.3.0",
"vitest": "4.1.11",
"jsdom": "30.0.1"
}
}
src/App.tsx
import { useShallow } from 'zustand/react/shallow';
import { useFilterStore } from './store';
export function BroadSummary() {
const { filters } = useFilterStore();
return <p>전체 구독: {filters.keyword || '없음'}</p>;
}
export function FilterSummary() {
const { keyword, category } = useFilterStore(
useShallow((state) => ({
keyword: state.filters.keyword,
category: state.filters.category,
})),
);
return (
<p>
요약: {keyword || '없음'} / {category}
</p>
);
}
export function SearchInput() {
const keyword = useFilterStore((state) => state.filters.keyword);
const setKeyword = useFilterStore((state) => state.setKeyword);
return (
<label>
검색어{' '}
<input value={keyword} onChange={(event) => setKeyword(event.target.value)} />
</label>
);
}
export function ResetFilterButton() {
const resetFilters = useFilterStore((state) => state.resetFilters);
return <button onClick={resetFilters}>초기화</button>;
}
function Controls() {
const category = useFilterStore((state) => state.filters.category);
const onlySoldOut = useFilterStore((state) => state.filters.onlySoldOut);
const setCategory = useFilterStore((state) => state.setCategory);
const toggleSoldOut = useFilterStore((state) => state.toggleSoldOut);
const toggleTheme = useFilterStore((state) => state.toggleTheme);
const theme = useFilterStore((state) => state.theme);
return (
<section>
<label>
카테고리{' '}
<select value={category} onChange={(event) => setCategory(event.target.value)}>
<option value="all">전체</option>
<option value="outer">아우터</option>
</select>
</label>
<button aria-pressed={onlySoldOut} onClick={toggleSoldOut}>
품절만 보기
</button>
<button onClick={toggleTheme}>테마 변경</button>
<p>테마: {theme}</p>
</section>
);
}
export default function App() {
return (
<main>
<h1>필터 action과 구독 범위</h1>
<SearchInput />
<Controls />
<FilterSummary />
<BroadSummary />
<ResetFilterButton />
</main>
);
}
src/main.tsx
import { createRoot } from 'react-dom/client';
import App from './App';
const root = document.getElementById('root');
if (!root) throw new Error('root 요소가 필요합니다.');
createRoot(root).render(<App />);
src/store.test.tsx
// @vitest-environment jsdom
import { afterEach, expect, test } from 'vitest';
import { act, Profiler } from 'react';
import { createRoot } from 'react-dom/client';
import { useFilterStore } from './store';
import { BroadSummary, FilterSummary, SearchInput } from './App';
(
globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
afterEach(() => useFilterStore.setState(useFilterStore.getInitialState(), true));
test('연속 토글은 최신 값으로 계산하고 다른 필드와 이전 객체를 보존한다', () => {
const api = useFilterStore.getState();
api.setKeyword('coat');
const before = useFilterStore.getState().filters;
api.toggleSoldOut();
api.toggleSoldOut();
api.setCategory('outer');
expect(useFilterStore.getState().filters).toEqual({
keyword: 'coat',
category: 'outer',
onlySoldOut: false,
});
expect(before).toEqual({ keyword: 'coat', category: 'all', onlySoldOut: false });
api.resetFilters();
expect(useFilterStore.getState().filters).toEqual({
keyword: '',
category: 'all',
onlySoldOut: false,
});
expect(useFilterStore.getState().toggleSoldOut).toBe(api.toggleSoldOut);
});
test('부모 고정: 관련 없는 테마 변경은 전체 구독만 갱신한다', async () => {
const host = document.createElement('div');
const root = createRoot(host);
const commits = { broad: 0, narrow: 0, shallow: 0 };
try {
await act(async () =>
root.render(
<>
<Profiler id="broad" onRender={() => commits.broad++}>
<BroadSummary />
</Profiler>
<Profiler id="narrow" onRender={() => commits.narrow++}>
<SearchInput />
</Profiler>
<Profiler id="shallow" onRender={() => commits.shallow++}>
<FilterSummary />
</Profiler>
</>,
),
);
const baseline = { ...commits };
await act(async () => useFilterStore.getState().toggleTheme());
expect(commits.broad).toBeGreaterThan(baseline.broad);
expect(commits.narrow).toBe(baseline.narrow);
expect(commits.shallow).toBe(baseline.shallow);
await act(async () => useFilterStore.getState().setKeyword('coat'));
expect(commits.narrow).toBeGreaterThan(baseline.narrow);
expect(commits.shallow).toBeGreaterThan(baseline.shallow);
expect(host.textContent).toContain('요약: coat / all');
} finally {
await act(async () => root.unmount());
}
});
test('Zustand 5의 매번 새 객체 selector는 snapshot 경고와 업데이트 오류를 재현한다', async () => {
const { vi } = await import('vitest');
const errors: unknown[][] = [];
const log = vi.spyOn(console, 'error').mockImplementation((...args) => {
errors.push(args);
});
const host = document.createElement('div');
const root = createRoot(host);
function Unstable() {
const selection = useFilterStore((state) => ({ keyword: state.filters.keyword }));
return <p>{selection.keyword}</p>;
}
try {
await expect(act(async () => root.render(<Unstable />))).rejects.toThrow(
/Maximum update depth/,
);
expect(errors.flat().join(' ')).toMatch(/getSnapshot.*cached/);
} finally {
await act(async () => root.unmount());
log.mockRestore();
}
});
src/store.ts
import { create } from 'zustand';
type Filters = { keyword: string; category: string; onlySoldOut: boolean };
const initialFilters: Filters = { keyword: '', category: 'all', onlySoldOut: false };
type FilterStore = {
filters: Filters;
theme: 'light' | 'dark';
setKeyword: (keyword: string) => void;
setCategory: (category: string) => void;
toggleSoldOut: () => void;
resetFilters: () => void;
toggleTheme: () => void;
};
export const useFilterStore = create<FilterStore>()((set) => ({
filters: { ...initialFilters },
theme: 'light',
setKeyword: (keyword) => set((state) => ({ filters: { ...state.filters, keyword } })),
setCategory: (category) =>
set((state) => ({ filters: { ...state.filters, category } })),
toggleSoldOut: () =>
set((state) => ({
filters: { ...state.filters, onlySoldOut: !state.filters.onlySoldOut },
})),
resetFilters: () => set({ filters: { ...initialFilters } }),
toggleTheme: () =>
set((state) => ({ theme: state.theme === 'light' ? 'dark' : 'light' })),
}));
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"noEmit": true
},
"include": ["src"]
}
기대 화면과 확인 순서
검색어를 입력하면 요약이 바뀌고 카테고리와 품절 버튼도 반영됩니다. 두 번 토글하면 원래 상태, 초기화하면 빈 검색어/all/false입니다. 테마 변경은 검색어를 바꾸지 않습니다. Profiler 구독 비교는 테스트 결과로 확인합니다.
검증 범위: npm 설치, TypeScript 타입 검사와 Vite production 빌드, Vitest 자동 테스트를 실행했습니다. UI 테스트는 jsdom이며 실제 기기의 레이아웃·성능 측정은 포함하지 않습니다.
정리
Zustand의 action은 상태 관리를 어렵게 만드는 추가 문법이 아닙니다. 컴포넌트에 흩어지기 쉬운 상태 변경 규칙을 store 안에 함수로 모아두는 방식입니다. 컴포넌트가 직접 객체를 복사하고 값을 조립하는 코드가 늘어난다면, 그 시점이 action으로 분리할 만한 지점입니다.
값 하나를 바꾸는 로직은 setKeyword처럼 단순하게 둘 수 있고, 현재 상태를 기준으로 바꾸는 로직은 toggleSoldOut처럼 동작이 드러나게 이름을 붙일 수 있습니다. 여러 값을 한 번에 되돌리는 초기화 로직은 action으로 분리했을 때 특히 효과가 큽니다.
다음에 Zustand 코드를 다시 볼 때는 store 안에 상태만 있는지, 아니면 상태를 바꾸는 규칙까지 함께 정리되어 있는지 확인하면 됩니다. action이 제대로 나뉘면 컴포넌트는 화면 이벤트를 연결하는 역할에 가까워지고, 상태 변경의 기준은 store에서 한 번에 따라갈 수 있습니다.
같이 읽으면 좋은 글
- Zustand 학습 순서: store, action, selector, persist까지
- React 처음 배우는 순서: 컴포넌트부터 state, Zustand까지
- Zustand 리렌더링 문제 해결: 상태 변경 후 화면이 바뀌지 않을 때
공식 근거와 확인 범위
확인일: 2026-09-12. 실행 프로젝트는 Zustand 5.0.15, React 19.3.0, TypeScript 7.0.2로 검사했습니다. 공식 문서의 API 설명과 실제 설치 버전의 동작을 구분해 확인합니다.
연결 학습: action의 입력 계약과 전용 훅
목표·선행 지식: 컴포넌트의 클릭 처리와 실제 상태 변경 계약을 나눕니다.
addTodo는 빈 문자열을 거부하고 removeTodo는 삭제 대상 ID를 받도록 정합니다. 검증을 입력 UI에만 두면 다른 호출 경로에서 잘못된 데이터가 들어갈 수 있습니다. 전용 훅은 컴포넌트가 store의 내부 구조를 덜 알게 만드는 인터페이스입니다.
직접 확인할 과제
UI를 거치지 않고 공백 문자열로 action을 호출해 목록 길이가 늘지 않는지 확인하세요. ID 비교는 ===, 삭제 후 남기는 조건은 !==이며 대입 연산자 =와 구분합니다.
공통 실습 ZIP · 다음 학습 · 라이브러리 선택 가이드
공통 ZIP은 버전을 고정한 학습 예제입니다. UI 파일은 Radix·Sonner·Embla 기반 축약 구현이며 shadcn CLI 생성물과 동일하지 않습니다. 적용 범위와 실행 방법은 ZIP의 README를 확인하세요.
이 글이 도움이 되었나요?
Zustand 학습 순서
필수 14개 · 전체 15개
읽음 기록 관리
전체 과정 목차 (15개)
- 필수 길잡이 · Zustand 학습 로드맵: store·action·selector·persist 순서
- 필수 길잡이 · React state vs Zustand: 전역 상태가 필요한 기준
- 필수 학습 · Zustand란? React 상태 관리 선택 기준과 기본 Store
- 필수 학습 · Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기
- 필수 학습 · Zustand state 사용법: 값 읽기와 변경 흐름 익히기
- 필수 학습 · Zustand action 사용법: 상태 변경 로직을 store로 분리하기 현재 글
- 필수 학습 · Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기
- 필수 학습 · Zustand 리렌더링 원리와 selector 최적화 방법
- 필수 학습 · Zustand persist 사용법: 새로고침 후 상태 저장하기
- 필수 학습 · Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때
- 선택 참고 · Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법
- 필수 선수 · Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수
- 필수 학습 · Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성
- 필수 학습 · Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
- 필수 학습 · Zustand Todo 완성 실습: actions·선택 훅·persist 연결
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.