Zustand 상태관리하기

2024-09-24조회수 0
Status
Post thumbnail

# 개요

Zustand는 작고 빠르며 확장 가능한 상태관리 도구입니다. 사용시 작성 코드양도 적어 Redux보다 깔끔하게 코드를 작성할 수 있습니다. React 자체에서 제공하는 Context API를 사용해서 전역상태를 관리 할 수 있지만 성능문제, 미들웨어를 제공하지 않는다.

Context API 성능문제에 대해 얘기를 간단히 하자면 약간 오해를 할 수 있는 부분이 Context API를 사용할때 Provider 내부에서 사용하는 state값이 변경이 되면 해당 state값을 사용하지 않는 모든 컴포넌트가 리랜더링이 일어난다라고 오해를 할 수 있다.

이건 반은 맞고 반은 틀리다.

// countContext.jsx
import { createContext, useCallback, useState } from 'react';

export const CountContext = createContext();

export default function CountProvider({ children }) {
  const [count, setCount] = useState(0);
  const increment = useCallback(() => setCount((c) => c + 1), []);

  return <CountContext.Provider value={{ count, increment }}>{children}</CountContext.Provider>;
}

// 컨텍스트를 사용하는 컴포넌트
function CountComponent() {
  console.log('CountComponent 렌더링');
  const { count, increment } = useContext(CountContext);
  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={increment}>증가</button>
    </div>
  );
}

// 컨텍스트를 사용하지 않는 컴포넌트
function NonContextUser() {
  console.log('NonContextUser 컴포넌트 렌더링');
  return <p>이 컴포넌트는 컨텍스트를 사용하지 않습니다.</p>;
}

// 부모 컴포넌트
function ParentComponent() {
  console.log('ParentComponent 렌더링');

  return (
    <div>
      <CountComponent />
      <NonContextUser />
    </div>
  );
}

function App() {
  return (
    <CountProvider>
      <ParentComponent />
    </CountProvider>
  );
}

// 콘솔로그 출력:
// CountComponent 렌더링

위의 예시코드를 보면 NonContextUser는 유저는 콘솔로그가 안찍히는걸 알 수 있다. 이점에서 알 수 있듯이 사용하지 않는 컴포넌트는 리랜더링이 발생하지 않는다.

다음 코드에서 리랜더링이 일어나는지 코드를 살펴보자.

import { createContext, useCallback, useState } from 'react';

export const MultiContext = createContext();

export default function MultiProvider({ children }) {
  const [count, setCount] = useState(0);
  const [text, setText] = useState('');

  const increment = useCallback(() => setCount((c) => c + 1), []);
  const updateText = useCallback((newText) => {
    setText(newText);
  }, []);
  return (
    <MultiContext.Provider value={{ count, increment, text, updateText }}>
      {children}
    </MultiContext.Provider>
  );
}

// count만 사용
function CountComponent() {
  console.log('CountComponent 렌더링');
  const { count, increment } = useContext(MultiContext);
  return (
    <div>
      <p>카운트: {count}</p>
      <button onClick={increment}>증가</button>
    </div>
  );
}

// text만 사용하는 컴포넌트
function TextComponent() {
  console.log('TextComponent 렌더링');
  const { text, updateText } = useContext(MultiContext);
  return (
    <div>
      <p>텍스트: {text}</p>
      <input value={text} onChange={(e) => updateText(e.target.value)} placeholder="텍스트 입력" />
    </div>
  );
}

// 부모 컴포넌트
function ParentComponent() {
  console.log('ParentComponent 렌더링');

  return (
    <div>
      <CountComponent />
      <TextComponent />
    </div>
  );
}

function App() {
  return (
    <MultiProvider>
      <ParentComponent />
    </MultiProvider>
  );
}

//콘솔출력:
// CountComponent 렌더링
// TextComponent 렌더링

위의 코드에서 알 수 있듯이 하나의 컨텍스트에서 여러상태를 가지고 있으면 value 객체가 새로 생성되면서 사용하지 않는 컴포넌트에서 리랜더링이 발생 하는것을 알 수 있다. 또는 만약 사용하지 않는 컴포넌트에서 const { count } = useContext(CountConext) 이렇게만 사용해도 리랜더링이 발생한다. useMemo를 사용해서 최적화를 해주거나 Context를 분리해서 사용하면 되긴 하지만 코드의 복잡성이 올라간다.

이러한 최적화 및 성능을 위해서 Redux, Recoil, Zustand를 사용하면 별다른 최적화 코드를 사용하지 않아도 된다.

#전역상태 트렌드

Jotai vs Recoil vs Zustand

diff status image

위의 이미지를 확인 해보면 Zustand가 3개중 독보적이다. Redux를 제외하면 Zustand가 가장 많이 사용되고 있다.

#사용법

설치 방법은 넘어가고 바로 사용법을 알아보자.

우선 먼저 상태를 저장 할 store를 만들어줘야 한다. store 내부에는 관리할 상태(state)와 업데이트 할 action 함수를 만들어줘야 한다.

(사용법를 위주로 다루었기 때문에 타입스크립트코드로 나중에 변환 예정)

export const useUserStore = create((set, get) => ({
  user: {
    name: '',
    email: '',
  },
  theme: 'light',

  // action 함수
  updateUser: (newUserData) =>
    set(() => {
      const state = get();
      return { ...state.user, ...newUserData };
    }),
  updateTheme: (newTheme) => set((state) => ({ theme: newTheme })),
}));

위의 store를 잘보면 updateUser를 할때는 get함수를 사용해서 state를 가져왔고, updateTheme을 보면 set의 함수의 첫번째 매개변수로 state가 넘어오기 때문에 그걸 사용했다. 대부분은 set 함수 매개변수 state를 사용하면 될 거 같다. 그리고 얕은 병합이 이루어지기 때문에 업데이트 할 상태만 업데이트 해주면된다. 아래코드를 보면 이해가 쉽다.

    // 굳이 user는 내부적으로 안해도 된다. set 함수내부에서 기본값으로 얕은병합을 해주기 때문이다.
    updateTheme: (newTheme) => set((state) => ({ user: { ...state.user }, theme: newTheme }))

    // 내부적으로 쉽게 말해 아래처럼 한번 진행해준다고 생각하면 편하다.
    {...currentState, ...updateState}

#미들웨어 사용법

Immer를 사용해서 복잡한 상태 쉽게 하기

npm i immer

우선 Immer를 우선 설치를 해줘야 하고 사용시에는 zustand에서 가져오면 된다. immer에서 가져오는게 아니다

import { immer } from 'zustand/middleware/immer';

export const useTodoStore = create(
  immer((set) => ({
    todos: [],

    addTodo: (text) =>
      set((state) => {
        state.todos.push({ id: Date.now(), text, completed: false });
      }),

    toggleTodo: (id) =>
      set((state) => {
        const todo = state.todos.find((item) => item.id === id);
        todo.completed = !todo.completed;
      }),

    updateTodoText: (id, text) =>
      set((state) => {
        const todo = state.todos.find((item) => item.id === id);
        todo.text = text;
      }),
    deleteTodo: (id) =>
      set((state) => {
        const index = state.todos.findIndex((item) => item.id === id);
        state.todos.splice(index, 1);
      }),
  }))
);

immer를 create 내부함수를 감싸주고 사용하면 된다. 내부적으로 immer가 동작하기 때문에 update action 함수 내부에는 불변성을 오히려 지키면 안되고 state를 직접 수정을 하면 된다.

간단히 순서를 말하면 update action 함수가 실행이 되면 set함수가 실행이 되고 제어권이 immer로 넘어가고 내부코드를 실행 후 새로운 불변객체를 생성해서 반환하다. 이후 store를 업데이트한다.

#참고

Zustand 핵심 정리