@ilokesto/utilinent 0.0.15 → 0.0.16

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 CHANGED
@@ -1,1474 +1,283 @@
1
- ![ilokestoUtilinent](https://github.com/user-attachments/assets/d0d67fc9-f4fc-4e9b-bf3c-e5bc96cfabf6)
2
-
3
-
4
- [![Build Size](https://img.shields.io/bundlephobia/minzip/utilinent?label=bundle%20size&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/result?p=utilinent)
5
- [![Version](https://img.shields.io/npm/v/utilinent?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/utilinent)
6
- [![Downloads](https://img.shields.io/npm/dt/utilinent.svg?style=flat&colorA=000000&colorB=000000)](https://www.npmjs.com/package/utilinent)
7
-
8
- # Utilinent
9
-
10
- **React를 위한 타입 안전하고 선언적인 Control Flow 라이브러리**
11
-
12
- React에서 조건부 렌더링과 반복 렌더링은 필수적이지만, 복잡해질수록 코드가 읽기 어려워지고 타입 안전성을 보장하기 어려워집니다. Utilinent는 SolidJS의 우아한 Control Flow API에서 영감을 받아, React 개발자들에게 더 나은 개발 경험을 제공합니다.
13
-
14
- ## 주요 특징
15
-
16
- - **🎯 타입 안전성**: TypeScript와 완벽하게 통합되어 컴파일 타임에 오류를 잡아냅니다
17
- - **📖 가독성**: 복잡한 조건문을 선언적인 컴포넌트로 변환하여 코드를 이해하기 쉽게 만듭니다
18
- - **🔄 일관성**: 팀 전체가 동일한 패턴을 사용하여 코드 스타일을 통일합니다
19
- - **🚀 성능**: 불필요한 리렌더링을 방지하고 최적화된 렌더링을 제공합니다
20
- - **📦 경량**: 최소한의 번들 크기로 프로젝트에 부담을 주지 않습니다
21
-
22
- ## 🚀 설치 및 사용법
23
-
24
- ```bash
25
- npm install utilinent
26
- ```
27
-
28
- ```typescript
29
- import { Show, For, createSwitcher, OptionalWrapper, Mount, Repeat, Observer, Slacker } from "utilinent"
30
- ```
31
-
32
- ## 📋 목차
33
-
34
- - [Show - 조건부 렌더링](#show---조건부-렌더링)
35
- - [For - 배열 렌더링](#for---배열-렌더링)
36
- - [createSwitcher - 타입 안전한 분기 처리](#createswitcher---타입-안전한-분기-처리)
37
- - [OptionalWrapper - 조건부 래퍼](#optionalwrapper---조건부-래퍼)
38
- - [Mount - 클라이언트 사이드 렌더링](#mount---클라이언트-사이드-렌더링)
39
- - [Repeat - 횟수 기반 반복 렌더링](#repeat---횟수-기반-반복-렌더링)
40
- - [Observer - 뷰포트 감지](#observer---뷰포트-감지)
41
- - [Slacker - 스마트 지연 로딩](#slacker---스마트-지연-로딩)
42
-
43
- ---
44
-
45
- # Show - 조건부 렌더링
46
-
47
- **기존 방식의 문제점**
48
-
49
- React에서 조건부 렌더링을 할 때 삼항 연산자(`? :`), AND 연산자(`&&`), OR 연산자(`||`) 등을 혼용하면 코드 스타일이 일관되지 않습니다. 특히 중첩된 조건이나 복잡한 로직에서는 가독성이 크게 떨어집니다.
50
-
51
- ```tsx
52
- // ❌ 일관성 없는 기존 방식
53
- {isLoading && <Spinner />}
54
- {user ? <UserProfile user={user} /> : <LoginButton />}
55
- {data || <EmptyState />}
56
- ```
57
-
58
- **Show 컴포넌트의 해결책**
59
-
60
- `Show` 컴포넌트는 모든 조건부 렌더링을 일관된 방식으로 처리하며, TypeScript의 타입 가드 기능을 활용해 안전한 타입 추론을 제공합니다.
61
-
62
- ```tsx
63
- interface ShowProps<T> {
64
- when: T; // 조건값 (truthy/falsy 체크)
65
- fallback?: React.ReactNode; // 조건이 false일 렌더링할 내용
66
- children: React.ReactNode | ((item: NonNullable<T>) => React.ReactNode); // 조건이 true일 때의 내용
67
- }
68
- ```
69
-
70
- **✅ Show를 사용한 개선된 방식**
71
-
72
- ```tsx
73
- // 간단한 조건부 렌더링
74
- <Show when={isLoading}>
75
- <Spinner />
76
- </Show>
77
-
78
- // fallback과 함께
79
- <Show when={user} fallback={<LoginButton />}>
80
- {(user) => <UserProfile user={user} />} {/* user는 자동으로 NonNullable 타입으로 추론 */}
81
- </Show>
82
-
83
- // 기본값 표시
84
- <Show when={data} fallback={<EmptyState />}>
85
- {(data) => <DataView data={data} />}
86
- </Show>
87
- ```
88
-
89
- **🎯 타입 안전성의 장점**
90
-
91
- ```tsx
92
- interface User {
93
- id: number;
94
- name: string;
95
- }
96
-
97
- const user: User | null = getUser();
98
-
99
- <Show when={user}>
100
- {(user) => (
101
- <div>
102
- {/* TypeScript가 user가 User 타입임을 보장 */}
103
- <h1>{user.name}</h1> {/* 안전함 */}
104
- <p>ID: {user.id}</p> {/* ✅ 안전함 */}
105
- </div>
106
- )}
107
- </Show>
108
- ```
109
-
110
- ---
111
-
112
- # For - 배열 렌더링
113
-
114
- **기존 방식의 문제점**
115
-
116
- React에서 배열을 렌더링할 때 `Array.map()`을 사용하는 것은 일반적이지만, 빈 배열이나 `null`/`undefined` 처리를 위해 추가적인 조건문이 필요하여 코드가 복잡해집니다.
117
-
118
- ```tsx
119
- // ❌ 복잡한 기존 방식
120
- {users && users.length > 0
121
- ? users.map(user => <UserCard key={user.id} user={user} />)
122
- : <EmptyUserList />
123
- }
124
- ```
125
-
126
- **For 컴포넌트의 해결책**
127
-
128
-
129
- `For` 컴포넌트는 배열 렌더링과 예외 상황 처리를 하나의 컴포넌트에서 깔끔하게 해결합니다.
130
-
131
- ```tsx
132
- interface ForProps<T extends Array<unknown>> {
133
- each: T | null | undefined; // 렌더링할 배열
134
- fallback?: React.ReactNode; // 배열이 비어있거나 null일 때의 대체 내용
135
- children: (item: T[number], index: number) => React.ReactNode; // 각 아이템을 렌더링하는 함수
136
- }
137
- ```
138
-
139
- **✅ For를 사용한 개선된 방식**
140
-
141
- ```tsx
142
- // 기본 배열 렌더링
143
- <For each={users} fallback={<EmptyUserList />}>
144
- {(user, index) => (
145
- <UserCard key={user.id} user={user} index={index} />
146
- )}
147
- </For>
148
-
149
- // API 데이터와 함께 (null/undefined 안전 처리)
150
- const { data: userList } = useQuery({ ... }); // userList는 User[] | undefined
151
-
152
- <For each={userList} fallback={<LoadingSpinner />}>
153
- {(user) => (
154
- <div key={user.id}>
155
- <h3>{user.name}</h3>
156
- <p>{user.email}</p>
157
- </div>
158
- )}
159
- </For>
160
- ```
161
-
162
- **🎯 타입 안전성의 장점**
163
-
164
- ```tsx
165
- interface Product {
166
- id: string;
167
- name: string;
168
- price: number;
169
- }
170
-
171
- const products: Product[] | null = getProducts();
172
-
173
- <For each={products} fallback={<div>상품이 없습니다</div>}>
174
- {(product, index) => (
175
- <div key={product.id}>
176
- {/* TypeScript가 product가 Product 타입임을 보장 */}
177
- <h4>{product.name}</h4> {/* ✅ 자동완성 지원 */}
178
- <span>${product.price}</span> {/* ✅ 타입 체크 */}
179
- </div>
180
- )}
181
- </For>
182
- ```
183
-
184
- ---
185
-
186
- # createSwitcher - 타입 안전한 분기 처리
187
-
188
- **기존 방식의 문제점**
189
- 복잡한 유니온 타입에서 특정 필드 값에 따라 다른 컴포넌트를 렌더링할 때, 기존의 `switch`문이나 연속된 `if`문은 코드 복잡성과 실수 가능성 문제를 야기합니다.
190
-
191
- ```tsx
192
- type ApiResponse =
193
- | { status: "loading" }
194
- | { status: "success", data: User[], count: number }
195
- | { status: "error", message: string, code: number };
196
-
197
- // 복잡하고 실수하기 쉬운 기존 방식
198
- function renderApiResponse(response: ApiResponse) {
199
- switch (response.status) {
200
- case 'loading':
201
- return <Spinner />;
202
- case 'success':
203
- // 복잡한 JSX가 switch 문 안에 섞여있음
204
- return (
205
- <div>
206
- <h2>성공! ({response.count}개 항목)</h2>
207
- {response.data.map(user => <UserCard key={user.id} user={user} />)}
208
- </div>
209
- );
210
- case 'error':
211
- // 다른 복잡한 JSX
212
- return (
213
- <div className="error">
214
- <h3>오류 발생 (코드: {response.code})</h3>
215
- <p>{response.message}</p>
216
- <button onClick={retry}>다시 시도</button>
217
- </div>
218
- );
219
- default:
220
- return null; // ❌ fallback 처리를 까먹기 쉬움
221
- }
222
- }
223
- ```
224
-
225
- **createSwitcher의 해결책**
226
- `createSwitcher`는 데이터 객체의 구조를 분석하여 타입 안전한 Switch/Match 컴포넌트를 생성합니다. 각 case에서 정확한 타입 추론을 제공하여 런타임 오류를 컴파일 타임에 방지합니다.
227
-
228
- ```tsx
229
- function createSwitcher<T, K extends LiteralKeys<T>>(data: T): {
230
- Switch: ({
231
- when: K, // 분기할 필드명
232
- children: Array<ReactElement>, // Match 컴포넌트들
233
- fallback?: React.ReactNode // 매칭되는 case가 없을 때의 대체 내용
234
- }) => React.ReactNode;
235
-
236
- Match: <V extends ExtractValues<T, K>>({
237
- case: V, // 매칭할 값
238
- children: (props: ExtractByKeyValue<T, K, V>) => React.ReactNode // 해당 case의 정확한 타입 제공
239
- }) => React.ReactNode;
240
- }
241
- ```
242
-
243
- **🧠 작동 원리**
244
- - `createSwitcher`는 유니온 타입 `T`에서 리터럴 값을 가진 키들을 `K`로 추출합니다
245
- - 리터럴 키가 하나만 있으면 자동 추론, 여러 개면 명시적 타입 지정이 필요합니다
246
- - `Match` 컴포넌트는 해당 case에 정확히 매칭되는 타입을 children 함수에 제공합니다
247
-
248
- ## 🔍 사용 사례들
249
-
250
- ### 케이스 1: 서로 다른 필드를 가진 유니온 타입
251
-
252
- ```tsx
253
- type ApiResponse =
254
- | { status: "loading" }
255
- | { status: "success", data: User[] }
256
- | { status: "error", message: string };
257
-
258
- function ApiStatus({ response }: { response: ApiResponse }) {
259
- const { Switch, Match } = createSwitcher(response); // K는 자동으로 "status"로 추론
260
-
261
- return (
262
- <Switch when="status" fallback={<div>알 없는 상태</div>}>
263
- <Match case="loading">
264
- {(props) => <Spinner />}
265
- {/* props: { status: "loading" } */}
266
- </Match>
267
-
268
- <Match case="success">
269
- {(props) => <UserList users={props.data} />}
270
- {/* props: { status: "success", data: User[] } - data 필드 사용 가능! */}
271
- </Match>
272
-
273
- <Match case="error">
274
- {(props) => <ErrorAlert message={props.message} />}
275
- {/* props: { status: "error", message: string } - message 필드 사용 가능! */}
276
- </Match>
277
- </Switch>
278
- );
279
- }
280
- ```
281
-
282
- ### 케이스 2: 동일한 필드를 가진 유니온 타입
283
-
284
- ```tsx
285
- type NotificationState =
286
- | { status: "pending", message: string }
287
- | { status: "sent", message: string }
288
- | { status: "failed", message: string };
289
-
290
- function NotificationStatus({ notification }: { notification: NotificationState }) {
291
- const { Switch, Match } = createSwitcher(notification); // K는 자동으로 "status"로 추론
292
-
293
- return (
294
- <Switch when="status">
295
- <Match case="pending">
296
- {(props) => (
297
- <div className="notification-pending">
298
- <ClockIcon />
299
- <span>{props.message}</span>
300
- </div>
301
- )}
302
- </Match>
303
-
304
- <Match case="sent">
305
- {(props) => (
306
- <div className="notification-success">
307
- <CheckIcon />
308
- <span>{props.message}</span>
309
- </div>
310
- )}
311
- </Match>
312
-
313
- <Match case="failed">
314
- {(props) => (
315
- <div className="notification-error">
316
- <XIcon />
317
- <span>{props.message}</span>
318
- </div>
319
- )}
320
- </Match>
321
- </Switch>
322
- );
323
- }
324
- ```
325
-
326
- ### 케이스 3: 여러 리터럴 필드가 있는 경우 (명시적 타입 지정 필요)
327
-
328
- ```tsx
329
- type ComplexState =
330
- | { status: "loading", priority: "high" }
331
- | { status: "success", priority: "low" }
332
- | { status: "error", priority: "medium" };
333
-
334
- function ComplexStatus({ state }: { state: ComplexState }) {
335
- // status와 priority 모두 리터럴 타입이므로 명시적으로 지정
336
- const { Switch, Match } = createSwitcher<ComplexState, "status">(state);
337
-
338
- return (
339
- <Switch when="status" fallback={<div>알 수 없는 상태</div>}>
340
- <Match case="loading">
341
- {(props) => (
342
- <div className={`loading priority-${props.priority}`}>
343
- <Spinner />
344
- <span>로딩 중... (우선순위: {props.priority})</span>
345
- </div>
346
- )}
347
- </Match>
348
-
349
- <Match case="success">
350
- {(props) => (
351
- <div className={`success priority-${props.priority}`}>
352
- <CheckIcon />
353
- <span>완료! (우선순위: {props.priority})</span>
354
- </div>
355
- )}
356
- </Match>
357
-
358
- <Match case="error">
359
- {(props) => (
360
- <div className={`error priority-${props.priority}`}>
361
- <ErrorIcon />
362
- <span>오류 발생 (우선순위: {props.priority})</span>
363
- </div>
364
- )}
365
- </Match>
366
- </Switch>
367
- );
368
- }
369
- ```
370
-
371
- ---
372
-
373
- # OptionalWrapper - 조건부 래퍼
374
-
375
- **기존 방식의 문제점**
376
- 특정 조건에 따라 요소를 다른 컴포넌트로 감싸야 할 때, 기존 방식은 중복 코드를 유발하거나 가독성을 해칩니다.
377
-
378
- ```tsx
379
- // ❌ 중복 코드가 발생하는 기존 방식
380
- {isClickable ? (
381
- <button onClick={handleClick}>
382
- <img src={image} alt="thumbnail" />
383
- </button>
384
- ) : (
385
- <img src={image} alt="thumbnail" /> // 중복!
386
- )}
387
- ```
388
-
389
- **OptionalWrapper의 해결책**
390
- `OptionalWrapper`는 조건에 따라 래퍼를 적용하거나 생략하는 패턴을 간단하고 재사용 가능하게 만듭니다.
391
-
392
- ```tsx
393
- interface OptionalWrapperProps {
394
- when: boolean; // 래퍼를 적용할 조건
395
- children: React.ReactNode; // 감싸질 내용
396
- wrapper: (children: React.ReactNode) => React.ReactNode; // 조건이 true일 때 적용할 래퍼 함수
397
- }
398
- ```
399
-
400
- **✅ OptionalWrapper를 사용한 개선된 방식**
401
- ```tsx
402
- // 조건부 링크 래핑
403
- <OptionalWrapper
404
- when={hasUrl}
405
- wrapper={(children) => <a href={url} target="_blank">{children}</a>}
406
- >
407
- <img src={image} alt="thumbnail" />
408
- </OptionalWrapper>
409
-
410
- // 조건부 버튼 래핑
411
- <OptionalWrapper
412
- when={isClickable}
413
- wrapper={(children) => (
414
- <button onClick={handleClick} className="clickable-wrapper">
415
- {children}
416
- </button>
417
- )}
418
- >
419
- <ProductCard product={product} />
420
- </OptionalWrapper>
421
-
422
- // 조건부 스타일 컨테이너
423
- <OptionalWrapper
424
- when={isHighlighted}
425
- wrapper={(children) => (
426
- <div className="highlight-border p-4 bg-yellow-100">
427
- {children}
428
- </div>
429
- )}
430
- >
431
- <ContentBlock content={content} />
432
- </OptionalWrapper>
433
- ```
434
-
435
- **🎯 실제 사용 사례**
436
- ```tsx
437
- function MediaCard({ media, isInteractive }: { media: Media, isInteractive: boolean }) {
438
- return (
439
- <OptionalWrapper
440
- when={isInteractive}
441
- wrapper={(children) => (
442
- <button
443
- className="media-button"
444
- onClick={() => openModal(media)}
445
- aria-label={`${media.title} 상세보기`}
446
- >
447
- {children}
448
- </button>
449
- )}
450
- >
451
- <div className="media-content">
452
- <img src={media.thumbnail} alt={media.title} />
453
- <h3>{media.title}</h3>
454
- <p>{media.description}</p>
455
- </div>
456
- </OptionalWrapper>
457
- );
458
- }
459
- ```
460
-
461
- ---
462
-
463
- # Mount - 클라이언트 사이드 렌더링
464
-
465
- **기존 방식의 문제점**
466
- Next.js나 SSR 환경에서 DOM이 마운트된 후에만 실행되어야 하는 코드를 처리할 때, `useEffect`와 `useState`를 반복적으로 사용하게 되어 보일러플레이트 코드가 많아집니다.
467
-
468
- ```tsx
469
- // ❌ 반복되는 보일러플레이트 코드
470
- function ClientOnlyComponent() {
471
- const [isMounted, setIsMounted] = useState(false);
472
- const [content, setContent] = useState<ReactNode>("Loading...");
473
-
474
- useEffect(() => {
475
- setIsMounted(true);
476
- const loadContent = async () => {
477
- await someAsyncOperation();
478
- setContent(<ActualContent />);
479
- };
480
- loadContent();
481
- }, []);
482
-
483
- if (!isMounted) {
484
- return <div>Loading...</div>;
485
- }
486
-
487
- return <>{content}</>;
488
- }
489
- ```
490
-
491
- **Mount 컴포넌트의 해결책**
492
- `Mount` 컴포넌트는 클라이언트 사이드 렌더링과 비동기 작업을 간단하고 선언적으로 처리할 수 있게 해줍니다.
493
-
494
- ```tsx
495
- interface MountProps {
496
- fallback?: React.ReactNode; // 마운트 전 또는 로딩 중 표시할 내용
497
- children: React.ReactNode | (() => React.ReactNode | Promise<ReactNode>); // 마운트 후 렌더링할 내용
498
- }
499
- ```
500
-
501
- **✅ Mount를 사용한 개선된 방식**
502
-
503
- **기본 클라이언트 사이드 렌더링**
504
- ```tsx
505
- // 간단한 클라이언트 전용 컴포넌트
506
- <Mount fallback={<div>Loading...</div>}>
507
- <ClientOnlyWidget />
508
- </Mount>
509
-
510
- // 브라우저 전용 API 사용
511
- <Mount fallback={<div>Initializing...</div>}>
512
- <GeolocationComponent />
513
- </Mount>
514
- ```
515
-
516
- **비동기 작업과 함께**
517
- ```tsx
518
- // 기존 방식 (복잡함)
519
- function OldWay() {
520
- const [content, setContent] = useState("Loading...");
521
-
522
- useEffect(() => {
523
- const loadContent = async () => {
524
- await new Promise(resolve => setTimeout(resolve, 1000));
525
- setContent("Loaded!");
526
- };
527
- loadContent();
528
- }, []);
529
-
530
- return <div>{content}</div>;
531
- }
532
-
533
- // ✅ Mount 사용 (간단함)
534
- function NewWay() {
535
- return (
536
- <Mount fallback={<div>Loading...</div>}>
537
- {async () => {
538
- await new Promise(resolve => setTimeout(resolve, 1000));
539
- return <div>Loaded!</div>;
540
- }}
541
- </Mount>
542
- );
543
- }
544
- ```
545
-
546
- **🎯 실제 사용 사례**
547
- ```tsx
548
- // 차트 라이브러리 (클라이언트 전용)
549
- <Mount fallback={<ChartSkeleton />}>
550
- {async () => {
551
- const chartData = await fetchChartData();
552
- return <Chart data={chartData} />;
553
- }}
554
- </Mount>
555
-
556
- // 지도 컴포넌트
557
- <Mount fallback={<MapPlaceholder />}>
558
- <MapComponent coordinates={coordinates} />
559
- </Mount>
560
-
561
- // 테마 의존적 컴포넌트
562
- <Mount fallback={<div>테마 로딩 중...</div>}>
563
- {() => {
564
- const theme = getClientTheme();
565
- return <ThemedComponent theme={theme} />;
566
- }}
567
- </Mount>
568
- ```
569
-
570
- ---
571
-
572
- # Repeat - 횟수 기반 반복 렌더링
573
-
574
- **기존 방식의 문제점**
575
-
576
- 특정 횟수만큼 컴포넌트를 반복 렌더링할 때, 기존 방식은 불필요한 배열을 생성하거나 복잡한 로직을 작성해야 합니다.
577
-
578
- ```tsx
579
- // ❌ 불필요한 배열 생성
580
- {Array(5).fill(null).map((_, index) => (
581
- <SkeletonCard key={index} />
582
- ))}
583
-
584
- // ❌ 복잡한 반복 로직
585
- {(() => {
586
- const items = [];
587
- for (let i = 0; i < starCount; i++) {
588
- items.push(<Star key={i} filled={i < rating} />);
589
- }
590
- return items;
591
- })()}
592
- ```
593
-
594
- **Repeat 컴포넌트의 해결책**
595
-
596
- `Repeat` 컴포넌트는 횟수 기반 반복 렌더링을 간단하고 직관적으로 처리할 수 있게 해줍니다.
597
-
598
- ```tsx
599
- interface RepeatProps {
600
- times: number; // 반복 횟수
601
- fallback?: React.ReactNode; // times가 0 이하일 때의 대체 내용
602
- children: (index: number) => React.ReactNode; // 각 반복에서 렌더링할 함수
603
- }
604
- ```
605
-
606
- **✅ Repeat을 사용한 개선된 방식**
607
-
608
- **기본 반복 렌더링**
609
- ```tsx
610
- // 스켈레톤 UI 생성
611
- <Repeat times={5} fallback={<div>로딩할 항목이 없습니다</div>}>
612
- {(index) => <SkeletonCard key={index} delay={index * 200} />}
613
- </Repeat>
614
-
615
- // 평점 시스템
616
- <Repeat times={5}>
617
- {(index) => (
618
- <Star
619
- key={index}
620
- filled={index < rating}
621
- onClick={() => setRating(index + 1)}
622
- />
623
- )}
624
- </Repeat>
625
-
626
- // 페이지네이션 번호
627
- <Repeat times={totalPages}>
628
- {(index) => {
629
- const pageNumber = index + 1;
630
- return (
631
- <PageButton
632
- key={pageNumber}
633
- page={pageNumber}
634
- active={currentPage === pageNumber}
635
- onClick={() => setCurrentPage(pageNumber)}
636
- />
637
- );
638
- }}
639
- </Repeat>
640
- ```
641
-
642
- **🎯 실제 사용 사례**
643
-
644
- ```tsx
645
- // 로딩 스켈레톤
646
- function ProductListSkeleton() {
647
- return (
648
- <div className="grid grid-cols-3 gap-4">
649
- <Repeat times={9}>
650
- {(index) => (
651
- <div key={index} className="animate-pulse">
652
- <div className="bg-gray-300 h-48 rounded-lg mb-4"></div>
653
- <div className="bg-gray-300 h-4 rounded mb-2"></div>
654
- <div className="bg-gray-300 h-4 rounded w-2/3"></div>
655
- </div>
656
- )}
657
- </Repeat>
658
- </div>
659
- );
660
- }
661
-
662
- // 진행률 표시기
663
- function ProgressDots({ total, current }: { total: number, current: number }) {
664
- return (
665
- <div className="flex space-x-2">
666
- <Repeat times={total}>
667
- {(index) => (
668
- <div
669
- key={index}
670
- className={`w-3 h-3 rounded-full ${
671
- index < current ? 'bg-blue-500' : 'bg-gray-300'
672
- }`}
673
- />
674
- )}
675
- </Repeat>
676
- </div>
677
- );
678
- }
679
-
680
- // 메뉴 아이템 생성
681
- function NavigationMenu({ menuCount }: { menuCount: number }) {
682
- return (
683
- <nav className="flex space-x-4">
684
- <Repeat times={menuCount} fallback={<div>메뉴가 없습니다</div>}>
685
- {(index) => {
686
- const menuItem = menuItems[index];
687
- return (
688
- <a
689
- key={index}
690
- href={menuItem?.href}
691
- className="px-4 py-2 text-gray-700 hover:text-blue-600"
692
- >
693
- {menuItem?.label || `메뉴 ${index + 1}`}
694
- </a>
695
- );
696
- }}
697
- </Repeat>
698
- </nav>
699
- );
700
- }
701
- ```
702
-
703
- **🔧 유용한 패턴들**
704
-
705
- ```tsx
706
- // 조건부 반복 (0일 때 fallback 표시)
707
- <Repeat times={itemCount} fallback={<EmptyState />}>
708
- {(index) => <Item key={index} data={items[index]} />}
709
- </Repeat>
710
-
711
- // 지연 애니메이션
712
- <Repeat times={6}>
713
- {(index) => (
714
- <div
715
- key={index}
716
- className="fade-in"
717
- style={{ animationDelay: `${index * 100}ms` }}
718
- >
719
- <Card />
720
- </div>
721
- )}
722
- </Repeat>
723
-
724
- // 인덱스 기반 스타일링
725
- <Repeat times={4}>
726
- {(index) => (
727
- <div
728
- key={index}
729
- className={`col-span-${index % 2 === 0 ? '2' : '1'}`}
730
- >
731
- <GridItem />
732
- </div>
733
- )}
734
- </Repeat>
735
- ```
736
- ---
737
-
738
- # Observer - 뷰포트 감지
739
-
740
- **기존 방식의 문제점**
741
- 뷰포트에 요소가 들어오거나 나가는 것을 감지하기 위해 직접 `IntersectionObserver` API를 사용하면 보일러플레이트 코드가 많아지고, cleanup 처리를 놓치기 쉽습니다.
742
-
743
- ```tsx
744
- // ❌ 복잡한 기존 방식
745
- function LazyImage({ src, alt }: { src: string, alt: string }) {
746
- const [isVisible, setIsVisible] = useState(false);
747
- const [hasLoaded, setHasLoaded] = useState(false);
748
- const ref = useRef<HTMLDivElement>(null);
749
-
750
- useEffect(() => {
751
- const element = ref.current;
752
- if (!element) return;
753
-
754
- const observer = new IntersectionObserver(
755
- ([entry]) => {
756
- if (entry.isIntersecting && !hasLoaded) {
757
- setIsVisible(true);
758
- setHasLoaded(true);
759
- observer.unobserve(element);
760
- }
761
- },
762
- { threshold: 0.1 }
763
- );
764
-
765
- observer.observe(element);
766
- return () => observer.unobserve(element);
767
- }, [hasLoaded]);
768
-
769
- return (
770
- <div ref={ref}>
771
- {isVisible ? (
772
- <img src={src} alt={alt} />
773
- ) : (
774
- <div className="w-full h-64 bg-gray-200" />
775
- )}
776
- </div>
777
- );
778
- }
779
- ```
780
-
781
- **Observer 컴포넌트의 해결책**
782
-
783
- `Observer` 컴포넌트는 뷰포트 감지 로직을 간단하고 재사용 가능하게 만들어 다양한 최적화 패턴을 쉽게 구현할 수 있게 해줍니다.
784
-
785
- ```tsx
786
- interface ObserverProps {
787
- children: React.ReactNode | ((isIntersecting: boolean) => React.ReactNode);
788
- fallback?: React.ReactNode; // 뷰포트에 보이지 않을 때 표시할 내용
789
- threshold?: number | number[]; // 교차 임계값 (0.0 ~ 1.0)
790
- rootMargin?: string; // 루트 마진
791
- triggerOnce?: boolean; // 한 번만 트리거할지 여부
792
- onIntersect?: (isIntersecting: boolean, entry: IntersectionObserverEntry) => void; // 교차 이벤트 콜백
793
- }
794
- ```
795
-
796
- **✅ Observer를 사용한 개선된 방식**
797
-
798
- **지연 로딩 (Lazy Loading)**
799
-
800
- ```tsx
801
- // fallback을 활용한 깔끔한 지연 로딩
802
- <Observer
803
- threshold={0.1}
804
- triggerOnce={true}
805
- fallback={<div className="w-full h-64 bg-gray-200 animate-pulse" />}
806
- >
807
- <img src={imageUrl} alt="지연 로딩 이미지" loading="lazy" />
808
- </Observer>
809
-
810
- // 함수형 children으로 더 세밀한 제어
811
- <Observer
812
- threshold={0.1}
813
- triggerOnce={true}
814
- fallback={<ImageSkeleton />}
815
- >
816
- {(isIntersecting) =>
817
- isIntersecting ? (
818
- <img src={imageUrl} alt="지연 로딩 이미지" loading="lazy" />
819
- ) : null
820
- }
821
- </Observer>
822
-
823
- // Show 컴포넌트와 함께 사용하여 조건부 활성화
824
- <Show when={shouldLoad}>
825
- <Observer
826
- threshold={0.2}
827
- triggerOnce={true}
828
- fallback={<ComponentSkeleton />}
829
- >
830
- <HeavyComponent data={data} />
831
- </Observer>
832
- </Show>
833
- ```
834
-
835
- **무한 스크롤**
836
- ```tsx
837
- // 무한 스크롤 트리거
838
- <Observer
839
- threshold={1.0}
840
- rootMargin="0px 0px 200px 0px" // 하단 200px 전에 트리거
841
- onIntersect={(isIntersecting) => {
842
- if (isIntersecting && hasNextPage && !isLoading) {
843
- loadMoreItems();
844
- }
845
- }}
846
- >
847
- <div className="h-20 flex items-center justify-center">
848
- {isLoading ? <Spinner /> : "더 보기"}
849
- </div>
850
- </Observer>
851
-
852
- // 페이지네이션과 함께
853
- <For each={items}>
854
- {(item) => <ItemCard key={item.id} item={item} />}
855
- </For>
856
-
857
- <Show when={hasNextPage}>
858
- <Observer
859
- threshold={0.5}
860
- onIntersect={(isIntersecting) => {
861
- if (isIntersecting) loadNextPage();
862
- }}
863
- >
864
- <LoadMoreButton />
865
- </Observer>
866
- </Show>
867
- ```
868
-
869
- **애니메이션 트리거**
870
- ```tsx
871
- // 뷰포트 진입 시 애니메이션
872
- <Observer
873
- threshold={0.3}
874
- triggerOnce={true}
875
- fallback={
876
- <div className="opacity-0 translate-y-10 transition-all duration-1000">
877
- <FeatureCard />
878
- </div>
879
- }
880
- >
881
- <div className="opacity-100 translate-y-0 transition-all duration-1000">
882
- <FeatureCard />
883
- </div>
884
- </Observer>
885
-
886
- // 함수형 children으로 더 세밀한 애니메이션 제어
887
- <Observer threshold={0.3} triggerOnce={true}>
888
- {(isIntersecting) => (
889
- <div className={`transition-all duration-1000 ${
890
- isIntersecting
891
- ? 'opacity-100 translate-y-0'
892
- : 'opacity-0 translate-y-10'
893
- }`}>
894
- <FeatureCard />
895
- </div>
896
- )}
897
- </Observer>
898
-
899
- // 순차적 애니메이션
900
- <Repeat times={features.length}>
901
- {(index) => (
902
- <Observer
903
- key={index}
904
- threshold={0.5}
905
- triggerOnce={true}
906
- fallback={
907
- <div className="opacity-0 scale-95 transition-all duration-700">
908
- <FeatureItem feature={features[index]} />
909
- </div>
910
- }
911
- >
912
- <div
913
- className="opacity-100 scale-100 transition-all duration-700"
914
- style={{ transitionDelay: `${index * 100}ms` }}
915
- >
916
- <FeatureItem feature={features[index]} />
917
- </div>
918
- </Observer>
919
- )}
920
- </Repeat>
921
- ```
922
-
923
- **🎯 실제 사용 사례**
924
-
925
- ```tsx
926
- // 갤러리 이미지 지연 로딩
927
- function ImageGallery({ images }: { images: ImageData[] }) {
928
- return (
929
- <div className="grid grid-cols-3 gap-4">
930
- <For each={images}>
931
- {(image) => (
932
- <Observer
933
- key={image.id}
934
- threshold={0.1}
935
- triggerOnce={true}
936
- fallback={
937
- <div className="aspect-square overflow-hidden rounded-lg">
938
- <div className="w-full h-full bg-gray-300 animate-pulse" />
939
- </div>
940
- }
941
- >
942
- <div className="aspect-square overflow-hidden rounded-lg">
943
- <img
944
- src={image.url}
945
- alt={image.alt}
946
- className="w-full h-full object-cover"
947
- loading="lazy"
948
- />
949
- </div>
950
- </Observer>
951
- )}
952
- </For>
953
- </div>
954
- );
955
- }
956
-
957
- // 뷰포트 진입 분석
958
- function AnalyticsSection({ sectionId, children }: {
959
- sectionId: string,
960
- children: React.ReactNode
961
- }) {
962
- return (
963
- <Observer
964
- threshold={0.5}
965
- triggerOnce={true}
966
- onIntersect={(isIntersecting, entry) => {
967
- if (isIntersecting) {
968
- analytics.track('section_viewed', {
969
- sectionId,
970
- visibilityRatio: entry?.intersectionRatio,
971
- viewportHeight: window.innerHeight
972
- });
973
- }
974
- }}
975
- >
976
- {children}
977
- </Observer>
978
- );
979
- }
980
-
981
- // 진행률 표시기 (entry가 필요한 경우는 onIntersect에서 처리)
982
- function ScrollProgressIndicator() {
983
- const [progress, setProgress] = useState(0);
984
-
985
- return (
986
- <Observer
987
- threshold={Array.from({length: 101}, (_, i) => i / 100)} // 0.00 ~ 1.00
988
- rootMargin="-50% 0px -50% 0px"
989
- onIntersect={(isIntersecting, entry) => {
990
- setProgress(entry.intersectionRatio * 100);
991
- }}
992
- >
993
- <div className="fixed top-0 left-0 w-full h-2 bg-gray-200 z-50">
994
- <div
995
- className="h-full bg-blue-500 transition-all duration-300"
996
- style={{ width: `${progress}%` }}
997
- />
998
- </div>
999
- </Observer>
1000
- );
1001
- }
1002
-
1003
- // 조건부 로딩 - Show 컴포넌트와 함께 사용
1004
- function ConditionalContent({ shouldLoad, children }: {
1005
- shouldLoad: boolean,
1006
- children: React.ReactNode
1007
- }) {
1008
- return (
1009
- <Show when={shouldLoad} fallback={<div>로딩이 비활성화되었습니다</div>}>
1010
- <Observer
1011
- threshold={0.1}
1012
- fallback={<ContentPlaceholder />}
1013
- >
1014
- {children}
1015
- </Observer>
1016
- </Show>
1017
- );
1018
- }
1019
- ```
1020
-
1021
- **🔧 고급 패턴들**
1022
-
1023
- ```tsx
1024
- // 다중 임계값으로 점진적 페이드 효과
1025
- <Observer
1026
- threshold={[0, 0.25, 0.5, 0.75, 1.0]}
1027
- fallback={<div className="opacity-0"><GradualContent /></div>}
1028
- onIntersect={(isIntersecting, entry) => {
1029
- // entry가 필요한 세밀한 제어는 콜백에서
1030
- console.log('Intersection ratio:', entry.intersectionRatio);
1031
- }}
1032
- >
1033
- <div className="opacity-100 transition-opacity duration-300">
1034
- <GradualContent />
1035
- </div>
1036
- </Observer>
1037
-
1038
- // 루트 마진을 활용한 프리로딩
1039
- <Observer
1040
- threshold={0}
1041
- rootMargin="0px 0px 500px 0px" // 500px 전에 미리 로딩
1042
- triggerOnce={true}
1043
- onIntersect={(isIntersecting) => {
1044
- if (isIntersecting) {
1045
- preloadNextPageData();
1046
- }
1047
- }}
1048
- fallback={<div>프리로드 트리거 대기 중...</div>}
1049
- >
1050
- <div>다음 페이지 프리로드 트리거</div>
1051
- </Observer>
1052
-
1053
- // 뷰포트 벗어남 감지 (entry 없이도 가능)
1054
- <Observer
1055
- threshold={0}
1056
- onIntersect={(isIntersecting) => {
1057
- if (!isIntersecting) {
1058
- pauseVideo();
1059
- } else {
1060
- playVideo();
1061
- }
1062
- }}
1063
- >
1064
- <VideoPlayer src={videoUrl} />
1065
- </Observer>
1066
- ```
1067
-
1068
- > **⚠️ 브라우저 호환성**: `Observer`는 내부적으로 `IntersectionObserver` API를 사용하므로 현대 브라우저에서 잘 지원되지만, 구형 브라우저에서는 폴리필이 필요할 수 있습니다. 컴포넌트는 API가 지원되지 않는 환경에서 graceful fallback을 제공합니다.
1069
-
1070
- ```tsx
1071
- // Observer 사용 - 더 간결함
1072
- <Observer threshold={0.1} fallback={<Skeleton />}>
1073
- <HeavyComponent />
1074
- </Observer>
1075
-
1076
- // 기존 IntersectionObserver와 동일한 기능
1077
- <IntersectionObserver threshold={0.1} fallback={<Skeleton />}>
1078
- <HeavyComponent />
1079
- </IntersectionObserver>
1080
- ```
1081
-
1082
- **🎯 빠른 사용 예제**
1083
-
1084
- ```tsx
1085
- // 지연 로딩
1086
- <Observer threshold={0.1} triggerOnce={true} fallback={<ImageSkeleton />}>
1087
- <img src={imageUrl} alt="지연 로딩 이미지" />
1088
- </Observer>
1089
-
1090
- // 애니메이션 트리거
1091
- <Observer threshold={0.3} triggerOnce={true}>
1092
- {(isIntersecting) => (
1093
- <div className={`transition-all duration-1000 ${
1094
- isIntersecting ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-10'
1095
- }`}>
1096
- <FeatureCard />
1097
- </div>
1098
- )}
1099
- </Observer>
1100
-
1101
- // 무한 스크롤
1102
- <Observer
1103
- threshold={1.0}
1104
- rootMargin="0px 0px 200px 0px"
1105
- onIntersect={(isIntersecting) => {
1106
- if (isIntersecting && hasNextPage) {
1107
- loadMoreItems();
1108
- }
1109
- }}
1110
- >
1111
- <LoadMoreButton />
1112
- </Observer>
1113
- ```
1114
-
1115
- # Slacker - 스마트 지연 로딩
1116
-
1117
- 복잡한 컴포넌트나 데이터를 미리 로드하면 초기 페이지 로딩이 느려지고, 사용자가 실제로 보지 않는 콘텐츠까지 로드하게 됩니다.
1118
-
1119
- **❌ 일반적인 방식의 문제점**
1120
-
1121
- ```tsx
1122
- // 모든 차트가 한 번에 로드 (페이지 로딩 느림)
1123
- function Dashboard() {
1124
- return (
1125
- <div>
1126
- <HeavyChart1 data={data1} />
1127
- <HeavyChart2 data={data2} />
1128
- <HeavyChart3 data={data3} />
1129
- </div>
1130
- );
1131
- }
1132
-
1133
- // 수동 lazy loading (복잡한 상태 관리)
1134
- function LazyChart() {
1135
- const [inView, setInView] = useState(false);
1136
- const [Component, setComponent] = useState(null);
1137
- const [data, setData] = useState(null);
1138
- const [loading, setLoading] = useState(false);
1139
-
1140
- useEffect(() => {
1141
- if (inView && !Component) {
1142
- setLoading(true);
1143
- Promise.all([
1144
- import('./HeavyChart'),
1145
- fetch('/api/data').then(r => r.json())
1146
- ]).then(([module, fetchedData]) => {
1147
- setComponent(module.default);
1148
- setData(fetchedData);
1149
- setLoading(false);
1150
- });
1151
- }
1152
- }, [inView]);
1153
-
1154
- return (
1155
- <div ref={observerRef}>
1156
- {loading && <ChartSkeleton />}
1157
- {Component && data && <Component data={data} />}
1158
- </div>
1159
- );
1160
- }
1161
- ```
1162
-
1163
- **Slacker 컴포넌트의 해결책**
1164
-
1165
- `Slacker` 컴포넌트는 뷰포트에 진입할 때까지 로딩을 지연시키고, loader에서 반환된 데이터를 children 함수에 전달하여 렌더링하는 스마트한 지연 로딩 솔루션입니다.
1166
-
1167
- ```tsx
1168
- interface SlackerProps {
1169
- children: (loaded: any) => React.ReactNode; // loader의 결과를 받는 함수
1170
- fallback?: React.ReactNode; // 로딩 중 표시할 내용
1171
- threshold?: number | number[]; // 교차 임계값 (기본: 0.1)
1172
- rootMargin?: string; // 루트 마진 (기본: "50px")
1173
- loader: () => Promise<any> | any; // 동적 로딩 함수 (필수)
1174
- }
1175
- ```
1176
-
1177
- **✅ Slacker를 사용한 개선된 방식**
1178
-
1179
- ```tsx
1180
- // 컴포넌트 lazy loading
1181
- <Slacker
1182
- fallback={<ChartSkeleton />}
1183
- loader={async () => {
1184
- const { HeavyChart } = await import('./HeavyChart');
1185
- return HeavyChart;
1186
- }}
1187
- >
1188
- {(Component) => <Component data={data} />}
1189
- </Slacker>
1190
-
1191
- // 데이터 lazy loading
1192
- <Slacker
1193
- fallback={<div>Loading data...</div>}
1194
- loader={async () => {
1195
- const response = await fetch('/api/data');
1196
- return response.json();
1197
- }}
1198
- >
1199
- {(data) => (
1200
- <div>
1201
- <h2>{data.title}</h2>
1202
- <p>{data.description}</p>
1203
- </div>
1204
- )}
1205
- </Slacker>
1206
-
1207
- // 컴포넌트와 데이터 함께 로딩
1208
- <Slacker
1209
- fallback={<ChartSkeleton />}
1210
- loader={async () => {
1211
- const [{ Chart }, chartData] = await Promise.all([
1212
- import('chart.js'),
1213
- fetch('/api/chart-data').then(r => r.json())
1214
- ]);
1215
- return { Chart, data: chartData };
1216
- }}
1217
- >
1218
- {({ Chart, data }) => <Chart data={data} />}
1219
- </Slacker>
1220
- ```
1221
-
1222
- **🎯 실제 사용 사례**
1223
-
1224
- ```tsx
1225
- // 대시보드의 차트들
1226
- function Dashboard() {
1227
- return (
1228
- <div className="grid grid-cols-2 gap-6">
1229
- <Slacker
1230
- fallback={<ChartSkeleton />}
1231
- loader={async () => {
1232
- const [{ PieChart }, salesData] = await Promise.all([
1233
- import('./charts/PieChart'),
1234
- fetch('/api/sales').then(r => r.json())
1235
- ]);
1236
- return { Component: PieChart, data: salesData };
1237
- }}
1238
- >
1239
- {({ Component, data }) => <Component data={data} />}
1240
- </Slacker>
1241
-
1242
- <Slacker
1243
- fallback={<ChartSkeleton />}
1244
- loader={async () => {
1245
- const [{ LineChart }, trafficData] = await Promise.all([
1246
- import('./charts/LineChart'),
1247
- fetch('/api/traffic').then(r => r.json())
1248
- ]);
1249
- return { Component: LineChart, data: trafficData };
1250
- }}
1251
- >
1252
- {({ Component, data }) => <Component data={data} />}
1253
- </Slacker>
1254
- </div>
1255
- );
1256
- }
1257
-
1258
- // 이미지 갤러리의 고해상도 이미지
1259
- function PhotoGallery({ photos }: { photos: Photo[] }) {
1260
- return (
1261
- <div className="grid grid-cols-3 gap-4">
1262
- <For each={photos}>
1263
- {(photo) => (
1264
- <Slacker
1265
- key={photo.id}
1266
- fallback={
1267
- <div className="aspect-square bg-gray-200 animate-pulse rounded-lg" />
1268
- }
1269
- loader={async () => {
1270
- const [imageUrl, metadata] = await Promise.all([
1271
- loadHighResImage(photo.id),
1272
- fetch(`/api/photos/${photo.id}/metadata`).then(r => r.json())
1273
- ]);
1274
- return { imageUrl, metadata };
1275
- }}
1276
- >
1277
- {({ imageUrl, metadata }) => (
1278
- <div className="aspect-square rounded-lg overflow-hidden">
1279
- <img
1280
- src={imageUrl}
1281
- alt={metadata.title}
1282
- className="w-full h-full object-cover"
1283
- />
1284
- <div className="p-2">
1285
- <p className="text-sm text-gray-600">{metadata.description}</p>
1286
- </div>
1287
- </div>
1288
- )}
1289
- </Slacker>
1290
- )}
1291
- </For>
1292
- </div>
1293
- );
1294
- }
1295
-
1296
- // 복잡한 에디터 컴포넌트
1297
- <Slacker
1298
- fallback={
1299
- <div className="h-96 bg-gray-100 rounded border-2 border-dashed flex items-center justify-center">
1300
- <div className="text-center">
1301
- <div className="w-8 h-8 border-2 border-blue-500 border-t-transparent rounded-full animate-spin mx-auto mb-2" />
1302
- <p>에디터 로딩 중...</p>
1303
- </div>
1304
- </div>
1305
- }
1306
- loader={async () => {
1307
- const [
1308
- { default: CodeMirror },
1309
- { default: prettier },
1310
- extensions
1311
- ] = await Promise.all([
1312
- import('@uiw/react-codemirror'),
1313
- import('prettier/standalone'),
1314
- import('./editor-extensions')
1315
- ]);
1316
- return { CodeMirror, prettier, extensions };
1317
- }}
1318
- >
1319
- {({ CodeMirror, prettier, extensions }) => (
1320
- <CodeMirror
1321
- value={code}
1322
- height="400px"
1323
- extensions={extensions}
1324
- onChange={(val) => setCode(prettier.format(val))}
1325
- />
1326
- )}
1327
- </Slacker>
1328
-
1329
- // 지도 컴포넌트
1330
- <Slacker
1331
- fallback={<MapSkeleton />}
1332
- threshold={0.3}
1333
- rootMargin="100px" // 더 일찍 로딩 시작
1334
- loader={async () => {
1335
- const [{ Map }, { Marker }, locationData] = await Promise.all([
1336
- import('react-leaflet'),
1337
- import('react-leaflet'),
1338
- fetch('/api/locations').then(r => r.json())
1339
- ]);
1340
-
1341
- // 분석 트래킹은 loader 내부에서 처리
1342
- analytics.track('map_loaded');
1343
-
1344
- return { Map, Marker, locations: locationData };
1345
- }}
1346
- >
1347
- {({ Map, Marker, locations }) => (
1348
- <Map center={[51.505, -0.09]} zoom={13} style={{ height: '400px' }}>
1349
- <For each={locations}>
1350
- {(location) => (
1351
- <Marker key={location.id} position={[location.lat, location.lng]} />
1352
- )}
1353
- </For>
1354
- </Map>
1355
- )}
1356
- </Slacker>
1357
- ```
1358
-
1359
- **🔧 고급 패턴들**
1360
-
1361
- ```tsx
1362
- // 조건부 lazy loading - Show와 함께 사용
1363
- <Show when={userCanSeeAdvancedFeatures} fallback={<BasicView />}>
1364
- <Slacker
1365
- fallback={<AdvancedFeatureSkeleton />}
1366
- loader={async () => {
1367
- const { AdvancedDashboard } = await import('./AdvancedDashboard');
1368
- return AdvancedDashboard;
1369
- }}
1370
- >
1371
- {(Component) => <Component user={user} />}
1372
- </Slacker>
1373
- </Show>
1374
-
1375
- // 에러 핸들링과 재시도
1376
- function SafeSlacker({ children, ...props }) {
1377
- const [error, setError] = useState(null);
1378
- const [retryCount, setRetryCount] = useState(0);
1379
-
1380
- const wrappedLoader = async () => {
1381
- try {
1382
- setError(null);
1383
- return await props.loader();
1384
- } catch (err) {
1385
- setError(err);
1386
- throw err;
1387
- }
1388
- };
1389
-
1390
- if (error && retryCount < 3) {
1391
- return (
1392
- <div className="text-center p-4">
1393
- <p>로딩 실패: {error.message}</p>
1394
- <button
1395
- onClick={() => setRetryCount(c => c + 1)}
1396
- className="mt-2 px-4 py-2 bg-blue-500 text-white rounded"
1397
- >
1398
- 재시도 ({retryCount}/3)
1399
- </button>
1400
- </div>
1401
- );
1402
- }
1403
-
1404
- return (
1405
- <Slacker {...props} loader={wrappedLoader}>
1406
- {children}
1407
- </Slacker>
1408
- );
1409
- }
1410
-
1411
- // 프리로딩 전략
1412
- <Slacker
1413
- rootMargin="200px 0px" // 뷰포트 200px 전에 로딩 시작
1414
- threshold={0}
1415
- fallback={<ContentSkeleton />}
1416
- loader={async () => {
1417
- // 중요한 리소스는 우선 로딩
1418
- const mainContent = await import('./MainContent');
1419
-
1420
- // 덜 중요한 리소스는 백그라운드에서 로딩
1421
- setTimeout(async () => {
1422
- await import('./SecondaryFeatures');
1423
- }, 100);
1424
-
1425
- return mainContent.default;
1426
- }}
1427
- >
1428
- {(Component) => <Component />}
1429
- </Slacker>
1430
-
1431
- // 점진적 로딩
1432
- <Slacker
1433
- fallback={<BasicChart />} // 먼저 기본 차트 표시
1434
- loader={async () => {
1435
- const { EnhancedChart } = await import('./EnhancedChart');
1436
- return EnhancedChart;
1437
- }}
1438
- >
1439
- {(EnhancedChart) => <EnhancedChart data={data} />}
1440
- </Slacker>
1441
-
1442
- // 뷰포트 진입 분석이 필요한 경우 - Observer와 조합
1443
- <Observer
1444
- onIntersect={(isIntersecting) => {
1445
- if (isIntersecting) {
1446
- analytics.track('heavy_component_section_viewed');
1447
- }
1448
- }}
1449
- >
1450
- <Slacker
1451
- fallback={<HeavyComponentSkeleton />}
1452
- loader={async () => {
1453
- const { HeavyComponent } = await import('./HeavyComponent');
1454
- analytics.track('heavy_component_loaded');
1455
- return HeavyComponent;
1456
- }}
1457
- >
1458
- {(Component) => <Component />}
1459
- </Slacker>
1460
- </Observer>
1461
- ```
1462
-
1463
- > **💡 성능 팁**: `Slacker`는 triggerOnce가 기본적으로 활성화되어 있어 한 번 로드된 후에는 다시 언로드되지 않습니다. 메모리 사용량을 고려하여 큰 컴포넌트는 필요에 따라 언마운트하는 로직을 별도로 구현하세요.
1464
-
1465
- > **🔍 분석/트래킹**: 로딩 이벤트 추적은 loader 함수 내부에서 처리하고, 뷰포트 진입 자체를 감지해야 한다면 `Observer` 컴포넌트와 조합하여 사용하세요.
1466
-
1467
-
1468
- # 🤝 기여하기
1469
-
1470
- Utilinent는 오픈소스 프로젝트입니다. 버그 리포트, 기능 제안, 풀 리퀘스트를 환영합니다!
1471
-
1472
- ## 🔗 관련 링크
1473
- - [GitHub Repository](https://github.com/ilokesto/utilinent)
1474
- - [NPM Package](https://www.npmjs.com/ayden94/utilinent)
1
+ # @ilokesto/utilinent
2
+
3
+ React를 위한 유용하고 재사용 가능한 컴포넌트 및 훅 모음입니다.
4
+
5
+ ## 소개
6
+
7
+ `@ilokesto/utilinent`는 React 애플리케이션 개발 시 반복적으로 필요한 로직과 패턴을 선언적이고 읽기 쉬운 컴포넌트로 제공합니다. 조건부 렌더링, 리스트 렌더링, 지연 로딩 등의 작업을 간편하게 처리할 수 있도록 돕습니다.
8
+
9
+ ## 설치
10
+
11
+ ```bash
12
+ npm install @ilokesto/utilinent
13
+ # 또는
14
+ yarn add @ilokesto/utilinent
15
+ # 또는
16
+ pnpm add @ilokesto/utilinent
17
+ ```
18
+
19
+ ## 주요 기능
20
+
21
+ ### 기본 컴포넌트
22
+
23
+ 기본적으로 다음 컴포넌트들을 가져와 사용할 수 있습니다.
24
+
25
+ ```tsx
26
+ import { Show, For, Repeat, Observer, OptionalWrapper } from '@ilokesto/utilinent';
27
+ ```
28
+
29
+ #### `<Show>`
30
+
31
+ 조건부 렌더링을 위한 컴포넌트입니다. `when` prop이 `true`일 때만 `children`을 렌더링합니다.
32
+
33
+ 또한, `Show.div`, `Show.span`과 같이 HTML 태그를 직접 사용하여 래퍼(wrapper) 컴포넌트를 지정할 수 있습니다. 이 경우, `when` prop이 `true`일 때 해당 태그로 감싸진 `children`이 렌더링됩니다.
34
+
35
+ **사용 예시:**
36
+
37
+ ```tsx
38
+ import { Show } from '@ilokesto/utilinent';
39
+
40
+ function UserProfile({ user }) {
41
+ return (
42
+ <div>
43
+ <Show when={user.isLoggedIn} fallback={<div>로그인이 필요합니다.</div>}>
44
+ <h1>{user.name}님, 환영합니다!</h1>
45
+ </Show>
46
+
47
+ {/* HTML 태그와 함께 사용 */}
48
+ <Show.div when={user.isAdmin} className="admin-badge">
49
+ 관리자
50
+ </Show.div>
51
+ </div>
52
+ );
53
+ }
54
+ ```
55
+
56
+ #### `<For>`
57
+
58
+ 배열을 순회하며 각 항목을 렌더링합니다. `Array.prototype.map`과 유사하지만, 배열이 비어있을 경우를 위한 `fallback`을 지원합니다.
59
+
60
+ `<Show>`와 유사하게, `For.ul`, `For.div`와 같이 HTML 태그를 사용하여 렌더링되는 항목들을 감싸는 컨테이너 엘리먼트를 지정할 있습니다.
61
+
62
+ **사용 예시:**
63
+
64
+ ```tsx
65
+ import { For } from '@ilokesto/utilinent';
66
+
67
+ function TodoList({ todos }) {
68
+ return (
69
+ <For.ul each={todos} fallback={<li>할 일이 없습니다.</li>} className="todo-list">
70
+ {(todo, index) => <li key={index}>{todo.text}</li>}
71
+ </For.ul>
72
+ );
73
+ }
74
+ ```
75
+
76
+ #### `<Repeat>`
77
+
78
+ 주어진 횟수(`times`)만큼 `children` 함수를 반복하여 렌더링합니다.
79
+
80
+ 다른 컴포넌트들과 마찬가지로, `Repeat.div`와 같이 HTML 태그를 사용하여 반복되는 항목들을 감싸는 컨테이너를 지정할 수 있습니다.
81
+
82
+ **사용 예시:**
83
+
84
+ ```tsx
85
+ import { Repeat } from '@ilokesto/utilinent';
86
+
87
+ function StarRating({ rating }) {
88
+ return (
89
+ <Repeat.div times={rating} className="star-container">
90
+ {(index) => <span key={index}>⭐️</span>}
91
+ </Repeat.div>
92
+ );
93
+ }
94
+ ```
95
+
96
+ #### `<Observer>`
97
+
98
+ 컴포넌트가 화면에 보일 때(intersect) `children`을 렌더링합니다. Intersection Observer API를 기반으로 하며, 지연 로딩(lazy loading) 구현에 유용합니다.
99
+
100
+ **사용 예시:**
101
+
102
+ ```tsx
103
+ import { Observer } from '@ilokesto/utilinent';
104
+
105
+ function LazyComponent() {
106
+ return (
107
+ <Observer fallback={<div>로딩 중...</div>}>
108
+ {/* 화면에 보일 때 렌더링될 무거운 컴포넌트 */}
109
+ <HeavyComponent />
110
+ </Observer>
111
+ );
112
+ }
113
+ ```
114
+
115
+ #### `<OptionalWrapper>`
116
+
117
+ `when` prop이 `true`일 때만 `children`을 `wrapper` 함수로 감싸줍니다.
118
+
119
+ **사용 예시:**
120
+
121
+ ```tsx
122
+ import { OptionalWrapper } from '@ilokesto/utilinent';
123
+
124
+ function Post({ post, withLink }) {
125
+ return (
126
+ <OptionalWrapper
127
+ when={withLink}
128
+ wrapper={(children) => <a href={`/posts/${post.id}`}>{children}</a>}
129
+ >
130
+ <h2>{post.title}</h2>
131
+ <p>{post.summary}</p>
132
+ </OptionalWrapper>
133
+ );
134
+ }
135
+ ```
136
+
137
+ ### 실험적 기능
138
+
139
+ 실험적인 컴포넌트 훅은 `experimental` 경로에서 가져올 수 있습니다. 이 기능들은 API가 변경될 수 있습니다.
140
+
141
+ ```tsx
142
+ import { Mount, Slacker, createSwitcher, useIntersectionObserver, Slot, Slottable } from '@ilokesto/utilinent/experimental';
143
+ ```
144
+
145
+ #### `<Slot>` `<Slottable>`
146
+
147
+ 자식 컴포넌트에 props를 전달하고 병합하는 Radix UI의 `<Slot>`과 유사한 패턴을 구현합니다. `<Slot>`은 자신의 props를 자식 요소에 병합합니다. 여러 자식 중 특정 자식에게 props를 전달하고 싶을 때는 `<Slottable>`로 감싸주면 됩니다.
148
+
149
+ Props 병합 규칙은 다음과 같습니다:
150
+ * **`className`**: 부모와 자식의 `className`이 합쳐집니다.
151
+ * **`style`**: 부모와 자식의 `style` 객체가 병합됩니다 (부모 우선).
152
+ * **이벤트 핸들러**: 부모와 자식의 이벤트 핸들러가 모두 순차적으로 호출됩니다.
153
+ * **`ref`**: 부모와 자식의 `ref`가 모두 연결됩니다.
154
+ * **기타 props**: 부모의 props가 자식의 props를 덮어씁니다.
155
+
156
+ **사용 예시:**
157
+
158
+ ```tsx
159
+ import { Slot, Slottable } from '@ilokesto/utilinent/experimental';
160
+
161
+ const Button = ({ asChild = false, ...props }) => {
162
+ const Comp = asChild ? Slot : 'button';
163
+ return <Comp {...props} />;
164
+ };
165
+
166
+ // 기본 버튼으로 사용
167
+ <Button onClick={() => alert('Clicked!')}>Click Me</Button>
168
+
169
+ // 다른 컴포넌트(a 태그)를 렌더링하면서 props를 전달
170
+ <Button asChild>
171
+ <a href="/home">Go Home</a>
172
+ </Button>
173
+
174
+ // 여러 자식 중 특정 자식에게 props 전달
175
+ <Button asChild>
176
+ <div>
177
+ <span>Icon</span>
178
+ <Slottable>Text</Slottable>
179
+ </div>
180
+ </Button>
181
+ ```
182
+
183
+ #### `<Mount>`
184
+
185
+ 컴포넌트가 마운트될 때 비동기적으로 `children`을 렌더링합니다. `children`으로 비동기 함수를 전달할 수 있습니다.
186
+
187
+ **사용 예시:**
188
+
189
+ ```tsx
190
+ import { Mount } from '@ilokesto/utilinent/experimental';
191
+
192
+ function AsyncComponent() {
193
+ return (
194
+ <Mount fallback={<div>로딩 중...</div>}>
195
+ {async () => {
196
+ const { HeavyComponent } = await import('./HeavyComponent');
197
+ return <HeavyComponent />;
198
+ }}
199
+ </Mount>
200
+ );
201
+ }
202
+ ```
203
+
204
+ #### `<Slacker>`
205
+
206
+ 컴포넌트나 데이터의 지연 로딩(lazy loading)을 위한 고급 컴포넌트입니다. 뷰포트에 들어왔을 때 `loader` 함수를 실행하여 비동기 작업을 처리하고, 로딩이 완료되면 결과를 `children`에게 전달하여 렌더링합니다.
207
+
208
+ **사용 예시:**
209
+
210
+ ```tsx
211
+ import { Slacker } from '@ilokesto/utilinent/experimental';
212
+
213
+ // 데이터 지연 로딩
214
+ function LazyUserData({ userId }) {
215
+ return (
216
+ <Slacker
217
+ fallback={<div>사용자 정보 로딩 중...</div>}
218
+ loader={async () => {
219
+ const response = await fetch(`/api/users/${userId}`);
220
+ return response.json();
221
+ }}
222
+ >
223
+ {(user) => (
224
+ <div>
225
+ <h1>{user.name}</h1>
226
+ <p>{user.email}</p>
227
+ </div>
228
+ )}
229
+ </Slacker>
230
+ );
231
+ }
232
+ ```
233
+
234
+ #### `createSwitcher` / `<Switch>` / `<Match>`
235
+
236
+ 주어진 데이터와 조건에 따라 여러 `<Match>` 컴포넌트 하나를 선택하여 렌더링합니다. `createSwitcher` 팩토리 함수를 통해 `<Switch>`와 `<Match>` 컴포넌트를 생성하여 사용합니다.
237
+
238
+ **사용 예시:**
239
+
240
+ ```tsx
241
+ import { createSwitcher } from '@ilokesto/utilinent/experimental';
242
+
243
+ const data = { type: 'image', src: 'image.jpg' };
244
+ const { Switch, Match } = createSwitcher(data);
245
+
246
+ function Media() {
247
+ return (
248
+ <Switch when="type" fallback={<div>지원하지 않는 형식입니다.</div>}>
249
+ <Match case="image">
250
+ {(data) => <img src={data.src} />}
251
+ </Match>
252
+ <Match case="video">
253
+ {(data) => <video src={data.src} controls />}
254
+ </Match>
255
+ </Switch>
256
+ );
257
+ }
258
+ ```
259
+
260
+ #### `useIntersectionObserver`
261
+
262
+ Intersection Observer API를 React 훅으로 감싼 것입니다. 컴포넌트의 뷰포트 내 가시성을 추적하는 데 사용됩니다.
263
+
264
+ **사용 예시:**
265
+
266
+ ```tsx
267
+ import { useIntersectionObserver } from '@ilokesto/utilinent/experimental';
268
+ import { useRef } from 'react';
269
+
270
+ function MyComponent() {
271
+ const { ref, isIntersecting } = useIntersectionObserver({ threshold: 0.5 });
272
+
273
+ return (
274
+ <div ref={ref} style={{ transition: 'opacity 0.5s', opacity: isIntersecting ? 1 : 0.2 }}>
275
+ {isIntersecting ? '이제 화면에 보입니다!' : '화면 밖에 있습니다.'}
276
+ </div>
277
+ );
278
+ }
279
+ ```
280
+
281
+ ## 라이선스
282
+
283
+ [MIT](./LICENSE)