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을 묶는다.
}))
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>
</>
)
}
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 })
// 미들웨어 없이
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 }))
}
)
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);
}
}
- 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
const listener = (newState, oldState) => {}
const unsubscribe = useCountStore.subscribe(listener)
unsubscribe() // 구독 해제
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