준영
사용 사례 시나리오 1: Zustand
Zustand는 리액트의 모듈 상태를 생성하도록 설계된 작은 라이브러리이다.
상태 객체를 수정할 수 없고, 항상 새로 만들어야 하는 불변 갱신 모델을 기반으로 한다.
렌더링 최적화는 선택자를 통해 수동으로 진행한다.
추가로 간단하면서도 강력한 store 생성자 인터페이스가 존재한다.
모듈 상태와 불변 상태 이해하기
Zustand는 불변 상태 모델을 기반으로 한다.
상태 변경을 위해선 새로운 객체를 만들어 새롭게 상태에 저장해야 한다.
상태 객체의 참조에 대한 동등성만 확인하면 변경 여부를 알 수 있어 객체 값 전체를 확인할 필요가 없다는 장점이 있다.
✅ 메모리 주소가 변경되었을때, 참조하고 있는 전역 상태의 속성에 대해서만 동등한지 비교하면 된다는 의미같다.
따라서 객체 내의 속성을 직접 변경할 경우, 라이브러리는 변경사항을 감지할 수 없다.
create
함수를 통해 store를 생성할 수 있다.
store는 getState
, setState
, subscribe
와 같은 기능들을 갖는다.
setState
를 통해 상태를 갱신하며, 반드시 새로운 객체를 활용해야 한다.
리액트의 setState
와 마찬가지로 함수를 통해 갱신하는 것도 가능하다.
여기에 추가적으로 Zustand의 setState
는 내부적으로 Object.assign으로 구현되어 새 상태와 이전 상태를 병합한다.
export const store = create(() => {
count: 0,
text: "hello",
});
store.setState({
count: 2,
});
console.log(store.getState); // { count: 2, text: "hello" }
// setState 내부 구현엔 아래 내용이 있다.
Object.assign({}, oldState, newState);
store의 subscribe
함수로 store의 상태가 변경되었을때 호출될 콜백을 등록할 수 있다.
리액트 훅을 이용한 리렌더링 최적화
store 전체를 가져다 사용할 경우, 사용하지 않는 속성이 바뀔 때에도 리렌더링이 일어나기 때문에 최적화가 필요하다.
따라서 선택자 함수를 활용하여 속성을 지정하고, 해당 속성이 변경될 때만 리렌더링이 일어나도록 할 수 있다.
이렇게 선택자 기반 리렌더링 제어를 수동 렌더링 최적화
라고 한다.
선택자 함수가 반환한 결과를 비교하여 갱신이 일어났는지 감지하는 것이다.
const Component = () => {
const count = useStore((state) => state.count); // count가 변경될 때만 리렌더링
return <div>count: {count}</div>;
};
하지만 선택자 함수로 store에서 객체 형태로 상태를 가져와 사용할 경우, 객체 참조가 바뀌지 않아 값을 업데이트했지만 리렌더링이 일어나지 않는 경우를 조심해야 한다.
읽기 상태와 갱신 상태 사용하기
상태를 갱신하는 함수를 store 내부에 미리 인라인 함수로 정의하여 재사용성과 가독성을 높일 수 있다.
type StoreState = {
count: number;
inc: () => void;
};
const useStore = create<StoreState>((set) => ({
count: 0,
inc: () => set((prev) => ({ count: prev.count + 1 })),
}));
파생 상태 또한 미리 store에 정의하면 컴포넌트가 값을 사용할 때 불필요한 계산을 피할 수 있다.
이 접근 방식과 라이브러리의 장단점
- 읽기 상태: 리렌더링 최적화를 위한 선택자 함수 사용
- 쓰기 상태: 불변 상태 모델 기반
핵심은 리액트가 최적화를 위해 객체 불변성을 기반으로 한다는 점!
Zustand의 상태 모델도 리액트의 이러한 규칙과 완전히 일치한다.
따라서 리액트와 동일한 모델을 사용해 라이브러리의 단순성과 번들 크기가 작다는 점에서 큰 이점이 있다.
반면 Zustand의 한계는 선택자를 이용한 수동 렌더링 최적화이다.
객체 참조 동등성에 대해 이해해야 하며, 선택자 코드를 위한 보일러 플레이트가 필요하다.
✅ Zustand는 React에서 모듈 상태를 활용하여 전역 상태를 관리할 수 있도록 도와주는 라이브러리 정도로 해석할 수 있을 것 같다.
기본적으로 리액트의 규칙을 따르고, 전역 상태를 위한 보일러 플레이트 작성을 도와주는 정도,,?
도은
모듈 상태와 불변 상태 이해하기
Zustand
는 상태를 유지하는store
를 만드는 데 사용되는 라이브러리Zustand
는 주로 모듈 상태를 위해 설계- 모듈에서
store
를 정의하고 내보내는 것 가능
- 모듈에서
- 😨 상태 객체 속성을 갱신 X (불변 상태 모델)
- 상태를 변경하기 위해서는 새 객체를 생성 후 대체
- 장점: 객체의 참조에 대한 동등성만으로 변경 여부 확인 (모든 프로퍼티를 고려하는 것이 아니라)
// store.ts
import create froom 'zustand'
export const store = create(() => ({count: 0}))
상태를 변경하기 위해서는 store.setState({count: 2})
와 같이 새로운 객체를 이용해 갱신
함수를 통해 이전 상태를 이용해 상태를 쉽게 변경 가능
store.setState((prev) => ({ count: prev.count + 1 }));
다음과 같은 경우는, 내부적으로 Object.assign()
으로 구현되어, 이전 상태를 병합한다.
export const store = create(() => ({
count: 0,
text: 'hello',
}));
store.setState({
count: 2,
}); // { count: 2, text: 'hello' }
store.subscribe
로 store
의 상태가 변경될 때마다 호출되는 콜백 함수 등록 가능
store.subscribe(() => {
console.log('store state is changed');
});
리액트 훅을 이용한 리렌더링 최적화
- 모든 컴포넌트가 전역 상태를 사용하는 것 X → 리렌더링 최적화 필요
Zustand
의create
함수는 훅으로 사용할 수 있는store
를 생성
읽기 상태와 갱신 상태 사용하기
-
selectCount1
선택자 함수를 미리 만든 후 useStore에 전달해 최적화const selectCount1 = (state) => state.count1; // count1먼 const Counter1 = () => { const count1 = useStore(selectCount1); const inc1 = () => { useStore.setState((prev) => ({ count1: prev.count1 + 1 })); }; return ( <div> count1: {count1} <button onClick={inc1}>+1</button> </div> ); };
-
create
의 첫 번째 인수는store
의 setState` 함수const useStore = create((set) => ({ count1: 0, count2: 0, inc1: () => set((prev) => ({ count1: prev.count1 + 1 })), inc2: () => set((prev) => ({ count1: prev.count2 + 1 })), }));
-
파생 상태에 대한 선택자 함수를 사용 가능
// 합계가 변경될 때만 리렌더링 const selectTotal = (state) => state.count1 + state.count2; const Total = () => { const total = useStore(selectTotal); return ... };
이 접근 방식과 라이브러리의 장단점
Zustand
의 읽기 및 쓰기 상태는 다음과 같다.- 읽기 상태: 리렌더링을 최적화하기 위해 선택자 함수를 사용
- 쓰기 상태: 불변 상태 모델을 기반
useState
도 객체 분변성을 기반으로 최적화- 선택자 함수가 참조적으로 동일한 객체를 반환하면 객체가 변경되지 않은 것으로 간주
Zustand
는 선택자를 이용한 수동 렌더링 최적화