이 포스팅은 인프런의 "한 입 크기로 잘라먹는 실전 프로젝트 - sns편" 강의 내용을 바탕으로 SNS 서비스 구축 간 학습 내용을 정리한 것입니다.
npm create vite@6.5.0 proj_namecreate vite 라는 도구를 통해 vite 번들러를 활용하여 구동되는 React 앱을 생성합니다.
💡 package.json에 vite 버전이 6.3.5로 위 명령어의 6.5.0과 다른 이유는
create vite@6.5.0명령어는 vite 그 자체의 버전이 아니라create vite라는 도구의 버전이기 때문입니다.
tsconfig.app.json: React 앱을 구동하기 위한 TS 옵션 설정 파일(브라우저 환경)
tsconfig.node.json: node.js 환경에서 실행되는 TS 파일을 위한 TS 옵션 설정 파일
tsconfig.json: app, node 옵션 파일을 참조
⚠️ 만약 설정 파일에서 타입 오류가 발생한다면 에디터에서 Shift + cmd + p를 눌러 설정 탭으로 이동한 다음, select typescript 검색 → 에디터/작업영역 둘 중 어느 타입스크립트 버전을 사용할지 선택 (보통은 작업 영역 버전을 사용함)하면 대부분 오류는 해결됩니다.
tsconfig.app.json
// 선언만 하고 사용하지 않는 변수가 있을 때, 에러로 표기하는 옵션
"noUnusedLocals": true,
// 선언만 하고 사용하지 않는 매개변수가 있을 때, 에러로 표기하는 옵션
"noUnusedParameters": true,기본 설정에 아래 2가지 룰을 추가합니다.
// 선언만 하고 사용하지 않는 변수가 있을 때, 에러로 표기하는 옵션
"@typescript-eslint/no-unused-vars": "off",
// 명시적으로 특정 변수에 any 타입을 정의할 수 없도록 막아주는 옵션
"@typescript-eslint/no-explicit-any": "off",일반적인 스타일 코드를 유틸리티 클래스 형태로 제공하는 CSS 프레임워크입니다.
⚡ JIT Compiler는 Tailwind CSS의 핵심 기능으로, 실제로 사용되는 클래스만 실시간으로 컴파일하여 CSS를 생성합니다. 이를 통해 개발 중에는 빠른 빌드 속도를 제공하고, 프로덕션 빌드에서는 최소한의 CSS 파일 크기를 유지할 수 있습니다.
JIT 모드의 주요 장점은 다음과 같습니다:
w-[137px], text-[#1da1f2]와 같은 커스텀 값 지원npm install tailwindcss @tailwindcss/vitevite 플러그인 설정
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
//
https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
});tailwindcss import
여러 유틸리티 클래스가 tailwindcss 라는 하나의 css 파일 안에 묶여서 제공되기 때문에 전역 css 파일에서 tailwind css 파일을 import 합니다.
@import "tailwindcss"참고
⭐ prettier에서 tailwind css class 자동정렬을 위해 터미널에서
npm install -D prettier prettier-plugin-tailwindcss플러그인 설치
만약 위 플러그인이 적용되지 않는다면 .prettierrc.json 파일의 내용을 아래와 같이 수정하고 에디터 재시작합니다.
{
"plugins": ["./node_modules/prettier-plugin-tailwindcss/dist/index.mjs"]
}{/* 1. 타이포그래피 */}
{/* font-size, font-weight, color */}
<div className="text-xs text-red-500">Text XS</div>
<div className="text-sm text-violet-600">Text SM</div>
<div className="text-base font-bold">Text Base</div>
<div className="text-lg font-extrabold">Text LG</div>
<div className="text-xl">Text XL</div>
<div className="text-2xl">Text 2XL</div>
<div className="text-[15px] text-[rgb(100,30,200)]">
Text custom style
</div>{/* 2. 백그라운드 컬러 */}
<div className="bg-ember-500">Background Ember</div>
{/* 3. 사이즈 */}
{/* width의 경우 기본 스페이싱 간격(4px)에 설정한 값을 곱함. 예: w-20 -> 80px */}
<div className="w-20 bg-blue-500">box</div>
<div className="w-[200px] bg-blue-500">box</div>
<div className="w-full bg-blue-500">box</div>
<div className="min-w-20 bg-blue-500">box</div>
<div className="max-w-20 bg-blue-500">box</div>
<div className="w-auto bg-blue-500">box</div>
<div className="h-20 bg-green-500">box</div>
<div className="h-[200px] bg-green-500">box</div>
<div className="h-full bg-green-500">box</div>
<div className="min-h-20 bg-green-500">box</div>
<div className="max-h-20 bg-green-500">box</div>
<div className="h-auto bg-green-500">box</div>{/* 4. 여백 (padding, margin) */}
{/* p-2의 경우에도 width와 같이 기본 스페이싱 간격(4px)에 설정한 값을 곱함. 예: p-2 -> 8px */}
<div className="bg-yellow-500 p-2">box</div>
<div className="h-50 w-50 bg-red-500 pt-5 pr-5 pb-5 pl-5">
<div className="h-full w-full bg-blue-500"></div>
</div>
<div className="m-2 bg-purple-500">box</div>
<div className="mt-20 mr-2 mb-2 ml-2 bg-purple-500">box</div>{/* 5. border */}
<div className="border">border</div>
<div className="border-2">border</div>
<div className="m-5 border-x">border</div>
<div className="m-5 border-y">border</div>
<div className="m-5 border-y-2">border</div>
<div className="m-5 border-t-2 border-r-2 border-b-2 border-l-2">
border
</div>
<div className="border border-red-300">border</div>
<div className="m-5 rounded-2xl border p-2">border</div>{/* 6. flex container */}
<div className="flex items-center-safe justify-center-safe">
<div className="w-10 border">1</div>
<div className="w-10 border">2</div>
<div className="w-10 border">3</div>
<div className="w-10 border">4</div>
{/* flex-1: 남은 공간을 채우는 속성 */}
<div className="w-10 flex-1 border">5</div>
</div>shadcn/ui는 현대 웹 개발에서 필요한 대부분의 필수적인 UI 요소를 제공하는 디자인 라이브러리입니다.
Tailwind CSS 기반으로 제작되었기 때문에 커스터마이징이 용이합니다.
tsconfig.json 수정
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}@/ 라는 절대 경로로 src 하위 파일들을 import 할 수 있도록 경로 별칭을 먼저 설정합니다.
tsconfig.app.json 파일 수정
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}vite.config.ts 수정
npm install -D @types/nodeimport { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
import path from "path";
//
https://vite.dev/config/
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
}
});run CLI
npx shadcn@latest initbase color는 가장 기본인 neutral로 설정합니다.
컴포넌트 설치 방법
npx shadcn@latest add button1. components.json 파일 생성
2. Updating CSS variable in src/index.css
3. Create src/lib/utils.ts
cn 이라는 함수 생성
<div>
<div className={cn(isActive ? "bg-red-500" : "bg-blue-500")}>
IsActive
</div>
</div>install
npx shadcn@latest add buttonusage
<Button variant={"destructive"}>Click me</Button>
<Button variant={"ghost"}>Click me</Button>
<Button variant={"link"}>Click me</Button>
<Button variant={"outline"}>Click me</Button>
<Button variant={"secondary"}>Click me</Button>install
npx shadcn@latest add inputusage
<Input placeholder="Enter your email" value={"기본값"} />install
npx shadcn@latest add textareainstall
npx shadcn@latest add sonnerusage
토스트 메세지 출력을 위해 Toaster 컴포넌트를 rootLayout 등과 같은 모든 컴포넌트의 부모 컴포넌트에 추가하고 이를 호출하면 됩니다.
function App() {
const isActive = false;
return (
<div className="p-5">
<Toaster />
<Button
onClick={() =>
toast.success("Hello", {
position: "top-center",
})
}
>Click me</Button>
</div>
);
}install
npx shadcn@latest add carouselusage
<Carousel>
<CarouselContent>
<CarouselItem>1</CarouselItem>
<CarouselItem>2</CarouselItem>
<CarouselItem>3</CarouselItem>
<CarouselItem>4</CarouselItem>
<CarouselItem>5</CarouselItem>
</CarouselContent>
<CarouselPrevious />
<CarouselNext />
</Carousel>install
npx shadcd@latest add popoverusage
<Popover>
<PopoverTrigger>open</PopoverTrigger>
<PopoverContent>Place content here.</PopoverContent>
</Popover>만약 popover trigger를 심심한 text가 아닌 버튼으로 설정하고 싶다면 아래와 같이 asChild props를 사용합니다.
💡 asChild라는 Props는 트리거가 자체적으로 버튼을 생성하지 않도록 하고 대신 자식요소로 전달된 컴포넌트를 그대로 트리거 역할로 사용할 수 있게 해주는 설정입니다.
<Popover>
<PopoverTrigger asChild>
<Button>Open</Button>
</PopoverTrigger>
<PopoverContent>Place content here.</PopoverContent>
</Popover>install
npx shadcn@latest add dialogusage
<Dialog>
<DialogTrigger>Open Dialog</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
<DialogDescription>Dialog Description</DialogDescription>
</DialogHeader>
<div>Body</div>
</DialogContent>
</Dialog>💡 popover나 dialog 에는 open이라는 props를 전달해줄 수 있습니다. 그래서 클릭하지 않아도 다이얼로그나 팝오버가 열리고 닫히는걸 조정할 수 있습니다.
사용자에게 확실한 확인을 받아내야 하는 경우에 사용합니다. (confirm 창과 비슷)
install
npx shadcn@latest add alert-dialogusage
<AlertDialog>
<AlertDialogTrigger>Open Alert Dialog</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Alert Dialog Title</AlertDialogTitle>
<AlertDialogDescription>
Alert Dialog Description
</AlertDialogDescription>
</AlertDialogHeader>
<div>Body</div>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => alert("Cancel")}>
Cancel
</AlertDialogCancel>
<AlertDialogAction onClick={() => alert("Action")}>
Action
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>⚠️ alert-dialog는 description을 포함해주는걸 권장하며, dialog는 title을 포함하는 것을 권장합니다.
shadcn은 기본적으로 lucide 아이콘 팩을 제공합니다.
그러기 때문에 별도 아이콘 라이브러리를 사용하지 않아도 됩니다.
import { ChefHat } from "lucide-react";<ChefHat className="size-10 fill-red-500" />install
npm install react-router@7main.tsx
import { createRoot } from "react-dom/client";
import "./index.css";
import App from "./App.tsx";
import { BrowserRouter } from "react-router";
createRoot(document.getElementById("root")!).render(
<BrowserRouter>
<App />
</BrowserRouter>,
);💡 browser router는 브라우저의 주소를 리액트 앱이 감지할 수 있도록해서 UI와 동기화할 수 있게 해주는 역할을 수행합니다. 그렇기 때문에 모든 컴포넌트의 부모 컴포넌트인 root에서 최상위 컴포넌트를 감싸줍니다.
app.tsx
import { Route, Routes } from "react-router";
import "./App.css";
function App() {
return (
<Routes>
<Route path="/" element={<div>Home</div>} />
<Route path="/sign-in" element={<div>Sign In</div>} />
<Route path="/sign-up" element={<div>Sign Up</div>} />
</Routes>
);
}
export default App;만약 사용자가 브라우저에서 / 경로 접근 시 routes 에서는 하위 컴포넌트들인 route 중 일치하는 경로가 있는지 확인하고, 해당 route 컴포넌트의 element props를 화면에 렌더링합니다.
react router를 활용하여 경로에 따른 Layout 설정을 할 수 있습니다.
예를 들어 sign-in과 sign-up 페이지가 같은 Layout을 갖기를 원할 때에는 이 두 컴포넌트를 하나의 routes 컴포넌트로 묶고 route 컴포넌트의 element props로 layout의 역할을 할 ui 요소를 넣어주면 됩니다.
💡 이때, Outlet 컴포넌트는 layout 컴포넌트에서 page component가 어디에 렌더링될지를 결정합니다.
복잡한 계층 구조를 갖는 리액트 앱의 모든 컴포넌트에서 접근 가능한 전역 상태(글로벌한 state 값을 새롭게 생성, 수정할 수 있도록 관리하는 것, 예를 들어 인증정보, 테마정보, 장바구니 정보 등)로 만들어서 관리하는 것을 의미합니다.
이러한 전역 상태 관리는 거의 반드시 필요합니다.
왜냐하면 props만을 이용해서 자식 컴포넌트로 전달하다보면 계속 중첩되어 props drilling 이슈가 생길 수 있습니다.
그리고 props 하나가 업데이트 되었을 때, 이를 전달받는 모든 컴포넌트가 모두 리렌더링 되는 성능 상의 문제를 야기합니다.
그래서 이러한 전역 상태 관리를 돕는 도구로 Context API나 Redux, jotai, zustand 와 같은 서드파티 라이브러리가 있습니다.
⚠️ 이때, Context API는 전역 상태 관리를 위한 기능이라기 보다는 props drilling 이슈를 해결하기 위해 제공되는 기능에 가깝습니다.
왜냐하면 전역 상태를 관리하는 ContextProvider 하나를 추가했다고 가정했을 때, 이를 활용해서 여러 하위 컴포넌트들에서 context를 공유할 수 있지만 문제는 이 context의 상태 값이 한번 업데이트 될때마다 컨텍스트 하위의 모든 컴포넌트 (상태를 전달받지 않은 컴포넌트 포함)들이 리렌더링 된다는 치명적인 한계점이 있습니다.
💡 그래서 보통 Context API는 특정 컴포넌트들끼리만 공유하는 데이터를 다룰 때 효과적으로 사용할 수 있습니다.
install
npm install zustandcounter 기능 구현
counter.ts
import { create } from "zustand";
type Store = {
count: number;
increase: () => void;
decrease: () => void;
};
export const useCountStore = create<Store>((set, get) => ({
count: 0,
increase: () => {
// const count = get().count;
// set({ count: count + 1 });
// set 메서드는 함수형 업데이트도 지원. (권장)
set((store) => ({
count: store.count + 1,
}));
},
decrease: () => {
set((store) => ({
count: store.count - 1,
}));
},
}));zustand 패키지에서 불러온 create 함수는 zustand 스토어 생성 역할을 하며, 이때, store란 전역 상태인 state와 해당 상태를 업데이트 하는 action 함수를 포함하는 객체를 의미합니다.
그리고 타입 별칭을 활용하여 store 타입을 정의하고 그 타입을 create 함수에 제네릭 타입으로 전달하면 set 메서드에서 store 객체의 타입을 추론할 수 있게 됩니다.
create 함수 사용은 인수로 콜백함수를 전달하며, 이 콜백함수는 객체를 반환합니다.
이때 반환하는 객체가 store 객체가 됩니다.
store에는 state와 action 함수가 저장되어 있습니다.
또한, create 함수는 단순 스토어 생성 뿐만 아니라 스토어에 접근할 수 있는 리액트 훅을 반환합니다.
create(() => {
return {
count: 0,
increase: () => {},
decrease: () => {},
}
})action 함수 내부에서 state를 사용하기 위해서 get 메서드를 사용합니다.
get 메서드는 store의 현재 상태를 반환하는 함수입니다 → 그렇기 때문에 count 상태를 보고 싶다면 get().count 와 같이 사용합니다.
set 메서드는 store의 상태를 업데이트 하는 함수로, store의 상태를 인자로 받아 업데이트 합니다.
💡 set 메서드는 인수로 전달한 객체 내부 프로퍼티를 기준으로 명시되어 있는 프로퍼티 값만 업데이트 하고 나머지는 그대로 유지합니다.
counter-page.tsx
import { Button } from "@/components/ui/button";
import { useCountStore } from "@/store/count";
export default function CounterPage() {
// useCountStore 훅을 사용하면 count.ts에서 만든 store 객체를 반환받음.
// 보통 구조분해 할당을 사용하여 state, action 함수를 분리하여 사용.
const { count, increase, decrease } = useCountStore();
return (
<div>
<h1 className="text-2xl font-bold">Counter</h1>
<div>{count}</div>
<div>
<Button onClick={increase}>+</Button>
<Button onClick={decrease}>-</Button>
</div>
</div>
);
}위와 같은 상태에서 각 기능별로 컴포넌트를 분리하면 다음과 같습니다.
counter-page.tsx
import Controller from "@/components/counter/controller";
import Viewer from "@/components/counter/viewer";
export default function CounterPage() {
return (
<div>
<h1 className="text-2xl font-bold">Counter</h1>
<Viewer />
<Controller />
</div>
);
}viewer.tsx
import { useCountStore } from "@/store/count";
export default function Viewer() {
const { count } = useCountStore();
return <div>{count}</div>;
}controller.tsx
import { useCountStore } from "@/store/count";
import { Button } from "../ui/button";
export default function Controller() {
const { increase, decrease } = useCountStore();
return (
<div>
<Button onClick={increase}>+</Button>
<Button onClick={decrease}>-</Button>
</div>
);
}이 상태에서 앱을 실행하고 개발자 도구 > components > 설정 > 하이라이트를 체크해서 리액트 리렌더링을 시각적으로 확인하면 count 값 뿐만 아니라 +, - 버튼들도 동시에 리렌더링 되는 것을 볼 수 있습니다.
⚠️ 이러한 현상의 원인은 zustand는 컴포넌트에서 불러온 store 값들 중, 하나라도 업데이트가 되면 해당 컴포넌트를 자동으로 리렌더링 시키기 때문입니다. 또한 현재 위 컴포넌트에서 store 객체의 전부를 불러오기 때문입니다 →
useCounterStore()
useCounterStore() 를 불러오면 스토어 객체 전부를 불러오는 것이며, 편의상 구조분해 할당을 이용해 action함수나 state 값만을 활용하는 것입니다.
즉, controller 컴포넌트에서도 count state를 불러오고 있는 것이며, 이 count 값이 변경되기 때문에 button이 있는 controller 컴포넌트도 리렌더링 되는 것입니다.
이런 문제를 해결하려면 useCountStroe() 의 인수로 스토어로부터 어떤 값을 꺼내올지 명시하는 함수(셀렉터)를 정의하면 됩니다.
즉, 스토어 훅에 콜백함수를 전달하는데 이 콜백함수의 인자로는 현재 스토어객체가 제공되며, 원하는 값을 불러올 수 있습니다. (selector 함수)
const increase = useCountStore((store) => store.increase);
const decrease = useCountStore((store) => store.decrease);위와 같이 수정 후 다시 counter 앱을 실행시키면서 react devtools로 리렌더링 여부를 확인하면 값이 변경되는 viewer 부분만 리렌더링 되는 것을 확인할 수 있습니다.
그런데 일반적으로 컴포넌트 내부에서 스토어를 불러올 때, Selector 함수를 잘 사용하지는 않습니다.
이유는 만약 store 내부에서 state나 action 함수에 무언가 변경사항(예를 들어 이름이 변경된다거나)이 있을 경우, 이 store에 접근하는 모든 컴포넌트에서 일일히 변경사항(변경된 이름을 반영)을 반영해주어야 하는 번거로움이 발생하고 이로 인해 유지보수성이 저하되기 때문입니다.
💡 그래서 통상적으로 store 내부에서 커스텀 훅을 만들고 컴포넌트에서는 이러한 커스텀 훅을 가져다가 사용하는 형태를 가장 많이 사용합니다.
export const useCount = () => {
const count = useCountStore((store) => store.count);
return count;
}
export const useIncrease = () => {
const increase = useCountStore((store) => store.increase);
return increase;
};
export const useDecrease = () => {
const decrease = useCountStore((store) => store.decrease);
return decrease;
};controller.tsx
import { useDecrease, useIncrease } from "@/store/count";
import { Button } from "../ui/button";
export default function Controller() {
const increase = useIncrease();
const decrease = useDecrease();
return (
<div>
<Button onClick={increase}>+</Button>
<Button onClick={decrease}>-</Button>
</div>
);
}viewer.tsx
import { useCount } from "@/store/count";
export default function Viewer() {
const count = useCount();
return <div>{count}</div>;
}미들웨어란 직역하면 중간에 있는 도구이며, 프로그래밍 측면에서 보면 특정 로직 중간에서 추가적인 작업을 진행하는 도구를 의미합니다.
zustand에서는 아래와 같은 5가지의 미들웨어를 제공합니다.
create 메서드를 사용해서 새로운 저장소를 생성할 때, state와 action 함수를 통으로 객체에 넣는 방식이 아닌, state는 state끼리, action 함수는 action 함수끼리 서로 분리해서 작성한 다음 결합시키는 방식으로 store를 정의할 수 있게 도와줍니다.
이를 통해 TS가 store의 타입을 더 정확하게 추론할 수 있습니다.
combine의 첫번째 인자로는 state 객체가 들어가며, 두번째 인자로는 콜백함수가 들어오는데, 이때 반환값이 action 함수들입니다.
create(
combine({ count: 0 }, (set) => ({
actions: {
increase: () => set((store) => ({ count: store.count + 1 })),
decrease: () => set((store) => ({ count: store.count - 1 })),
},
})),
);위와 같이 combine을 사용하면 state와 action 함수를 분리해서 작성할 수 있습니다.
이런 방식을 사용하는 이유는 첫번째 인수로 전달한 state 타입이 자동으로 추론되기 때문이며, 이전처럼 제네릭으로 스토어의 타입을 정의해주지 않아도 됩니다.
⚠️ 단, 타입 관련 주의사항으로 combine은 기본적으로 스토어의 타입을 추론할 때, 첫번째 인자로 들어온 state 값만 포함하는 타입으로 추론합니다. 즉, count 프로퍼티 외 state 객체 내부에 다른 프로퍼티가 있었다면 해당 프로퍼티의 타입은 추론되지 않습니다.
action 함수의 상태 업데이트를 좀 더 편리하게 해주고 불변성 관리를 도와주는 미들웨어입니다.
install
npm install immerimport { immer } from "zustand/middleware/immer"설치 후 create 함수 내부에서 immer 함수 호출합니다.
함수의 인자로는 위에서 만들어두었던 combine 함수가 들어옵니다.
export const useCountStore = create(
immer(
combine({ count: 0 }, (set) => ({
actions: {
increase: () => set((store) => ({ count: store.count + 1 })),
decrease: () => set((store) => ({ count: store.count - 1 })),
},
})),
),
);이렇게 하면 이제 객체의 특정 프로퍼티에 접근해서 값을 직접 변경해도 불변성을 유지할 수 있습니다. (이전까지는 새로운 객체를 생성)
export const useCountStore = create(
immer(
combine({ count: 0 }, (set) => ({
actions: {
increase: () => {
set((state) => {
state.count += 1;
});
},
decrease: () => {
set((state) => {
state.count -= 1;
});
},
},
})),
),
);selector 함수를 통해 스토어의 특정 값을 구독하고 해당 값이 변경될 때마다 특정 기능을 수행할 수 있게 도와주는 미들웨어입니다.
export const useCountStore = create(
subscribeWithSelector(
immer(
combine({ count: 0 }, (set) => ({
actions: {
increase: () => {
set((state) => {
state.count += 1;
});
},
decrease: () => {
set((state) => {
state.count -= 1;
});
},
},
})),
),
),
);
useCountStore.subscribe(
(store) => store.count,
(count, prev) => {
console.log("count", count); // 업데이트된 값
console.log("prev", prev); // 업데이트 이전 값
const store = useCountStore.getState();
// useCountStore.setState(() => ({}));
},
);위와 같이 subscribe 메서드를 활용합니다. 이 메서드의 첫번째 인자로는 어떤 값을 구독할건지 선택하기 위한 셀렉터 함수가 들어옵니다.
그리고 이 첫번째 인자에서 선택한 값이 변경될 때마다 두번째 인자로 전달하는 콜백 함수가 실행되며, 이때 콜백함수의 매개변수에는 첫번째 인자에서 결정한 구독값이 전달됩니다.
이렇게 구독한 값이 변경될 때마다 실행되는 함수를 Listener 함수라고 합니다.
이러한 리스너 함수는 두번째 매개변수로 previousSelectedState 즉, 선택된 state가 업데이트 되기 이전 값이 전달됩니다.
또한, 리스너 함수는 내부에서 store를 참조할 수 있는데 이때는 getState() 메서드를 사용하여 참조할 수 있습니다.
현재 스토어의 값을 브라우저 스토어에 저장하는 미들웨어입니다.
export const useCountStore = create(
persist(
subscribeWithSelector(
immer(
combine({ count: 0 }, (set) => ({
actions: {
increase: () => {
set((state) => {
state.count += 1;
});
},
decrease: () => {
set((state) => {
state.count -= 1;
});
},
},
})),
),
),
{
name: "count-storage",
partialize: (state) => ({ count: state.count }),
storage: createJSONStorage(() => sessionStorage),
},
),
);persist 미들웨어는 2개의 인자를 받습니다.
첫번째는 store의 정의부, 두번째는 옵션 객체입니다.
옵션 객체까지 생성해주면 persist 미들웨어가 자동으로 현재 store의 값을 브라우저 로컬스토리지에 저장해줍니다.
대표적인 옵션은 아래와 같습니다:
개발자 도구를 통해 store를 디버깅할 수 있도록 해주는 미들웨어입니다.
이전 미들웨어들과 마찬가지로 create 함수 내에서 호출하여 스토어 정의부를 첫번째 인자로 받고 두번째로는 옵션 객체를 받습니다.
export const useCountStore = create(
devtools(
persist(
subscribeWithSelector(
immer(
combine({ count: 0 }, (set) => ({
actions: {
increase: () => {
set((state) => {
state.count += 1;
});
},
decrease: () => {
set((state) => {
state.count -= 1;
});
},
},
})),
),
),
{
name: "count-storage",
partialize: (state) => ({ count: state.count }),
storage: createJSONStorage(() => sessionStorage),
},
),
{
name: "count-store",
},
),
);💡 또한, 브라우저 확장 프로그램 중 Redux Devtools 라는 익스텐션을 다운로드 받아야 개발자도구에서 확인이 가능합니다.
⚠️ 위와 같이 여러 미들웨어를 사용할 경우 순서가 중요합니다!
일반적으로 devtools는 가장 바깥쪽에, 그 다음 persist, 그리고 나머지 미들웨어들을 적용하는 순서(combine → immer → subscribeWithSelector)로 사용하는 것을 권장합니다.
이는 각 미들웨어의 역할과 동작 방식에 따라 최적의 순서가 결정되기 때문입니다.