zustand persist(broswer storage연동)
- 리액트 기준 사용법
- NextJS 기준 사용법
리액트 기준 사용법
기존 zustand의 스토어 정의에서 persist 미들웨어와 옵션을 추가해 준 것이 끝입니다.
공식문서 샘플을 확인 하여 broswer storage를 사용하는데 필요한 최소 코드를 확인해 봅시다
아래 코드를 보시면 옵션 부분 중 name만 명시해도 기본으로 localStorage가 적용되기 때문에 손쉽게 사용가능 합니다.
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
export const useBearStore = create(
persist(
(set, get) => ({
bears: 0,
addABear: () => set({ bears: get().bears + 1 }),
}),
{
name: 'food-storage', // storage key 이름
storage: createJSONStorage(() => localStorage), // 이 옵션을 명시 안하면 기본으로 localStorage를 사용하도록 되어있습니다.
},
),
)
예제 샘플 코드로 사용법을 확인하면 아래처럼 간단하게 localStorage와 연동해서 사용할 수 있습니다.
import reactLogo from './assets/react.svg';
import viteLogo from '/vite.svg';
import './App.css';
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useState } from 'react';
interface BearStore {
bears: string[];
addItem: (item: string) => void;
removeItem: (item: string) => void;
clear: () => void;
}
const useBearStore = create<BearStore>()(
persist(
(set, get) => ({
bears: [],
addItem: (item) => {
const searchSet = new Set(get().bears);
searchSet.add(item);
set({ bears: Array.from(searchSet.values()) });
},
removeItem: (item) =>
set({ bears: get().bears.filter((v) => item !== v) }),
clear: () => set({ bears: [] }),
}),
{
name: 'bear-str-storage', // 스토리지 키 이름
}
)
);
function App() {
const { bears, addItem, removeItem, clear } = useBearStore();
const [state, setState] = useState('');
return (
<>
<div>
<a href='https://vitejs.dev' target='_blank'>
<img src={viteLogo} className='logo' alt='Vite logo' />
</a>
<a href='https://react.dev' target='_blank'>
<img src={reactLogo} className='logo react' alt='React logo' />
</a>
</div>
<h1>Vite + React</h1>
<div className='card'>
<input
type='text'
value={state}
onChange={(e) => setState(e.target.value)}
/>
<button
onClick={() => {
addItem(state);
setState('');
}}
>
add
</button>
<button
onClick={() => {
removeItem(state);
setState('');
}}
>
remove
</button>
<button onClick={() => clear()}>clear</button>
<p>{JSON.stringify(bears)}</p>
</div>
</>
);
}
export default App;
아이템 하나 추가 시 결과
옵션에 설정한 키 이름으로 데이터가 저장되어 있는 것을 확인할 수 있습니다.
아이템 제거 결과
전체 제거
NextJS 기준 사용법
nextjs에서는 hydrate과정을 신경 써줘야 합니다. 리액트에서 사용하듯 사용하면 hydrate 에러가 발생할 수 있습니다.
에러가 발생하는 이유는 서버에서 렌더링 결과와 클라이언트에서 렌더링 결과가 다르기 때문에 발생합니다.
해결방법으로는 다이나믹 임포트를 해도 되지만, zustand 공식문서에서 StoreHook을 이용한 방법이 제시되어 있습니다.
아래 코드의 간단 설명은 useEffect로 컴포넌트가 마운트 될 때 storage에서 데이터를 가져와서 사용하는 것인데 커스텀훅으로 감싸준 것뿐입니다.
아래 코드를 기준으로 제 프로젝트에 적용을 해보겠습니다.
// useStore.ts
import { useState, useEffect } from 'react'
const useStore = <T, F>(
store: (callback: (state: T) => unknown) => unknown,
callback: (state: T) => F,
) => {
const result = store(callback) as F
const [data, setData] = useState<F>()
useEffect(() => {
setData(result)
}, [result])
return data
}
export default useStore
// useBearStore.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
// the store itself does not need any change
export const useBearStore = create(
persist(
(set, get) => ({
bears: 0,
addABear: () => set({ bears: get().bears + 1 }),
}),
{
name: 'food-storage',
},
),
)
// yourComponent.tsx
import useStore from './useStore'
import { useBearStore } from './stores/useBearStore'
const bears = useStore(useBearStore, (state) => state.bears)
프로젝트 적용 후 코드
아래는 최근 검색어를 위해 이용된 코드입니다
//useHydrateStore.ts
import { useState, useEffect, useMemo } from 'react';
const useHydrateStore = <T, F>(
store: (callback: (state: T) => unknown) => unknown,
callback: (state: T) => F
) => {
const result = store(callback) as F;
const [data, setData] = useState<F>();
useEffect(() => {
setData(result);
}, [result]);
return data;
};
export default useHydrateStore;
// recentSearchStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface RecentSearchStore {
recentSearchList: string[];
addItem: (item: string) => void;
removeItem: (item: string) => void;
clear: () => void;
}
export const useRecentSearchStore = create<RecentSearchStore>()(
persist(
(set, get) => ({
recentSearchList: [],
addItem: (item) => {
const keywordSet = new Set([item, ...get().recentSearchList]);
set({ recentSearchList: Array.from(keywordSet.values()) });
},
removeItem: (item) =>
set({
recentSearchList: get().recentSearchList.filter((v) => v !== item),
}),
clear: () => set({ recentSearchList: [] }),
}),
{
name: 'recent-search-list',
}
)
);
export const useRecentSearchStoreState = () =>
useRecentSearchStore((state) => ({
recentSearchList: state.recentSearchList,
}));
export const useRecentSearchStoreAction = () =>
useRecentSearchStore((state) => ({
addItem: state.addItem,
removeItem: state.removeItem,
clear: state.clear,
}));
// Component.tsx
'use client';
import CustomGlobalLoadingLink from '@/components/common/customlink/CustomGlobalLoadingLink/CustomGlobalLoadingLink';
import useHydrateStore from '@/hooks/common/useHydrateStore';
import {
RecentSearchStore,
useRecentSearchStore,
useRecentSearchStoreAction,
} from '@/store/common/recentSearchStore';
export default function GNBRecentSearch() {
const { clear } = useRecentSearchStoreAction();
const recentSearchList = useHydrateStore(
useRecentSearchStore,
(state: RecentSearchStore) => state.recentSearchList
);
return (
<div className='absolute w-full top-full text-sm mt-2'>
<div className='bg-gray-100 border border-gray-300 py-2 px-4 flex items-center justify-between'>
<strong className='font-semibold'>최근 검색어</strong>
<button
className='text-xs text-gray-400'
type='button'
onClick={() => clear()}
>
전체 삭제
</button>
</div>
<div className='max-h-40 overflow-y-scroll z-[9999] py-2 px-4 bg-white '>
<ul className='flex flex-col'>
<li className='flex items-center'>
<CustomGlobalLoadingLink href='/' className='flex-grow'>
test
</CustomGlobalLoadingLink>
<button type='button' className='text-lg'>
×
</button>
</li>
{recentSearchList?.map((v) => (
<li key={v} className='flex items-center'>
<CustomGlobalLoadingLink href='/' className='flex-grow'>
{v}
</CustomGlobalLoadingLink>
<button className='text-lg'>×</button>
</li>
))}
</ul>
</div>
<div className='bg-gray-100 border border-gray-300 py-2 px-4 flex justify-end rounded-b-md'>
<button className='text-xs text-gray-400'>닫기</button>
</div>
</div>
);
}
NestJS Hydration 관련 부분
https://docs.pmnd.rs/zustand/integrations/persisting-store-data#hydration-and-asynchronous-storages