Skip to content

스토어 가이드

dictionary-app은 zustand로 전역 상태를 관리한다. hooks/stores(공통) 또는 페이지 디렉토리 하위 stores(페이지 전용)에 상태 모양별로 몇 가지 패턴을 반복해서 쓴다.

  1. 훅 이름은 use로 시작하고 Store 접미사를 붙인다(useSpinnerStore, useSideMenuStore).
  2. 상태 타입(XxxStoreType)에 값과 액션(setter 함수)을 함께 정의하고, create<XxxStoreType>((set, get) => ({...}))로 만든다.
  3. 액션 이름은 set{Field}/remove{Field}처럼 하는 일이 그대로 드러나는 동사로 짓는다 — setSessionUser, removeSessionUser, increaseSpinner처럼.
  4. 특정 페이지에서만 쓰는 상태는 공통 hooks/stores가 아니라 해당 페이지 디렉토리 하위 stores에 동일한 형식으로 둔다.

여러 요청이 동시에 진행될 수 있어 boolean이 아니라 카운터로 관리한다. 카운터가 0보다 크면 스피너를 표시한다.

hooks/stores/useSpinnerStore.ts
type SpinnerStoreType = {
counter: number
increaseSpinner: () => void
decreaseSpinner: () => void
}
export const useSpinnerStore = create<SpinnerStoreType>((set) => ({
counter: 0,
increaseSpinner: () => {
set((prev) => ({...prev, counter: prev.counter + 1}))
},
decreaseSpinner: () => {
set((prev) => ({...prev, counter: prev.counter > 0 ? prev.counter - 1 : 0}))
},
}))

useApi의 useSpinner 옵션이 요청 시작/종료 시점에 이 카운터를 자동으로 증감시킨다 — 자세한 내용은 백엔드 API 사용 예제 참고.

boolean 토글형 상태 — useSideMenuStore

Section titled “boolean 토글형 상태 — useSideMenuStore”
hooks/stores/useSideMenuStore.ts
type SideMenuStoreType = {
isOpen: boolean
open: () => void
close: () => void
toggle: () => void
}
export const useSideMenuStore = create<SideMenuStoreType>((set) => ({
isOpen: false,
open: () => set({isOpen: true}),
close: () => set({isOpen: false}),
toggle: () => set((prev) => ({isOpen: !prev.isOpen})),
}))

모바일 화면에서 side-menu를 Drawer로 열고 닫는 상태처럼, 컴포넌트 여러 곳(헤더의 토글 버튼, Drawer 자신)이 같은 열림 상태를 공유해야 할 때 쓴다.

Promise를 반환하는 요청-응답형 상태 — useDialogStore

Section titled “Promise를 반환하는 요청-응답형 상태 — useDialogStore”
  1. openDialog가 만든 Promise의 resolve를 상태에 들고 있다가, closeDialog가 호출될 때 그 값으로 resolve한다 — 호출부는 await openDialog(...)처럼 사용자의 선택을 기다릴 수 있다.
    hooks/stores/useDialogStore.ts
    type DialogStoreType = {
    isOpen: boolean
    options: DialogOptions | null
    resolve: ((confirmed: boolean) => void) | null
    openDialog: (options: DialogOptions) => Promise<boolean>
    closeDialog: (confirmed: boolean) => void
    }
    export const useDialogStore = create<DialogStoreType>((set, get) => ({
    isOpen: false,
    options: null,
    resolve: null,
    openDialog: (options) => {
    return new Promise<boolean>((resolve) => {
    set({isOpen: true, options, resolve})
    })
    },
    closeDialog: (confirmed) => {
    get().resolve?.(confirmed)
    set({isOpen: false, options: null, resolve: null})
    },
    }))
  2. 스토어를 직접 쓰지 않고 confirmDialog/alertDialog(@/utils) 전역 함수를 통해 호출한다 — 자세한 소비 방법과 실사용 예는 alert/confirm 사용 예제 참고.

영속화(persist) + 서버 상태 동기화형 상태 — useSessionStore

Section titled “영속화(persist) + 서버 상태 동기화형 상태 — useSessionStore”
  1. zustand/middleware의 persist로 localStorage에 저장해, 새로고침해도 로그인 상태가 유지되게 한다. 복원(rehydrate) 시점에 만료 여부를 확인해서 지난 세션이면 지운다.
    hooks/stores/useSessionStore.ts
    type SessionStoreType = {
    sessionUser: SessionUser | null
    expiresAt: number | null
    setSessionUser: (sessionUser: SessionUser) => void
    setExpiresAt: (expiresAt: number) => void
    removeSessionUser: () => void
    }
    export const useSessionStore = create<SessionStoreType>()(
    persist(
    (set) => ({
    sessionUser: null,
    expiresAt: null,
    setSessionUser: (sessionUser) => set({sessionUser}),
    setExpiresAt: (expiresAt) => set({expiresAt}),
    removeSessionUser: () => set({sessionUser: null, expiresAt: null}),
    }),
    {
    name: 'SESSION_USER',
    onRehydrateStorage: () => (state) => {
    const expiresAt = state?.expiresAt
    if (expiresAt != null && Date.now() >= expiresAt * 1000) {
    state?.removeSessionUser()
    }
    },
    },
    ),
    )
  2. 사용 예: 인증 가드 — 서버에 세션 유효성을 재확인(useSessionFindQuery)하고, 성공하면 스토어를 최신화하고 401이면 비운다.
    routes/RequireAuth.tsx
    export const RequireAuth = () => {
    const {sessionUser: persistedSessionUser, setSessionUser, removeSessionUser} =
    useSessionStore()
    const {sessionUser, isSessionChecking, isSessionExpired} = useSessionFindQuery()
    useEffect(() => {
    if (sessionUser !== undefined) {
    setSessionUser(sessionUser)
    }
    }, [sessionUser, setSessionUser])
    useEffect(() => {
    if (isSessionExpired) {
    removeSessionUser()
    }
    }, [isSessionExpired, removeSessionUser])
    if (isSessionChecking) {
    return <PageLoader />
    }
    if (isSessionExpired) {
    return <Navigate to="/login" replace />
    }
    return <Outlet />
    }
  3. 사용 예: 로그아웃 — 스토어를 비우는 것과 함께 react-query 캐시(queryClient.clear())도 초기화해서, 재로그인(특히 게스트 로그인) 시 이전 사용자의 캐시된 조회 결과가 잠깐 되살아나지 않게 한다.
    layout/header/Header.tsx
    const handleLogout = async () => {
    try {
    await logout()
    } finally {
    removeSessionUser()
    queryClient.clear()
    navigate('/login', {replace: true})
    }
    }