스토어 가이드
dictionary-app은 zustand로 전역 상태를 관리한다. hooks/stores(공통) 또는 페이지 디렉토리 하위
stores(페이지 전용)에 상태 모양별로 몇 가지 패턴을 반복해서 쓴다.
- 훅 이름은
use로 시작하고Store접미사를 붙인다(useSpinnerStore,useSideMenuStore). - 상태 타입(
XxxStoreType)에 값과 액션(setter 함수)을 함께 정의하고,create<XxxStoreType>((set, get) => ({...}))로 만든다. - 액션 이름은 하는 일이 드러나는 동사로 짓는다(
setSessionUser,removeSessionUser,increaseSpinner). - 특정 페이지에서만 쓰는 상태는 공통
hooks/stores가 아니라 해당 페이지 디렉토리 하위stores에 동일한 형식으로 둔다.
카운터형 상태 — useSpinnerStore
Section titled “카운터형 상태 — useSpinnerStore”여러 요청이 동시에 진행될 수 있어 boolean이 아니라 카운터로 관리한다. 카운터가 0보다 크면 스피너를
표시한다.
import {create} from 'zustand'
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”import {create} from 'zustand'
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”openDialog가 만든Promise의resolve를 상태에 들고 있다가,closeDialog가 호출될 때 그 값으로 resolve한다 — 호출부는await openDialog(...)처럼 사용자의 선택을 기다릴 수 있다.hooks/stores/useDialogStore.ts import {create} from 'zustand'export type DialogOptions = {title: stringdescription?: stringconfirmText?: stringcancelText?: stringconfirmColorPalette?: stringshowCancel?: boolean}type DialogStoreType = {isOpen: booleanoptions: DialogOptions | nullresolve: ((confirmed: boolean) => void) | nullopenDialog: (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})},}))- 스토어를 직접 쓰지 않고
confirmDialog/alertDialog(@/utils) 전역 함수를 통해 호출한다 — 자세한 소비 방법과 실사용 예는 alert/confirm 사용 예제 참고.
영속화(persist) + 서버 상태 동기화형 상태 — useSessionStore
Section titled “영속화(persist) + 서버 상태 동기화형 상태 — useSessionStore”zustand/middleware의persist로localStorage에 저장해, 새로고침해도 로그인 상태가 유지되게 한다. 복원(rehydrate) 시점에 만료 여부를 확인해서 지난 세션이면 지운다.hooks/stores/useSessionStore.ts 4 collapsed linesimport {create} from 'zustand'import {persist} from 'zustand/middleware'import type {components} from '@/types'type SessionUser = components['schemas']['SessionUser']type SessionStoreType = {sessionUser: SessionUser | nullexpiresAt: number | null// 게스트 안내 다이얼로그를 이번 로그인에서 이미 보여줬는지 여부. 로그아웃하면// false로 초기화돼 다음 로그인 때 다시 1회 노출된다.guestAlertShown: booleansetSessionUser: (sessionUser: SessionUser) => voidsetExpiresAt: (expiresAt: number) => voidsetGuestAlertShown: (guestAlertShown: boolean) => voidremoveSessionUser: () => void}export const useSessionStore = create<SessionStoreType>()(persist((set) => ({sessionUser: null,expiresAt: null,guestAlertShown: false,setSessionUser: (sessionUser) => set({sessionUser}),setExpiresAt: (expiresAt) => set({expiresAt}),setGuestAlertShown: (guestAlertShown) => set({guestAlertShown}),removeSessionUser: () =>set({sessionUser: null, expiresAt: null, guestAlertShown: false}),}),{name: 'SESSION_USER',onRehydrateStorage: () => (state) => {const expiresAt = state?.expiresAtif (expiresAt !== undefined && expiresAt !== null && Date.now() >= expiresAt * 1000) {state?.removeSessionUser()}},},),)- 사용 예: 인증 가드 — 서버에 세션 유효성을 재확인(
useSessionFindQuery)하고, 성공하면 스토어를 최신화하고 401이면 비운다. 401이 아닌 에러(네트워크 순단, 백엔드 콜드스타트 등)는 “로그인되지 않음”으로 단정하지 않고,localStorage에 남은 세션의 만료 시각(expiresAt)이 아직 지나지 않았으면 그 값을 폴백으로 신뢰한다.routes/RequireAuth.tsx 5 collapsed linesimport {useEffect} from 'react'import {Navigate, Outlet} from 'react-router'import {useSessionFindQuery, useSessionStore} from '@/hooks'import {PageLoader} from '@/components'export const RequireAuth = () => {const {sessionUser: persistedSessionUser,expiresAt: persistedExpiresAt,setSessionUser,removeSessionUser,} = useSessionStore()const {sessionUser, isSessionChecking, isSessionExpired} = useSessionFindQuery()const isPersistedSessionValid =persistedSessionUser !== null &&persistedExpiresAt !== null &&Date.now() < persistedExpiresAt * 1000useEffect(() => {if (sessionUser !== undefined) {setSessionUser(sessionUser)}}, [sessionUser, setSessionUser])useEffect(() => {if (isSessionExpired || (sessionUser === undefined && !isPersistedSessionValid)) {removeSessionUser()}}, [isSessionExpired, sessionUser, isPersistedSessionValid, removeSessionUser])if (isSessionChecking) {return <PageLoader />}if (isSessionExpired) {return <Navigate to="/login" replace />}if (sessionUser === undefined && !isPersistedSessionValid) {return <Navigate to="/login" replace />}return <Outlet />} - 사용 예: 로그아웃 — 스토어를 비우는 것과 함께 react-query 캐시(
queryClient.clear())도 초기화해서, 재로그인(특히 게스트 로그인) 시 이전 사용자의 캐시된 조회 결과가 잠깐 되살아나지 않게 한다.layout/header/Header.tsx const handleLogout = async () => {try {await logout()} finally {removeSessionUser()queryClient.clear()navigate('/login', {replace: true})}}