Zustand 사용법

이수빈·2024년 7월 9일
1

Next.js

목록 보기
14/15
post-thumbnail

기본 사용법

  • store 생성, zustand는 단일 store을 가진다.

  • store에서는 상태와 action이 존재한다, action은 객체로 묶어서 사용 가능하다.

import { create } from 'zustand'

interface State {
  count: number
  double: number
  min: number
  max: number
}
interface Actions {
  actions: {
    increase: () => void
    decrease: () => void
    resetState: () => void
  }
}

const initialState: State = {
  count: 1,
  double: 2,
  min: 0,
  max: 99
}
export const useCountStore = create<State & Actions>(set => ({
  ...initialState,
  actions: {
    increase: () => set(state => ({ count: state.count + 1 })),
    decrease: () => set(state => ({ count: state.count - 1 })),
    resetState: () => set(initialState)
  } // action을 묶는다. 
}))
  • 컴포넌트에서 사용할 때 => store에서 상태와 action을 꺼내서 사용한다.
import { useCountStore } from './store/count'

export default function App() {
  const count = useCountStore(state => state.count)
  const { increase, decrease } = useCountStore(state => state.actions)
  return (
    <>
      <h2>{count}</h2>
      <button onClick={increase}>+1</button>
      <button onClick={decrease}>-1</button>
    </>
  )
}

Redux와의 차이점?

  • store, action 기반인 redux와 매우 유사하다. 단일스토어기준, 불변성을 지키는 원리.

  • redux는 store을 만들고 이를 context로 감싸야하지만, zustand는 그렇지 않다.

//zustand
const countReducer = (state: State, action: Action) => {
  switch (action.type) {
    case 'increment':
      return { count: state.count + action.qty }
    case 'decrement':
      return { count: state.count - action.qty }
    default:
      return state
  }
}

const useCountStore = create<State & Actions>((set) => ({
  count: 0,
  dispatch: (action: Action) => set((state) => countReducer(state, action)),
}))
  • zustand는 useCountStore 자체를 가져가서 사용

  • redux-toolit, redux는 slice나 reducer를 만든 후 configStore이나, createStore로 context를 만들어야함.

import { createSlice, configureStore } from '@reduxjs/toolkit'

const countSlice = createSlice({
  name: 'count',
  initialState: { value: 0 },
  reducers: {
    incremented: (state, qty: number) => {
      // Redux Toolkit does not mutate the state, it uses the Immer library
      // behind scenes, allowing us to have something called "draft state".
      state.value += qty
    },
    decremented: (state, qty: number) => {
      state.value -= qty
    },
  },
})

const countStore = configureStore({ reducer: countSlice.reducer })

Jotai와의 차이점

  • zustand는 단일 store이지만 jotai는 여러개의 atom을 각각 사용하는 구조이다.

middleware

  • 미들웨어 중첩형태는 다음과 같다.
// 미들웨어 없이
create(콜백)

// 단일 미들웨어
import { 미들웨어 } from '미들웨어'
create(
  미들웨어(콜백)
)

// 다중 미들웨어
import { 미들웨어A } from '미들웨어A'
import { 미들웨어B } from '미들웨어B'
import { 미들웨어C } from '미들웨어C'
create(
  미들웨어A(
    미들웨어B(
      미들웨어C(콜백)
    )
  )
)

// 타입을 사용하는 경우
create(
  미들웨어A(
    미들웨어B(
      미들웨어C<타입>(콜백)
    )
  )
)
  • 여러 미들웨어를 중첩시켜 아래와 같이 코드를 작성 할 수 있다.
import { create } from 'zustand'
import { combine, subscribeWithSelector } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

const initialState = {
  count: 1,
  double: 2
}
export const useCountStore = create(
  subscribeWithSelector(
    immer(
      combine(
        initialState,
        set => ({
          actions: {
            increase: () => set(state => { state.count += 1 }),
            decrease: () => set(state => { state.count -= 1 })
          }
        })
      )
    )
  )
)

// double getter
useCountStore.subscribe(
  state => state.count, // Selector
  count => { // Listener
    useCountStore.setState(() => ({ double: count * 2 }))
  }
)

combine middleware

  • type추론을 도와주는 middleware create에서 래핑하는 형태로 사용한다.

immer middleware

  • immer는 mutable하게 변경하는 객체 built-in method를 사용하더라도 immutable하게 데이터를 반환해주는 기능을 함.

  • immer가 어떻게 동작하는지 대강 흐름을 정리하면 다음과 같다.

  • 처음에는 변경된 객체에 대한 복사본을 만든 후 => 이걸 다시 깊은복사를해서 return하나?라고 생각했는데 아니였다.

export class Immer {
  produce: (base, recipe) => { 
    let result;

    const scope = enterScope(this);
    const proxy = createProxy(this, base, undefined);
    
    result = recipe(proxy);

    return processResult(result, scope);     
  }
}
  • immer함수의 핵심 로직인 produce함수이다. => produce함수는 다음과 같이 동작한다.
  • produce 함수는 기존 객체(base)와 객체를 어떻게 변경시킬지 결정하는 함수(recipe)를 인자로 받는다.
  • scope를 생성한다.
  • proxy를 생성한다.
  • proxy를 이용해서 recipe를 실행시킨다.
  • processResult를 이용해서 업데이트 된 최종 객체를 리턴한다.
  • 객체의 변경을 처리하는 방식의 핵심은 proxy에 있다. 먼저 초기 State를 proxy로 만든다.

  • proxy객체는 base와 copy두 객체를 내부적으로 관리하며 base는 original data, copy는 updated data로써 관리한다.

  • Proxy의 set에서는 Proxy객체 내부에서 관리하고 있는 modified flag를 보고 변경 여부를 관리하며 base객체가 아닌 copy_객체를 업데이트한다.

  • immer에서 Proxy의 set, get을 활용해서 recipe 로직을 모두 수행하고나면 Proxy객체의 정보를 이용해서 변경된 객체는 업데이트 된 객체(copy)를 사용하고 변경되지 않은 객체는 기존 객체(base)를 사용함으로써 structuring share를 사용하여 새로운 객체를 만들어서 리턴한다.

  • 좀 더 정확한 내용은 아래 블로그를 참고하자.

동작원리 : https://hmos.dev/deep-dive-to-immer

subscribeWithSelector middleware

  • 스토어 훅에서 subscribe 함수를 사용하면 => 스토어의 모든 상태변경을 감지해 리스너를 호출한다.
const listener = (newState, oldState) => {}
const unsubscribe = useCountStore.subscribe(listener)
unsubscribe() // 구독 해제
  • 특정상태만 구독하려면 subscribeWithSelector hook을 래핑해서 사용한다.

번외 type 만족(satisfiy)

  • 다음과 같은 타입이 있다고 가정
interface User {
  name: string
  age: number
}
  • 타입을 확정하는 방법은 아래와 같다.

  • as User로 타입 assertion을 한다면, 물론 에러는 없겠지만 위험한 방식이다(실제로 타입을 만족하지 않았기 때문)

  • 타입선언을 하는게 더 안전한게 맞다. => 하지만 특정상황에서는 단언을 해야 하는 경우도 있는데,

  • 이때 satisfies를 사용하면 유용하다. 이는 해당 타입을 만족하는지 알려준다. 해당 타입을 만족한다면, 그 타입으로 단언하는게
    안전한 코드이다.

// Pass.. 안전하지 않은 타입 '단언'..
const userA = { 
  name: 'Neo' 
} as User

// Error! 안전한 타입 '선언'!
const userB: User = {
  name: 'Neo' 
}

// Error! 안전한 타입 '만족'!
const userC = { 
  name: 'Neo' 
} satisfies User

// Error! 안전한 내부 객체 타입 '만족'! 후 '단언'
const userF = { 
  info: { 
    name: 'Neo', 
    age: 85, 
    isValid: true 
  } satisfies User as User,
  photo: null 
}

ref) https://zustand-demo.pmnd.rs/
https://www.heropy.dev/p/n74Tgc

profile
응애 나 애기 개발자

0개의 댓글