[React] Zustand
1. Zustand 개요
1.1 Zustand 개념
Zustand는 독일어로 “상태”를 의미하며, React 애플리케이션에서 상태를 효율적으로 관리할 수 있도록 도와주는 상태 관리 라이브러리이다.
1.1 Zustand 특징
- Redux와 같은 flux 아키텍처로 동작한다.
- Flux 아키텍처는 단방향 데이터 흐름을 가지는 애플리케이션 아키텍처 패턴이다.
- 보일러플레이트 코드 감소
- Redux나 React Context API에 비해 보일러플레이트 코드가 적다.
- 보일러플레이트 코드란? 반복적으로 작성해야 하는 기본적인 설정 코드로, 애플리케이션의 핵심 로직과는 직접적으로 관련이 없지만 필수적인 부분이다.
- Provider 불필요
- Jotai는 전역 상태를 관리할 때 Provider 컴포넌트를 필요로 하지 않고, 각 컴포넌트에서 직접 원자(atom)를 사용하여 상태를 접근하고 업데이트할 수 있다.
- React Hook 기반
- React Hook을 사용하여 상태를 관리하여 함수형 컴포넌트에서 상태를 쉽게 사용할 수 있게 해준다.
- useAtom 훅을 통해 원자를 접근하고 상태를 업데이트할 수 있다.
2. 사용하기
2.1 설치
yarn add zustand
2.2 스토어 생성
- zustand를 사용하기 위해 스토어를 생성해야 한다.
- 데이터를 저장하는 store를 사용할 때는
create
라는 메소드를 사용해서 선언한다. - set 함수는 상태를 업데이트하는 데 사용되는 함수이다.
// js로 사용할 때
import { create } from "zustand";
export const projectName = create((set) => ({
state: 0,
setState: (newState) => set({ state: newState }),
}));
// ts로 사용할 때
import { create } from "zustand";
interface ProjectNameState {
state: number;
setState: (newState: number) => void;
}
export const useProjectNameStore = create<ProjectNameState>((set) => ({
state: 0,
setState: (newState) => set({ state: newState }),
}));
2.3 스토어 예제
// src/store/counterStore.jsx
// js로 사용할 때
import create from "zustand";
const useCounterStore = create((set) => ({
count: 0, // 초기값
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
}));
export default useCounterStore;
// ts로 사용할 떄
import { create } from "zustand";
interface CounterState {
count: number;
increase: () => void;
decrease: () => void;
}
const useCounterStore = create<CounterState>((set) => ({
count: 0, // 초기값
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
}));
export default useCounterStore;
set 함수를 호출할 때 상태 업데이트를 객체 형태로 받는 이유가 뭘까?
- 객체 형태의 상태 업데이트란
- 상태를 업데이트할 때 변경하고자 하는 부분만 객체 형태로 전달하여 상태의 특정 속성만 변경하는 방식을 말한다.
- 객체 형태의 상태 업데이트의 장점
- 불변성 유지: 기존 상태 객체를 변경하지 않고 새 객체를 생성하기 때문에, 상태의 불변성을 유지할 수 있다.
- 부분 업데이트: 특정 속성만 변경할 수 있다. 즉, 기존 상태의 다른 속성들을 유지하면서 일부만 변경할 수 있다.
2.4 스토어 사용
스토어를 사용하려면 React 컴포넌트에서 useCounterStore 훅을 호출하여 상태와 상태를 변경하는 함수를 사용한다.
// src/components/Counter.jsx
import useCounterStore from "../store/counterStore";
const Counter = () => {
// 스토어에서 상태와 액션을 가져온다.
const { count, increase, decrease } = useCounterStore();
return (
<div>
<h1>Count: {count}</h1>
<button onClick={increase}>Increase</button>
<button onClick={decrease}>Decrease</button>
</div>
);
};
export default Counter;
3. 미들웨어(Middleware)
3.1 미들웨어 설치
zustand는 필요에 따라 미들웨어를 사용하여 추가 기능을 구현할 수 있다.
yarn add zustand/middleware
3.2 persist
persist 미들웨어는 상태를 로컬 스토리지 또는 세션 스토리지에 저장하고, 페이지를 새로고침해도 상태를 유지할 수 있도록 해준다.
import create from "zustand";
import { persist } from "zustand/middleware";
const useStore = create(
persist(
(set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
}),
{
name: "counter-storage", // 저장할 키 이름
}
)
);
3.3 devtools
devtools 미들웨어를 사용하면 브라우저 개발자 도구에서 상태를 디버깅(Debugging)할 수 있다.
import create from "zustand";
import { devtools } from "zustand/middleware";
const useStore = create(
devtools(
(set) => ({
count: 0,
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
}),
"MyStore"
)
);
3.4 combine
여러 상태 조각을 하나의 스토어로 합치는 데 유용한 미들웨어이다.
import create from "zustand";
import { combine } from "zustand/middleware";
const useStore = create(
combine(
{
count: 0,
user: null,
},
(set) => ({
increase: () => set((state) => ({ count: state.count + 1 })),
decrease: () => set((state) => ({ count: state.count - 1 })),
setUser: (user) => set({ user }),
})
)
);
4. 실습: TodoList 만들기
4.1 useTodosStore.jsx
Zustand를 사용해 스토어를 생성하고, Axios를 사용해 API 요청을 처리한다.
코드 요약
// src/store/useTodosStore.jsx
import axios from "axios";
import { create } from "zustand";
// Axios 인스턴스 생성
const todosAxios = axios.create({
baseURL: process.env.REACT_APP_SERVER_URL,
});
export const useTodosStore = create((set) => ({
// todos 초기 값
todos: [],
loading: false,
// todos 조회
fetchTodos: async () => {
set({ loading: true });
try {
const response = await todosAxios.get("/todos");
set({ todos: response.data });
} catch (error) {
console.error(error);
} finally {
set({ loading: false });
}
},
// todos 추가
addTodos: async (data) => {
try {
const response = await todosAxios.post("/todos", data);
set((state) => ({
todos: [...state.todos, response.data], // 새로운 todo 추가
}));
} catch (error) {
console.error(error);
}
},
// todos 삭제
deleteTodos: async (id) => {
try {
await todosAxios.delete(`/todos/${id}`);
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id), // 삭제된 todo 필터링
}));
} catch (error) {
console.error(error);
}
},
// todos 수정
editTodos: async (id, updatedData) => {
try {
const response = await todosAxios.patch(`/todos/${id}`, updatedData);
set((state) => ({
todos: state.todos.map(
(todo) => (todo.id === id ? response.data : todo) // 수정된 todo 반영
),
}));
} catch (error) {
console.error(error);
}
},
// todos isDone 상태 수정
toggleTodos: async (id) => {
try {
// 1. 현재 상태에서 id와 일치하는 todo 찾기
//_토글은 isDone 값을 반전시키기 때문에, 현재 상태를 먼저 알아야 함
const todoToggle = useTodosStore
.getState()
.todos.find((todo) => todo.id === id);
// 2. 찾은 todo의 isDone 값을 반전시켜서 서버에 PATCH 요청을 보내기
const response = await todosAxios.patch(`/todos/${id}`, {
isDone: !todoToggle.isDone,
});
// 3. 서버 응답으로 상태 업데이트
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, isDone: response.data.isDone } : todo
),
}));
} catch (error) {
console.error(error);
}
},
}));
4.2 Todos.jsx
할 일 추가 폼과 완료된/미완료된 할 일 리스트를 보여준다.
// src/components/todos/Todos.jsx
import styled from "styled-components";
import TodoForm from "./TodoForm";
import TodoList from "./TodoList";
const Todos = () => {
return (
<>
<TodoForm />
<StH1>할 일 미완료</StH1>
<TodoList showCompleted={true} />
<StH1>할 일 완료</StH1>
<TodoList showCompleted={false} />
</>
);
};
export default Todos;
const StH1 = styled.h1`
font-size: 24px;
`;
4.2 TodoForm.jsx
새로운 할 일을 추가할 수 있는 폼이다.
// src/components/todos/TodoForm.jsx
import { useState } from "react";
import { useTodosStore } from "store/useTodosStore";
const TodoForm = () => {
const { addTodos } = useTodosStore();
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const handleSubmit = async (e) => {
e.preventDefault();
if (!title || !content) {
alert("제목과 내용을 모두 입력하세요.");
return;
}
await addTodos({ title, content, isDone: false });
alert("추가 완료");
setTitle("");
setContent("");
};
return (
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="title">제목: </label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="제목을 입력하세요."
required
/>
</div>
<div>
<label htmlFor="content">내용: </label>
<input
id="content"
type="text"
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="내용을 입력하세요."
required
/>
</div>
<button type="submit">추가하기</button>
</form>
);
};
export default TodoForm;
할 일 목록을 표시하고, 수정, 삭제, 완료 상태 토글 기능을 제공한다.
4.2 TodoList.jsx
// src/components/todos/TodoList.jsx
import { useEffect, useState } from "react";
import { useTodosStore } from "store/useTodosStore";
const TodoList = ({ showCompleted }) => {
const { todos, loading, fetchTodos, deleteTodos, editTodos, toggleTodos } =
useTodosStore();
const [edit, setEdit] = useState(null);
useEffect(() => {
fetchTodos();
}, [fetchTodos]);
if (loading) {
return <p>...로딩중</p>;
}
const handleDeleteTodo = (todo) => {
const deleteConfirm = window.confirm("정말 삭제하시겠습니까?");
if (deleteConfirm) {
deleteTodos(todo);
alert("삭제가 완료되었습니다.");
}
};
const handleEditMode = (todo) => {
setEdit(todo);
};
const handleDoneEdit = (todoId) => {
editTodos(todoId, { title: edit.title, content: edit.content });
alert("수정이 완료되었습니다.");
setEdit(null);
};
const handleToggleTodo = (todo) => {
toggleTodos(todo.id, !todo.isDone);
};
const filteredTodos = todos.filter((todo) => todo.isDone === showCompleted);
return (
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id}>
{edit && todo.id === edit.id ? (
<>
<input
value={edit.title}
onChange={(e) =>
setEdit((prev) => ({ ...prev, title: e.target.value }))
}
/>
<input
value={edit.content}
onChange={(e) =>
setEdit((prev) => ({ ...prev, content: e.target.value }))
}
/>
<button onClick={() => setEdit(null)}>취소</button>
<button onClick={() => handleDoneEdit(todo.id)}>완료</button>
</>
) : (
<>
<p>제목: {todo.title}</p>
<p>내용: {todo.content}</p>
<p>완료 상태: {todo.isDone ? "완료됨" : "미완료"}</p>
<button onClick={() => handleToggleTodo(todo)}>
{todo.isDone ? "취소" : "완료"}
</button>
<button onClick={() => handleDeleteTodo(todo.id)}>삭제</button>
<button onClick={() => handleEditMode(todo)}>수정</button>
</>
)}
</li>
))}
</ul>
);
};
export default TodoList;
댓글남기기