Zustand Simple Guide

Zustand Store 를 설계할 때 최적화 규칙들을 정리한다.

먼저 Store 자체를 구독하지 않도록 설계한다.

Store 값들 중 하나라도 업데이트가 되면, 사용처를 리렌더링시킨다.

const { addTodo, deleteTodo } = useTodoStore(); // X

const addTodo = useTodoStore((store) => store.addTodo); // O
const deleteTodo = useTodoStore((store) => store.deleteTodo); // O

// Selector: (store) => store.addTodo

addTodo 와 deleteTodo 를 두 줄로 작성하는게 번거로우면 아래와 같이 액션 객체로 감싸서 사용하면 된다.

interface Store {
  todos: Todo[];
  actions: Actions;
}

interface Actions {
  addTodo: (content: string) => void;
  deleteTodo: (id: string) => void;
}

interface Todo {
  id: string;
  content: string;
}

export const useTodoStore = create<Store>((set, get) => ({
  todos: [],
  actions: {
    addTodo: (content: string) => {
      const todos = get().todos;
      const newTodo = { id: crypto.randomUUID(), content };

      set({ todos: [...todos, newTodo] });
    },
    deleteTodo: (id: string) => {
      const todos = get().todos;

      set({ todos: todos.filter((todo) => todo.id !== id) });
    },
  },
}));


// Usage
const { addTodo, deleteTodo } = useTodoStore((store) => store.actions);

actions 객체로 감싼 덕분에

  • addTodo, deleteTodo 사용처에서 todos 업데이트로 인한 리렌더링이 발생하지 않으면서
  • 사용 방식이 좀 더 간결해졌다.

만약 액션의 사용처가 많아졌고 Store 의 액션 이름을 변경해야한다면, 사용처를 돌아다니며 store.addTodo 를 바꿔야하니까 조금 번거로워진다.

export const useTodos = () => {
  const todos = useTodoStore((state) => state.todos);

  return todos;
};

export const useAddTodo = () => {
  const addTodo = useTodoStore((state) => state.actions.addTodo);

  return addTodo;
};

export const useDeleteTodo = () => {
  const deleteTodo = useTodoStore((state) => state.actions.deleteTodo);

  return deleteTodo;
};

// Usage
const addTodo = useAddTodo();

상태와 액션을 별도 훅으로 관리하면

액션의 변경을 Store 내부, 하나로 최소화 시킬 수 있다.

export const useTodoStore = create<Store>((set, get) => ({
  todos: [],
  actions: {
    addTodo: (content: string) => {
      const todos = get().todos;
      const newTodo = { id: crypto.randomUUID(), content };

      set({ todos: [...todos, newTodo] });
    },
    deleteTodo: (id: string) => {
      const todos = get().todos;

      set({ todos: todos.filter((todo) => todo.id !== id) });
    },
  },
}));

addTodo 액션의 set() 메서드를 살펴보면

todos 상태만 업데이트하고 있는데, Zustand 내부에서는 Partial 하게 인수로 전달한 속성만을 업데이트하고 나머지 상태는 유지하도록 최적화한다.

addTodo 액션은 get() 으로 현재 상태를 가져오고 set() 으로 상태를 업데이트하고 있는데. 간결하게 set() 의 함수형 업데이트를 사용해볼 수 있다.

addTodo: (content: string) => {
  set((state) => {
    const newTodo = { id: crypto.randomUUID(), content };

    return {
      todos: [...state.todos, newTodo],
    };
  });
}

함수 인자로 현재 상태를 가져오므로 좀 더 간결하다.

미들웨어를 활용하면 스토어 설계를 좀 더 최적화해볼 수 있다.

combine 미들웨어는 상태의 타입을 추론해주므로 Store 타입 선언이 불필요해진다.

const INIT_STATE: { todos: Todo[] } = {
  todos: [],
};

const useTodoStore = create(
  combine(INIT_STATE, (set) => ({
    actions: {
      addTodo: (content: string) => {
        set((state) => {
          const newTodo = { id: crypto.randomUUID(), content };

          return {
            todos: [...state.todos, newTodo],
          };
        });
      },
      ...
    },
  }))
);

set() 메서드를 살펴보면, todos 상태의 불변성을 위해 state.todos를 Spread로 복사해 주고 있는데.

지금은 괜찮지만 객체가 중첩되면 가독성을 해칠 수 있다.

immer 미들웨어로 불변성을 지키지 않는 코드로 가독성을 개선해 볼 수 있다. (실제로는 immer 가 불변하도록 처리한다.)

const useTodoStore = create(
  immer(
    combine(INIT_STATE, (set) => ({
      actions: {
        addTodo: (content: string) => {
          set((state) => {
            const newTodo = { id: crypto.randomUUID(), content };

            state.todos.push(newTodo);
          });
        },
        ...
      },
    }))
  )
);

불변성을 지키지 않는 방식으로 코드를 작성할 수 있다.

subscribeWithSelector는 상태를 구독할 수 있는 미들웨어인데. 상태 변경의 부수효과를 정의할 때 유용하다.

const useTodoStore = create(
  subscribeWithSelector(
    combine(INIT_STATE, (set) => ({
      actions: {
        addTodo: (content: string) => {
          set((state) => {
            const newTodo = { id: crypto.randomUUID(), content };

            return {
              todos: [...state.todos, newTodo],
            };
          });
        },
        // ...
      },
    }))
  )
);

// Usage
useTodoStore.subscribe(
  (state) => state.todos,
  (todos, prevTodos) => {
    console.log({ todos, prevTodos });
  }
);

useTodoStore.subscribe() 메서드로 어떤 상태를 구독할지 정의할 수 있고, 변경 전과 후의 값을 꺼내올 수 있다.

useTodoStore.subscribe(
  (state) => state.todos,
  (todos, prevTodos) => {
    const count = useCounterStore.getState().count;
    console.log({ count });

    useCounterStore.setState({ count: count + 1 });

    const newCount = useCounterStore.getState().count;
    console.log({ newCount });
  }
);

또 상태를 업데이트하는 것도 가능하다. 지금은 todos를 업데이트하면 무한 루프가 발생하기 때문에 Count Store를 사용했다.

보통 인증 여부를 위한 상태를 클라이언트 스토어를 활용하는데. 인증 성공, 실패에 따라 특정 페이지로 보내는 부수효과를 정의할 때 유용하다.

persist라는 미들웨어는 스토어에 브라우저의 로컬 스토리지, 세션 스토리지처럼 영속성을 부여하기 위해 사용된다.

사용법은 간단하다.

const useTodoStore = create(
  persist(
    combine(INIT_STATE, (set) => ({
      actions: {
        addTodo: (content: string) => {
          set((state) => {
            const newTodo = { id: crypto.randomUUID(), content };

            return {
              todos: [...state.todos, newTodo],
            };
          });
        },
       // ...
      },
    })),
    {
      name: "todo-store",
      storage: createJSONStorage(() => localStorage),
    }
  )
);

두 번째 인자로 스토리지의 이름을 부여해야 한다. 지금은 localStorage를 활용했는데. sessionStorage 도 활용이 가능하다.

실제로 브라우저 도구를 확인해 보면, actions 객체에는 아무것도 담기지 않는 것을 볼 수 있는데. 이것은 함수의 특성 때문이다.

함수는 JSON으로 파싱이 되지 않는다. 여러 이유가 있는데.

실행 컨텍스트에 대한 정보, 스코프 체인, 클로저 등 여러 정보들이 있어서 객체로 표현이 되지 않는 것이다.

로컬 스토리지로서 스토어에 영속성을 부여했기 때문에, 새로고침을 해도 최신 todos 가 사라지지 않는다. (세션 스토리지도 탭이 동일하므로 새로고침하면 동일한 이슈가 발생한다.)

하지만 새로고침 이후 액션은 동작하지 않는데. 이는 actions 객체가 비어있는 채로 스토어로 전이되었기 때문이다.

{
  name: "todo-store",
  partialize: (state) => ({
    todos: state.todos,
  }),
  storage: createJSONStorage(() => sessionStorage),
}

위처럼 partialize 옵션으로 상태만 담기도록 수정해 주면 해결이 된다.