@m1kapp/kit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,627 @@
1
+ # @m1kapp/kit
2
+
3
+ 사이드 프로젝트를 위한 UI · OG · PWA · Fetch · Utils 올인원 킷.
4
+
5
+ ```bash
6
+ npm install @m1kapp/kit
7
+ ```
8
+
9
+ **Peer dependencies:** `react >= 18`, `react-dom >= 18`
10
+ **Optional:** `@vercel/og >= 0.6` (Next.js 외 환경에서 OG 이미지 생성 시)
11
+
12
+ ---
13
+
14
+ ## 빠른 시작
15
+
16
+ ```tsx
17
+ // CSS 자동 주입 — 별도 import 불필요
18
+ import { AppShell, AppShellHeader, AppShellContent, TabBar, Tab } from "@m1kapp/kit";
19
+
20
+ export default function App() {
21
+ return (
22
+ <AppShell>
23
+ <AppShellHeader>
24
+ <h1>My App</h1>
25
+ </AppShellHeader>
26
+ <AppShellContent>
27
+ {/* 콘텐츠 */}
28
+ </AppShellContent>
29
+ <TabBar>
30
+ <Tab href="/" icon={<HomeIcon />} label="홈" />
31
+ </TabBar>
32
+ </AppShell>
33
+ );
34
+ }
35
+ ```
36
+
37
+ ---
38
+
39
+ ## 모듈 구성
40
+
41
+ | 모듈 | import | 설명 |
42
+ |---|---|---|
43
+ | UI | `@m1kapp/kit` | 컴포넌트 24개 + 훅 |
44
+ | OG Image | `@m1kapp/kit` | OG 이미지 생성 (서버) |
45
+ | PWA | `@m1kapp/kit` | manifest, viewport, 설치 유도 |
46
+ | Fetch | `@m1kapp/kit` | 캐싱·중복제거·재시도 fetch 유틸 |
47
+ | Utils | `@m1kapp/kit` | 날짜·숫자 포맷, 범용 훅 |
48
+ | **Server** | `@m1kapp/kit/server` | Next.js API route 핸들러 유틸 |
49
+
50
+ ---
51
+
52
+ ## UI
53
+
54
+ CSS가 import 시 자동 주입됩니다. 별도 스타일시트 import 불필요.
55
+
56
+ ### 레이아웃
57
+
58
+ ```tsx
59
+ import { AppShell, AppShellHeader, AppShellContent } from "@m1kapp/kit";
60
+
61
+ <AppShell> // 최대 430px 중앙 정렬 모바일 컨테이너
62
+ <AppShellHeader>...</AppShellHeader> // 상단 sticky 헤더
63
+ <AppShellContent>...</AppShellContent> // 스크롤 가능한 본문
64
+ </AppShell>
65
+ ```
66
+
67
+ ### 내비게이션
68
+
69
+ ```tsx
70
+ import { TabBar, Tab } from "@m1kapp/kit";
71
+
72
+ <TabBar>
73
+ <Tab
74
+ active={tab === "home"}
75
+ onClick={() => setTab("home")}
76
+ label="홈"
77
+ icon={<HomeIcon />}
78
+ activeColor="#3b82f6" // 활성 색상 자유롭게 지정
79
+ />
80
+ </TabBar>
81
+ ```
82
+
83
+ ### 데이터 표시
84
+
85
+ ```tsx
86
+ import { Avatar, Badge, StatChip, EmptyState, GrassMap } from "@m1kapp/kit";
87
+
88
+ // Avatar — 이니셜 or 이미지, 이미지 로드 실패 시 이니셜로 자동 fallback
89
+ <Avatar src="/photo.jpg" fallback="MH" size="md" shape="circle" />
90
+ <Avatar fallback="MH" size="lg" shape="rounded" color="#3b82f6" />
91
+ // size: "xs" | "sm" | "md" | "lg" | "xl"
92
+ // shape: "circle" | "rounded"
93
+
94
+ // Badge — 상태/카테고리 레이블
95
+ <Badge variant="green">LIVE</Badge>
96
+ <Badge variant="red">오류</Badge>
97
+ <Badge variant="blue" size="sm">정보</Badge>
98
+ // variant: "default" | "green" | "red" | "yellow" | "blue" | "purple" | "orange"
99
+
100
+ // StatChip — 숫자 stat 뱃지
101
+ <StatChip label="방문자" value={1024} />
102
+
103
+ // EmptyState — 빈 목록 플레이스홀더
104
+ <EmptyState message="아직 아무것도 없어요" />
105
+
106
+ // GrassMap — GitHub 스타일 활동 히트맵
107
+ <GrassMap data={[{ date: "2025-04-19", count: 42 }]} accent="#3b82f6" />
108
+ ```
109
+
110
+ ### 스켈레톤
111
+
112
+ 로딩 플레이스홀더. `className`으로 크기를 지정합니다.
113
+
114
+ ```tsx
115
+ import { Skeleton } from "@m1kapp/kit";
116
+
117
+ // 텍스트 줄
118
+ <Skeleton className="h-4 w-3/4" />
119
+
120
+ // 카드 블록
121
+ <Skeleton className="h-32 w-full" rounded="xl" />
122
+
123
+ // 아바타
124
+ <Skeleton className="h-10 w-10" rounded="full" />
125
+
126
+ // 실전 패턴
127
+ function PostCardSkeleton() {
128
+ return (
129
+ <div className="flex gap-3 p-4">
130
+ <Skeleton className="h-10 w-10" rounded="full" />
131
+ <div className="flex-1 space-y-2">
132
+ <Skeleton className="h-4 w-2/3" />
133
+ <Skeleton className="h-3 w-1/2" />
134
+ </div>
135
+ </div>
136
+ );
137
+ }
138
+ ```
139
+
140
+ ### 모달 / 다이얼로그
141
+
142
+ backdrop 클릭, ESC 키, 스크롤 잠금 자동 처리.
143
+
144
+ ```tsx
145
+ import { Dialog } from "@m1kapp/kit";
146
+
147
+ // 기본 사용
148
+ <Dialog open={open} onClose={() => setOpen(false)} title="설정">
149
+ <p className="text-sm text-zinc-500">내용</p>
150
+ </Dialog>
151
+
152
+ // 확인 다이얼로그
153
+ <Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)} title="삭제할까요?">
154
+ <p className="text-sm text-zinc-500">이 작업은 되돌릴 수 없어요.</p>
155
+ <div className="flex gap-2 mt-4">
156
+ <button onClick={handleDelete} className="...">삭제</button>
157
+ <button onClick={() => setConfirmOpen(false)} className="...">취소</button>
158
+ </div>
159
+ </Dialog>
160
+
161
+ // size: "sm" (기본) | "md" | "lg"
162
+ // persistent: true — backdrop 클릭 / ESC로 닫기 비활성화
163
+ ```
164
+
165
+ ### 인터랙션
166
+
167
+ ```tsx
168
+ import { Button, Tooltip, Typewriter, EmojiButton, EmojiPicker } from "@m1kapp/kit";
169
+
170
+ <Button onClick={fn}>시작하기</Button>
171
+
172
+ <Tooltip label="설명 텍스트">
173
+ <button>hover me</button>
174
+ </Tooltip>
175
+
176
+ <Typewriter words={["Hello", "World"]} color="#3b82f6" />
177
+
178
+ // 이모지 선택기
179
+ const [emoji, setEmoji] = useState("🏠");
180
+ const [open, setOpen] = useState(false);
181
+ <EmojiButton emoji={emoji} onClick={() => setOpen(true)} />
182
+ <EmojiPicker open={open} onClose={() => setOpen(false)} current={emoji} onSelect={setEmoji} />
183
+ ```
184
+
185
+ ### 공유
186
+
187
+ ```tsx
188
+ import { ShareButton, useShare } from "@m1kapp/kit";
189
+
190
+ // 버튼 그대로 사용 — 모바일은 네이티브 공유, 데스크탑은 클립보드 복사
191
+ <ShareButton url="https://m1k.app" title="My App" />
192
+
193
+ // 커스텀 UI
194
+ const { share, copied, canNativeShare } = useShare({ url: "https://m1k.app" });
195
+ <button onClick={() => share()}>{copied ? "복사됨!" : "공유"}</button>
196
+ ```
197
+
198
+ ### 토스트
199
+
200
+ ```tsx
201
+ import { ToastProvider, useToast } from "@m1kapp/kit";
202
+
203
+ // 앱 루트 감싸기
204
+ <ToastProvider>
205
+ <App />
206
+ </ToastProvider>
207
+
208
+ // 어디서나
209
+ const toast = useToast();
210
+ toast("저장됐어요!", { variant: "success" });
211
+ toast("오류가 발생했어요.", { variant: "error", duration: 4000 });
212
+ toast("링크가 복사됐어요."); // default (dark)
213
+ // variant: "default" | "success" | "error" | "info"
214
+ ```
215
+
216
+ ### 테마
217
+
218
+ 다크모드 + 컬러 테마 선택기.
219
+
220
+ ```tsx
221
+ import { ThemeButton, ThemeDialog, THEME_SCRIPT, colors } from "@m1kapp/kit";
222
+
223
+ // layout.tsx — 다크모드 깜빡임 방지
224
+ <head>
225
+ <script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
226
+ </head>
227
+
228
+ // 테마 버튼 + 다이얼로그
229
+ const [themeOpen, setThemeOpen] = useState(false);
230
+ <ThemeButton color={color} dark={dark} onClick={() => setThemeOpen(true)} />
231
+ <ThemeDialog
232
+ open={themeOpen}
233
+ onClose={() => setThemeOpen(false)}
234
+ current={color}
235
+ onSelect={setColor}
236
+ dark={dark}
237
+ onDarkToggle={() => setDark(v => !v)}
238
+ />
239
+
240
+ // 팔레트
241
+ colors.blue // "#3b82f6"
242
+ colors.purple // "#a855f7"
243
+ colors.green // "#22c55e"
244
+ // blue | purple | green | orange | pink | red | yellow | cyan | slate | zinc
245
+ ```
246
+
247
+ ### 워터마크
248
+
249
+ ```tsx
250
+ import { Watermark } from "@m1kapp/kit";
251
+
252
+ <Watermark color="#3b82f6" text="myapp">
253
+ {children}
254
+ </Watermark>
255
+ ```
256
+
257
+ ---
258
+
259
+ ## OG Image
260
+
261
+ Next.js 14+는 `next/og`가 내장되어 있어 별도 설치 불필요. 그 외 환경은 `npm i @vercel/og`.
262
+
263
+ ```tsx
264
+ // app/og/route.tsx
265
+ import { OGImage, loadPretendard } from "@m1kapp/kit";
266
+ import { ImageResponse } from "next/og";
267
+
268
+ export async function GET() {
269
+ const font = await loadPretendard();
270
+
271
+ return new ImageResponse(
272
+ <OGImage
273
+ type="default"
274
+ title="사이드 프로젝트 시작하기"
275
+ sub="빠르게 만들고 빠르게 배우는"
276
+ badge="🚀 NEW"
277
+ appName="myapp"
278
+ color="#3b82f6"
279
+ bg="dark" // "dark" | "gradient" | "blend"
280
+ domain="m1k.app"
281
+ />,
282
+ { width: 1200, height: 630, fonts: [font] }
283
+ );
284
+ }
285
+ ```
286
+
287
+ ### 템플릿
288
+
289
+ | type | 크기 | 용도 |
290
+ |---|---|---|
291
+ | `default` | 1200×630 | 기본 OG |
292
+ | `article` | 1200×630 | 블로그 포스트 — author, date, category |
293
+ | `stat` | 1200×630 | 마일스톤 — stat, label |
294
+ | `product` | 1200×630 | 제품 소개 — tagline, features[] |
295
+ | `match` | 1200×630 | 경기 결과 — home, away, score |
296
+ | `square` | 1200×1200 | Instagram / SNS |
297
+ | `icon` | 512×512 | 앱 아이콘 / favicon |
298
+
299
+ ```tsx
300
+ // article
301
+ <OGImage type="article" title="제목" author="minho" date="2025-04-19" category="Tutorial" sub="부제" color={c} bg={bg} />
302
+
303
+ // stat
304
+ <OGImage type="stat" stat="1,000" label="명의 방문자" sub="론칭 3일 만에" badge="🎉" color={c} bg={bg} />
305
+
306
+ // product
307
+ <OGImage type="product" title="@m1kapp/kit" tagline="올인원 킷" features={["기능1", "기능2"]} color={c} bg={bg} />
308
+ ```
309
+
310
+ ### 폰트 & 이모지
311
+
312
+ ```tsx
313
+ import { loadPretendard, loadGoogleFont, createEmojiLoader } from "@m1kapp/kit";
314
+
315
+ const pretendard = await loadPretendard(); // Pretendard 한국어 폰트
316
+ const roboto = await loadGoogleFont("Roboto", 700); // Google Fonts
317
+ const loadEmoji = createEmojiLoader("twemoji"); // 이모지 fallback
318
+ ```
319
+
320
+ ---
321
+
322
+ ## PWA
323
+
324
+ ### Manifest
325
+
326
+ `public/manifest.json` 대신 코드로 관리. 아이콘 이미지 파일 불필요.
327
+
328
+ ```ts
329
+ // app/manifest.ts
330
+ import { createManifest } from "@m1kapp/kit";
331
+
332
+ export default createManifest({
333
+ name: "My App",
334
+ shortName: "App",
335
+ description: "What this app does",
336
+ themeColor: "#3b82f6", // 아이콘 배경색으로도 사용
337
+ backgroundColor: "#ffffff",
338
+ icon: { text: "MA" }, // 텍스트로 192×192, 512×512 SVG 아이콘 자동 생성
339
+ });
340
+ ```
341
+
342
+ ### Viewport — 핀치 줌 차단
343
+
344
+ iOS 10+에서 핀치 줌과 인풋 자동 확대를 막습니다.
345
+
346
+ ```ts
347
+ // app/layout.tsx
348
+ import { mobileViewport } from "@m1kapp/kit";
349
+
350
+ export const viewport = mobileViewport;
351
+ ```
352
+
353
+ 내부적으로 CSS `touch-action: pan-x pan-y`와 `input { font-size: max(16px, 1em) }`를 자동 적용합니다.
354
+
355
+ ### SVG 아이콘
356
+
357
+ ```ts
358
+ import { svgIcon } from "@m1kapp/kit";
359
+
360
+ const src = svgIcon("MA", { size: 192, bg: "#3b82f6", color: "#ffffff", radius: 0.25 });
361
+ // → "data:image/svg+xml,..." — <img src={src} /> 또는 manifest icons에 바로 사용
362
+ ```
363
+
364
+ ### 앱 설치 유도
365
+
366
+ Android는 네이티브 설치 프롬프트, iOS는 홈 화면 추가 안내 시트를 자동으로 띄워줍니다.
367
+
368
+ ```tsx
369
+ import { PWAInstallButton, IOSInstallSheet, usePWAInstall } from "@m1kapp/kit";
370
+
371
+ // 버튼 그대로 사용
372
+ <PWAInstallButton appName="My App" iconSrc={iconSrc} label="앱으로 설치" />
373
+
374
+ // 커스텀 UI
375
+ const { state, install } = usePWAInstall();
376
+ // state: "android-ready" | "ios-safari" | "installed" | "unsupported"
377
+
378
+ if (state === "android-ready") {
379
+ return <button onClick={install}>설치</button>;
380
+ }
381
+ if (state === "ios-safari") {
382
+ return <button onClick={() => setSheetOpen(true)}>설치</button>;
383
+ }
384
+
385
+ // iOS 안내 시트 (직접 제어 시)
386
+ <IOSInstallSheet open={sheetOpen} onClose={() => setSheetOpen(false)} appName="My App" iconSrc={iconSrc} />
387
+ ```
388
+
389
+ ---
390
+
391
+ ## Fetch
392
+
393
+ 의존성 제로. 캐싱 · 중복제거 · 재시도 · 포커스 revalidate가 내장된 fetch 유틸.
394
+
395
+ ### useFetch
396
+
397
+ ```tsx
398
+ import { useFetch } from "@m1kapp/kit";
399
+
400
+ const { data, loading, error, refetch } = useFetch<User[]>("/api/users", {
401
+ staleTime: 30_000, // 30초 캐시 — 같은 URL 중복 요청 없음
402
+ retry: 2, // 네트워크 오류 시 지수 백오프로 2회 재시도
403
+ revalidateOnFocus: true, // 탭 돌아오면 자동 최신 데이터
404
+ });
405
+
406
+ // 로딩 처리
407
+ if (loading && !data) return <PostListSkeleton />;
408
+ if (error) return <p>{error.message}</p>;
409
+ return data?.map(u => <UserCard key={u.id} user={u} />);
410
+ ```
411
+
412
+ ### usePolling
413
+
414
+ 실시간 데이터, 라이브 스코어 등에 사용.
415
+
416
+ ```tsx
417
+ import { usePolling } from "@m1kapp/kit";
418
+
419
+ const { data, isRunning, start, stop } = usePolling(
420
+ () => fetch("/api/match/live").then(r => r.json()),
421
+ {
422
+ interval: 5000, // 5초마다
423
+ enabled: true, // 시작 여부
424
+ pauseOnHidden: true, // 탭 숨기면 자동 정지 — 불필요한 요청 없음
425
+ }
426
+ );
427
+
428
+ <button onClick={() => isRunning ? stop() : start()}>
429
+ {isRunning ? "정지" : "시작"}
430
+ </button>
431
+ ```
432
+
433
+ ### createApiClient
434
+
435
+ baseURL과 공통 헤더를 한 번만 설정하면 타입 안전한 API 클라이언트가 만들어집니다.
436
+
437
+ ```ts
438
+ // lib/api.ts
439
+ import { createApiClient, ApiError } from "@m1kapp/kit";
440
+
441
+ export const api = createApiClient("https://api.myapp.com", {
442
+ headers: { Authorization: `Bearer ${token}` },
443
+ onError: (err) => {
444
+ if (err.status === 401) signOut();
445
+ },
446
+ });
447
+
448
+ // 사용
449
+ const me = await api.get<User>("/users/me");
450
+ const post = await api.post<Post>("/posts", { title, body });
451
+ await api.put("/posts/1", { title: "수정된 제목" });
452
+ await api.delete("/posts/1");
453
+
454
+ // 에러는 ApiError로 정규화
455
+ try {
456
+ await api.delete("/posts/1");
457
+ } catch (e) {
458
+ if (e instanceof ApiError) {
459
+ console.log(e.status, e.body); // 404, { error: "Not found" }
460
+ }
461
+ }
462
+ ```
463
+
464
+ ---
465
+
466
+ ## Server
467
+
468
+ Next.js API route 전용. `@m1kapp/kit/server`로 import — 클라이언트 번들에 포함되지 않습니다.
469
+
470
+ ### handler()
471
+
472
+ try/catch 없이 에러를 처리합니다. `unauthorized()`, `notFound()` 등은 `never`를 반환하므로 TypeScript가 제어 흐름을 정확히 추론합니다.
473
+
474
+ ```ts
475
+ import { handler, ok, created, unauthorized, forbidden, notFound, badRequest } from "@m1kapp/kit/server";
476
+
477
+ // Before ❌
478
+ export async function GET(req: Request) {
479
+ const user = await currentUser();
480
+ if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
481
+ try {
482
+ const data = await db.sites.findMany({ where: { userId: user.id } });
483
+ return Response.json(data);
484
+ } catch {
485
+ return Response.json({ error: "Internal Server Error" }, { status: 500 });
486
+ }
487
+ }
488
+
489
+ // After ✅
490
+ export const GET = handler(async () => {
491
+ const user = await currentUser();
492
+ if (!user) unauthorized(); // throws → 401
493
+
494
+ const data = await db.sites.findMany({ where: { userId: user.id } });
495
+ return ok(data); // 200 + JSON
496
+ // 처리되지 않은 에러 → 500 자동
497
+ });
498
+
499
+ export const POST = handler(async (req) => {
500
+ const user = await currentUser();
501
+ if (!user) unauthorized();
502
+
503
+ const { url } = await req.json();
504
+ if (!url) badRequest("url이 필요해요"); // throws → 400
505
+
506
+ const site = await db.sites.create({ data: { url, userId: user.id } });
507
+ return created(site); // 201 + JSON
508
+ });
509
+ ```
510
+
511
+ ### 응답 헬퍼
512
+
513
+ | 함수 | 상태 | 설명 |
514
+ |---|---|---|
515
+ | `ok(data)` | 200 | JSON 응답 |
516
+ | `created(data)` | 201 | 생성 완료 |
517
+ | `noContent()` | 204 | 본문 없음 |
518
+ | `badRequest(msg?)` | 400 | 잘못된 요청 |
519
+ | `unauthorized(msg?)` | 401 | 인증 필요 |
520
+ | `forbidden(msg?)` | 403 | 권한 없음 |
521
+ | `notFound(msg?)` | 404 | 리소스 없음 |
522
+ | `conflict(msg?)` | 409 | 충돌 |
523
+ | `serverError(msg?)` | 500 | 서버 오류 |
524
+
525
+ ### safely()
526
+
527
+ 특정 에러를 try/catch 없이 처리하고 싶을 때.
528
+
529
+ ```ts
530
+ import { handler, ok, serverError, safely } from "@m1kapp/kit/server";
531
+
532
+ export const GET = handler(async () => {
533
+ const { ok: success, data, error } = await safely(() => db.users.findFirstOrThrow());
534
+ if (!success) return serverError("DB 조회 실패");
535
+ return ok(data);
536
+ });
537
+ ```
538
+
539
+ ---
540
+
541
+ ## Utils
542
+
543
+ 순수 함수 — 의존성 없음, 어디서나 import.
544
+
545
+ ```ts
546
+ import { relativeTime, formatNumber, formatPrice, cn } from "@m1kapp/kit";
547
+
548
+ // 상대 시간
549
+ relativeTime(post.createdAt) // "3분 전", "어제", "2025. 4. 19."
550
+
551
+ // 숫자 포맷
552
+ formatNumber(1_500) // "1.5천"
553
+ formatNumber(15_000) // "1.5만"
554
+ formatNumber(150_000_000) // "1.5억"
555
+
556
+ // 가격 포맷
557
+ formatPrice(9_900) // "₩9,900"
558
+ formatPrice(9.99, "USD") // "$9.99"
559
+
560
+ // 조건부 클래스
561
+ cn("base", isActive && "active", err && "border-red-500")
562
+ // → "base active"
563
+ ```
564
+
565
+ ## Hooks
566
+
567
+ ```ts
568
+ import { useDebounce, useFormSubmit, useInView, useLocalStorage } from "@m1kapp/kit";
569
+ ```
570
+
571
+ ### useDebounce
572
+
573
+ ```ts
574
+ const [query, setQuery] = useState("");
575
+ const debouncedQuery = useDebounce(query, 300);
576
+
577
+ useEffect(() => {
578
+ if (debouncedQuery) searchAPI(debouncedQuery); // 타이핑 멈출 때만 실행
579
+ }, [debouncedQuery]);
580
+ ```
581
+
582
+ ### useFormSubmit
583
+
584
+ 모든 form handler의 loading / error / try-catch / finally 보일러플레이트를 제거합니다.
585
+
586
+ ```ts
587
+ const { submit, loading, error, data, reset } = useFormSubmit(
588
+ async (url: string) => api.post<Site>("/api/sites", { url }),
589
+ { onSuccess: (site) => router.push(`/sites/${site.id}`) }
590
+ );
591
+
592
+ <form onSubmit={e => { e.preventDefault(); submit(inputValue); }}>
593
+ <input value={inputValue} onChange={...} />
594
+ {error && <p className="text-red-500 text-sm">{error.message}</p>}
595
+ <button disabled={loading}>{loading ? "등록 중…" : "등록"}</button>
596
+ </form>
597
+ ```
598
+
599
+ ### useInView
600
+
601
+ 무한스크롤 트리거, 레이지 로드, 등장 애니메이션에 사용.
602
+
603
+ ```tsx
604
+ const { ref, inView } = useInView({ threshold: 0.1, once: true });
605
+
606
+ useEffect(() => {
607
+ if (inView) fetchNextPage();
608
+ }, [inView]);
609
+
610
+ return (
611
+ <div>
612
+ {posts.map(p => <PostCard key={p.id} post={p} />)}
613
+ <div ref={ref} /> {/* 리스트 맨 아래 센티넬 */}
614
+ </div>
615
+ );
616
+ ```
617
+
618
+ ### useLocalStorage
619
+
620
+ 새로고침 후에도 유지되는 로컬 상태. SSR 안전.
621
+
622
+ ```ts
623
+ const [theme, setTheme, removeTheme] = useLocalStorage("theme", "light");
624
+
625
+ setTheme("dark"); // localStorage에 저장
626
+ removeTheme(); // localStorage에서 삭제, 초기값으로 복원
627
+ ```