18.4 제어 흐름과 Props
Solid.js는 조건부 렌더링과 리스트 렌더링을 위해 내장 컴포넌트를 제공합니다. JavaScript의 if, map, switch 대신 Show, For, Index, Switch/Match를 사용합니다. 이 컴포넌트들은 세분화된 반응성 시스템과 긴밀하게 통합되어 최적의 DOM 업데이트를 보장합니다.
왜 내장 제어 흐름 컴포넌트를 사용하는가?
JavaScript 표현식의 문제점
// ❌ 삼항 연산자 — 조건이 바뀔 때마다 두 브랜치를 모두 평가
function BadComponent() {
const [show, setShow] = createSignal(true);
// show()가 false여도 <HeavyComponent />의 JSX가 평가됨
return (
<div>
{show() ? <HeavyComponent /> : <EmptyState />}
</div>
);
}
// ✅ Show — 조건이 true일 때만 children 평가 (lazy evaluation)
function GoodComponent() {
const [show, setShow] = createSignal(true);
return (
<div>
<Show when={show()} fallback={<EmptyState />}>
<HeavyComponent />
</Show>
</div>
);
}
Solid.js의 JSX는 컴포넌트 함수가 한 번만 실행되기 때문에, 삼항 연산자를 써도 실제 컴포넌트 재실행은 일어나지 않습니다. 그러나 Show를 쓰면 children 자체의 생성/파괴를 명확히 제어할 수 있습니다.
<Show> — 조건부 렌더링
기본 사용법
import { Show } from 'solid-js';
import { createSignal } from 'solid-js';
function LoginStatus() {
const [isLoggedIn, setIsLoggedIn] = createSignal(false);
const [username, setUsername] = createSignal('홍길동');
return (
<div>
<Show
when={isLoggedIn()}
fallback={<button onClick={() => setIsLoggedIn(true)}>로그인</button>}
>
{/* when이 truthy일 때만 렌더링 */}
<div>
<span>안녕하세요, {username()}!</span>
<button onClick={() => setIsLoggedIn(false)}>로그아웃</button>
</div>
</Show>
</div>
);
}
render prop 패턴으로 타입 좁히기
Show의 children을 함수로 전달하면, when 속성의 타입이 좁혀진 값을 받을 수 있습니다.
interface User {
name: string;
email: string;
role: 'admin' | 'user';
}
function UserCard() {
const [user, setUser] = createSignal<User | null>(null);
return (
<Show
when={user()}
fallback={<p>사용자 정보 없음</p>}
>
{/* (u) => ... 형태: u는 User 타입 (null이 제거됨) */}
{(u) => (
<div class="user-card">
<h2>{u().name}</h2>
<p>{u().email}</p>
<Show when={u().role === 'admin'}>
<span class="badge">관리자</span>
</Show>
</div>
)}
</Show>
);
}
when 속성 상세
// 숫자 (0은 falsy)
<Show when={count()}> ... </Show>
// 문자열 (빈 문자열은 falsy)
<Show when={errorMessage()}> ... </Show>
// 객체/배열 (null은 falsy)
<Show when={data()}> ... </Show>
// 복합 조건
<Show when={isAdmin() && hasPermission('write')}> ... </Show>
<For> — 리스트 렌더링
For는 배열을 렌더링하는 표준 방법입니다. 각 항목은 참조(reference)로 추적됩니다. 즉, 배열에서 항목이 이동하면 컴포넌트를 재생성하지 않고 DOM 노드를 이동시킵니다.
기본 사용법
import { For } from 'solid-js';
import { createSignal } from 'solid-js';
interface Todo {
id: number;
text: string;
done: boolean;
}
function TodoList() {
const [todos, setTodos] = createSignal<Todo[]>([
{ id: 1, text: '운동하기', done: false },
{ id: 2, text: '독서하기', done: true },
{ id: 3, text: '코딩하기', done: false },
]);
const toggleTodo = (id: number) => {
setTodos(prev =>
prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
};
return (
<ul>
{/* each: 반복할 배열 Signal */}
{/* fallback: 빈 배열일 때 표시할 컨텐츠 */}
<For
each={todos()}
fallback={<li>할 일이 없습니다.</li>}
>
{/* 첫 번째 인자: 현재 항목 (Signal), 두 번째: 인덱스 (Signal) */}
{(todo, index) => (
<li style={todo.done ? 'text-decoration: line-through' : ''}>
<span style="color: gray; margin-right: 0.5rem;">{index() + 1}.</span>
<input
type="checkbox"
checked={todo.done}
onChange={() => toggleTodo(todo.id)}
/>
{todo.text}
</li>
)}
</For>
</ul>
);
}
For의 최적화 원리
배열 변경 전: [A, B, C, D, E]
배열 변경 후: [A, C, B, D, E] (B와 C 순서 교환)
React (map + key): B, C DOM 노드 파괴 → 재생성
Solid (For): B, C DOM 노드를 이동만 함 → 훨씬 빠름
정렬, 필터링과 함께 사용
function SortableList() {
const [items, setItems] = createSignal([
{ id: 1, name: '바나나', price: 1500 },
{ id: 2, name: '사과', price: 2000 },
{ id: 3, name: '딸기', price: 5000 },
{ id: 4, name: '포도', price: 3000 },
]);
const [sortBy, setSortBy] = createSignal<'name' | 'price'>('name');
const [ascending, setAscending] = createSignal(true);
const sorted = createMemo(() => {
const key = sortBy();
const asc = ascending();
return [...items()].sort((a, b) => {
const diff = a[key] < b[key] ? -1 : a[key] > b[key] ? 1 : 0;
return asc ? diff : -diff;
});
});
return (
<div>
<div style="margin-bottom: 1rem;">
정렬:
<button onClick={() => setSortBy('name')}>이름</button>
<button onClick={() => setSortBy('price')}>가격</button>
<button onClick={() => setAscending(a => !a)}>
{ascending() ? '오름차순' : '내림차순'}
</button>
</div>
<ul>
<For each={sorted()}>
{(item) => (
<li>{item.name} — {item.price.toLocaleString()}원</li>
)}
</For>
</ul>
</div>
);
}
<Index> — 인덱스 기반 렌더링
Index는 For와 달리 인덱스(위치)를 안정적으로 유지합니다. 배열 항목이 변경되면 해당 인덱스의 컴포넌트를 업데이트합니다.
For vs Index 차이점
| 항목 | <For> | <Index> |
|---|---|---|
| 추적 기준 | 항목의 참조(identity) | 배열의 인덱스(위치) |
| 항목 인자 타입 | 값 (불변) | Signal (getter) |
| 인덱스 인자 타입 | Signal (getter) | 값 (불변) |
| 적합한 경우 | 객체 배열 (key 있음) | 원시값 배열 또는 빈번한 위치 변경 |
| 항목 변경 시 | DOM 이동 | 해당 위치 인플레이스 업데이트 |
import { Index } from 'solid-js';
import { createSignal } from 'solid-js';
function IndexExample() {
const [items, setItems] = createSignal(['사과', '바나나', '체리']);
const updateItem = (idx: number, value: string) => {
setItems(prev => prev.map((item, i) => i === idx ? value : item));
};
return (
<div>
{/* Index: item은 Signal, index는 일반 숫자 */}
<Index each={items()}>
{(item, index) => (
<div>
<span>#{index}: </span>
{/* item()로 호출해야 값을 읽을 수 있음 */}
<input
value={item()}
onInput={(e) => updateItem(index, e.target.value)}
/>
</div>
)}
</Index>
</div>
);
}
For vs Index 선택 기준
// ✅ 객체 배열 → For 사용 (id로 참조 추적)
<For each={users()}>
{(user) => <UserCard name={user.name} email={user.email} />}
</For>
// ✅ 원시값 배열 + 인플레이스 편집 → Index 사용
<Index each={tableRows()}>
{(row, rowIndex) => (
<Index each={row()}>
{(cell, colIndex) => (
<td onInput={(e) => updateCell(rowIndex, colIndex, e.target.value)}>
{cell()}
</td>
)}
</Index>
)}
</Index>
// ✅ 자주 변경되는 슬라이딩 윈도우 → Index 사용
// 예: 채팅 메시지 목록 (위치별로 업데이트)
<Switch> + <Match> — 다중 조건 처리
여러 조건 중 하나를 선택하는 경우 Switch와 Match를 사용합니다.
기본 사용법
import { Switch, Match } from 'solid-js';
type Status = 'loading' | 'error' | 'empty' | 'success';
function DataView() {
const [status, setStatus] = createSignal<Status>('loading');
const [data, setData] = createSignal<string[]>([]);
const [error, setError] = createSignal('');
return (
<div>
{/* Switch: 첫 번째로 when이 truthy인 Match만 렌더링 */}
<Switch fallback={<p>알 수 없는 상태입니다.</p>}>
<Match when={status() === 'loading'}>
<LoadingSpinner />
</Match>
<Match when={status() === 'error'}>
<ErrorMessage message={error()} />
</Match>
<Match when={status() === 'empty'}>
<EmptyState message="데이터가 없습니다." />
</Match>
<Match when={status() === 'success'}>
<DataList items={data()} />
</Match>
</Switch>
{/* 테스트 버튼 */}
<div style="margin-top: 1rem;">
<button onClick={() => setStatus('loading')}>로딩</button>
<button onClick={() => { setStatus('error'); setError('네트워크 오류'); }}>에러</button>
<button onClick={() => setStatus('empty')}>비어있음</button>
<button onClick={() => { setStatus('success'); setData(['항목1', '항목2']); }}>성공</button>
</div>
</div>
);
}
render prop으로 타입 좁히기
type Result =
| { type: 'ok'; value: string }
| { type: 'err'; message: string };
function ResultView() {
const [result, setResult] = createSignal<Result>({ type: 'ok', value: '성공!' });
return (
<Switch>
<Match when={result().type === 'ok' && result()}>
{(r) => <p style="color: green">성공: {(r() as { type: 'ok'; value: string }).value}</p>}
</Match>
<Match when={result().type === 'err' && result()}>
{(r) => <p style="color: red">에러: {(r() as { type: 'err'; message: string }).message}</p>}
</Match>
</Switch>
);
}
<ErrorBoundary> — 에러 처리
자식 컴포넌트에서 발생하는 JavaScript 에러를 잡아 fallback UI를 표시합니다.
기본 사용법
import { ErrorBoundary } from 'solid-js';
function BrokenComponent() {
throw new Error('예상치 못한 에러 발생!');
return <div>이 코드는 실행되지 않음</div>;
}
function App() {
return (
<ErrorBoundary
fallback={(err, reset) => (
<div style="border: 2px solid red; padding: 1rem; border-radius: 8px;">
<h3>오류가 발생했습니다</h3>
<p>{err.message}</p>
<button onClick={reset}>다시 시도</button>
</div>
)}
>
<BrokenComponent />
</ErrorBoundary>
);
}
중첩 ErrorBoundary
function Dashboard() {
return (
{/* 전체 앱 수준 에러 경계 */}
<ErrorBoundary fallback={(err) => <CriticalError error={err} />}>
<Header />
{/* 위젯별 독립적인 에러 경계 */}
<div class="widgets">
<ErrorBoundary fallback={<WidgetError name="날씨" />}>
<WeatherWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="주식" />}>
<StockWidget />
</ErrorBoundary>
<ErrorBoundary fallback={<WidgetError name="뉴스" />}>
<NewsWidget />
</ErrorBoundary>
</div>
</ErrorBoundary>
);
}
<Suspense> — 비동기 로딩 처리
Suspense는 자식 컴포넌트의 createResource가 로딩 중일 때 fallback을 표시합니다.
기본 사용법
import { Suspense, createResource } from 'solid-js';
async function fetchData() {
await new Promise(r => setTimeout(r, 1500)); // 지연 시뮬레이션
return { name: '홍길동', email: 'hong@example.com' };
}
function UserInfo() {
const [user] = createResource(fetchData);
// Suspense 경계 내에서 데이터 로딩 중이면 자동으로 Suspense fallback 표시
return <div>{user()?.name}</div>;
}
function App() {
return (
<Suspense fallback={<div class="spinner">로딩 중...</div>}>
<UserInfo />
</Suspense>
);
}
SuspenseList로 여러 로딩 조율
import { SuspenseList, Suspense } from 'solid-js';
function Dashboard() {
return (
{/* revealOrder: 'forwards' | 'backwards' | 'together' */}
<SuspenseList revealOrder="forwards" tail="collapsed">
<Suspense fallback={<Skeleton />}>
<UserCard />
</Suspense>
<Suspense fallback={<Skeleton />}>
<RecentOrders />
</Suspense>
<Suspense fallback={<Skeleton />}>
<Statistics />
</Suspense>
</SuspenseList>
);
}
Props 반응성 유지 패턴
Solid.js에서 Props는 읽기 전용 반응형 객체입니다. 컴포넌트가 한 번만 실행되기 때문에, props의 최신 값에 접근하려면 반드시 함수처럼 접근해야 합니다.
props는 함수처럼 동작한다
// ✅ props는 항상 최신값을 반환하는 Proxy 객체
function Greeting(props) {
// props.name은 항상 부모에서 전달되는 최신 name을 반환
return <h1>안녕하세요, {props.name}!</h1>;
}
// 부모 컴포넌트
function Parent() {
const [name, setName] = createSignal('홍길동');
return (
<div>
<Greeting name={name()} />
<button onClick={() => setName('김철수')}>이름 변경</button>
</div>
);
}
구조 분해 절대 금지
// ❌ props 구조 분해 — 최초 값으로 고정됨!
function BadGreeting({ name, age }) {
// name = '홍길동', age = 30으로 영구 고정
// 부모가 name을 '김철수'로 바꿔도 업데이트되지 않음
return <div>{name} ({age}세)</div>;
}
// ✅ 항상 props 객체 전체를 받아서 접근
function GoodGreeting(props) {
return <div>{props.name} ({props.age}세)</div>;
}
mergeProps — 기본값 설정
mergeProps는 props에 기본값을 반응형으로 병합합니다.
import { mergeProps } from 'solid-js';
interface ButtonProps {
label: string;
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
onClick?: () => void;
}
function Button(props: ButtonProps) {
// 기본값 설정 — 반응형 유지됨
const merged = mergeProps(
{ variant: 'primary', size: 'md', disabled: false },
props
);
return (
<button
class={`btn btn-${merged.variant} btn-${merged.size}`}
disabled={merged.disabled}
onClick={merged.onClick}
>
{merged.label}
</button>
);
}
// 사용
<Button label="저장" />
<Button label="삭제" variant="danger" size="lg" />
splitProps — props 분리
자식 컴포넌트나 DOM 요소로 일부 props만 전달할 때 splitProps를 사용합니다.
import { splitProps } from 'solid-js';
interface InputProps {
label: string;
error?: string;
// + 모든 HTML input 속성
[key: string]: any;
}
function LabeledInput(props: InputProps) {
// 컴포넌트 전용 props와 input 요소용 props 분리
const [local, inputProps] = splitProps(props, ['label', 'error']);
return (
<div class="form-group">
<label>{local.label}</label>
{/* inputProps에는 label, error를 제외한 모든 props가 들어있음 */}
<input {...inputProps} class={local.error ? 'input-error' : ''} />
{local.error && <span class="error-msg">{local.error}</span>}
</div>
);
}
// 사용
<LabeledInput
label="이메일"
type="email"
value={email()}
onInput={(e) => setEmail(e.target.value)}
error={emailError()}
placeholder="example@email.com"
/>
실전 예제: 대시보드
import {
createSignal, createResource, createMemo,
For, Show, Switch, Match, Suspense, ErrorBoundary
} from 'solid-js';
// 타입 정의
interface Stat {
label: string;
value: number;
change: number;
unit: string;
}
interface Order {
id: number;
customer: string;
amount: number;
status: 'pending' | 'processing' | 'done' | 'cancelled';
date: string;
}
// 가상 API 함수
async function fetchStats(): Promise<Stat[]> {
await new Promise(r => setTimeout(r, 800));
return [
{ label: '오늘 매출', value: 4250000, change: 12.5, unit: '원' },
{ label: '신규 주문', value: 48, change: -3.2, unit: '건' },
{ label: '활성 사용자', value: 1284, change: 8.1, unit: '명' },
{ label: '전환율', value: 3.8, change: 0.5, unit: '%' },
];
}
async function fetchOrders(): Promise<Order[]> {
await new Promise(r => setTimeout(r, 1200));
return [
{ id: 1001, customer: '홍길동', amount: 150000, status: 'done', date: '2026-03-21' },
{ id: 1002, customer: '김철수', amount: 89000, status: 'processing', date: '2026-03-21' },
{ id: 1003, customer: '이영희', amount: 230000, status: 'pending', date: '2026-03-21' },
{ id: 1004, customer: '박민준', amount: 45000, status: 'cancelled', date: '2026-03-20' },
{ id: 1005, customer: '최지원', amount: 178000, status: 'done', date: '2026-03-20' },
];
}
// 통계 카드 컴포넌트
function StatCard(props: { stat: Stat }) {
const isPositive = () => props.stat.change >= 0;
return (
<div class="stat-card">
<p class="stat-label">{props.stat.label}</p>
<p class="stat-value">
{props.stat.value.toLocaleString()}{props.stat.unit}
</p>
<p class={isPositive() ? 'change positive' : 'change negative'}>
{isPositive() ? '▲' : '▼'} {Math.abs(props.stat.change)}%
</p>
</div>
);
}
// 주문 상태 배지
function StatusBadge(props: { status: Order['status'] }) {
const config = () => ({
pending: { label: '대기중', color: '#ffa726' },
processing: { label: '처리중', color: '#42a5f5' },
done: { label: '완료', color: '#66bb6a' },
cancelled: { label: '취소', color: '#ef5350' },
}[props.status]);
return (
<span style={`
background: ${config().color}20;
color: ${config().color};
padding: 2px 8px;
border-radius: 12px;
font-size: 0.8rem;
`}>
{config().label}
</span>
);
}
// 메인 대시보드
function Dashboard() {
const [filterStatus, setFilterStatus] = createSignal<Order['status'] | 'all'>('all');
const [stats] = createResource(fetchStats);
const [orders] = createResource(fetchOrders);
const filteredOrders = createMemo(() => {
const f = filterStatus();
if (f === 'all') return orders() ?? [];
return (orders() ?? []).filter(o => o.status === f);
});
const totalAmount = createMemo(() =>
filteredOrders().reduce((sum, o) => sum + o.amount, 0)
);
return (
<div class="dashboard">
<header class="dashboard-header">
<h1>대시보드</h1>
<p>2026년 3월 21일</p>
</header>
{/* 통계 카드 영역 */}
<section class="stats-section">
<ErrorBoundary fallback={(err) => <p>통계 로드 실패: {err.message}</p>}>
<Suspense fallback={
<div class="stats-grid">
{[1,2,3,4].map(() => <div class="stat-card skeleton" />)}
</div>
}>
<div class="stats-grid">
<For each={stats()}>
{(stat) => <StatCard stat={stat} />}
</For>
</div>
</Suspense>
</ErrorBoundary>
</section>
{/* 주문 목록 */}
<section class="orders-section">
<div class="section-header">
<h2>주문 목록</h2>
<div class="filter-buttons">
<For each={(['all', 'pending', 'processing', 'done', 'cancelled'] as const)}>
{(status) => (
<button
class={filterStatus() === status ? 'filter-btn active' : 'filter-btn'}
onClick={() => setFilterStatus(status)}
>
{status === 'all' ? '전체' :
status === 'pending' ? '대기' :
status === 'processing' ? '처리중' :
status === 'done' ? '완료' : '취소'}
</button>
)}
</For>
</div>
</div>
<ErrorBoundary fallback={(err) => <p>주문 로드 실패: {err.message}</p>}>
<Suspense fallback={<p>주문 목록 로딩 중...</p>}>
<Show
when={filteredOrders().length > 0}
fallback={<p class="empty">해당 상태의 주문이 없습니다.</p>}
>
<table class="orders-table">
<thead>
<tr>
<th>주문번호</th>
<th>고객</th>
<th>금액</th>
<th>상태</th>
<th>날짜</th>
</tr>
</thead>
<tbody>
<For each={filteredOrders()}>
{(order) => (
<tr>
<td>#{order.id}</td>
<td>{order.customer}</td>
<td>{order.amount.toLocaleString()}원</td>
<td><StatusBadge status={order.status} /></td>
<td>{order.date}</td>
</tr>
)}
</For>
</tbody>
<tfoot>
<tr>
<td colSpan={2}><strong>합계</strong></td>
<td><strong>{totalAmount().toLocaleString()}원</strong></td>
<td colSpan={2} />
</tr>
</tfoot>
</table>
</Show>
</Suspense>
</ErrorBoundary>
</section>
</div>
);
}
export default Dashboard;
실전 예제: 동적 목록 — 드래그 앤 드롭
import { createSignal, For } from 'solid-js';
interface Item {
id: number;
text: string;
color: string;
}
function DragDropList() {
const [items, setItems] = createSignal<Item[]>([
{ id: 1, text: '첫 번째 항목', color: '#ffcdd2' },
{ id: 2, text: '두 번째 항목', color: '#c8e6c9' },
{ id: 3, text: '세 번째 항목', color: '#bbdefb' },
{ id: 4, text: '네 번째 항목', color: '#fff9c4' },
{ id: 5, text: '다섯 번째 항목', color: '#f3e5f5' },
]);
let dragIndex = -1;
const handleDragStart = (index: number) => {
dragIndex = index;
};
const handleDragOver = (e: DragEvent, index: number) => {
e.preventDefault();
if (dragIndex === index) return;
setItems(prev => {
const next = [...prev];
const [dragged] = next.splice(dragIndex, 1);
next.splice(index, 0, dragged);
dragIndex = index;
return next;
});
};
const handleDragEnd = () => {
dragIndex = -1;
};
return (
<div>
<h3>드래그하여 순서 변경</h3>
{/* For는 참조 기반 추적으로 DOM 이동 최적화 */}
<ul style="list-style: none; padding: 0; max-width: 300px;">
<For each={items()}>
{(item, index) => (
<li
draggable={true}
onDragStart={() => handleDragStart(index())}
onDragOver={(e) => handleDragOver(e, index())}
onDragEnd={handleDragEnd}
style={`
background: ${item.color};
padding: 1rem;
margin-bottom: 0.5rem;
border-radius: 8px;
cursor: grab;
user-select: none;
border: 2px solid transparent;
transition: border-color 0.2s;
`}
>
☰ {item.text}
</li>
)}
</For>
</ul>
</div>
);
}
export default DragDropList;
실전 예제: 에러 처리 패턴
import { createSignal, createResource, ErrorBoundary, Suspense } from 'solid-js';
// 에러를 발생시킬 수 있는 API
async function fetchRiskyData(id: number) {
const res = await fetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}: 요청 실패`);
const data = await res.json();
if (data.id > 10) throw new Error('ID 10 이하만 조회 가능합니다.');
return data;
}
function RiskyDataView() {
const [id, setId] = createSignal(1);
const [data, { refetch }] = createResource(id, fetchRiskyData);
return (
<div>
<div>
<input
type="number"
value={id()}
onInput={(e) => setId(Number(e.target.value))}
min="1"
max="20"
/>
<button onClick={refetch}>새로고침</button>
</div>
<ErrorBoundary
fallback={(err, reset) => (
<div style="background: #fff3f3; border: 1px solid #f44336; padding: 1rem; border-radius: 8px; margin-top: 1rem;">
<h4 style="color: #f44336; margin: 0 0 0.5rem;">오류 발생</h4>
<p style="margin: 0 0 1rem;">{err.message}</p>
<div style="display: flex; gap: 0.5rem;">
<button onClick={reset}>다시 시도</button>
<button onClick={() => { setId(1); reset(); }}>기본값으로</button>
</div>
</div>
)}
>
<Suspense fallback={<p>데이터 로딩 중...</p>}>
<Show when={data()}>
<div style="margin-top: 1rem; padding: 1rem; border: 1px solid #ccc; border-radius: 8px;">
<p><strong>ID:</strong> {data()?.id}</p>
<p><strong>제목:</strong> {data()?.title}</p>
<p><strong>완료:</strong> {data()?.completed ? '✅' : '❌'}</p>
</div>
</Show>
</Suspense>
</ErrorBoundary>
</div>
);
}
export default RiskyDataView;
고수 팁
팁 1: children 헬퍼로 children 반응성 처리
import { children, type JSX } from 'solid-js';
interface AnimatedListProps {
children: JSX.Element | JSX.Element[];
}
function AnimatedList(props: AnimatedListProps) {
// children()을 memo처럼 처리 — 반응형 children을 안전하게 배열로 변환
const resolved = children(() => props.children);
// resolved()는 항상 최신 자식 요소 배열
return (
<ul>
<For each={resolved.toArray()}>
{(child) => (
<li style="animation: fadeIn 0.3s ease;">
{child}
</li>
)}
</For>
</ul>
);
}
팁 2: 중첩 Show의 성능 최적화
// ❌ 중첩이 많으면 가독성 저하
<Show when={isLoggedIn()}>
<Show when={isAdmin()}>
<Show when={hasPermission()}>
<AdminPanel />
</Show>
</Show>
</Show>
// ✅ createMemo로 복합 조건 만들기
const canShowAdmin = createMemo(
() => isLoggedIn() && isAdmin() && hasPermission()
);
<Show when={canShowAdmin()}>
<AdminPanel />
</Show>
팁 3: For 내부 컴포넌트 최적화
// For 내부의 각 항목은 독립적인 reactive scope를 가짐
// 한 항목이 변경되어도 다른 항목은 영향받지 않음
<For each={items()}>
{(item) => (
// ✅ item은 불변값 — createSignal 불필요
// item 객체가 교체되면 이 전체 블록이 재실행됨
<ExpensiveItemComponent
id={item.id}
data={item.data}
/>
)}
</For>
팁 4: 빈 배열 처리 패턴
// For의 fallback 대신 Show + For 조합
const hasItems = createMemo(() => items().length > 0);
<Show when={hasItems()} fallback={<EmptyState />}>
<For each={items()}>
{(item) => <Item data={item} />}
</For>
</Show>
// 또는 For의 fallback 직접 사용 (더 간결)
<For each={items()} fallback={<EmptyState />}>
{(item) => <Item data={item} />}
</For>
팁 5: Suspense 경계 최적화
// ❌ 전체를 하나의 Suspense로 감싸면 모두 로딩될 때까지 기다림
<Suspense fallback={<Loading />}>
<SlowComponent1 /> {/* 3초 */}
<SlowComponent2 /> {/* 1초 */}
<SlowComponent3 /> {/* 2초 */}
{/* 3초 후에 전체가 한 번에 나타남 */}
</Suspense>
// ✅ 개별 Suspense로 감싸면 각자 완료되는 대로 표시
<SlowComponent1 /> {/* Suspense 없이 즉시 표시 (if pre-loaded) */}
<Suspense fallback={<Skeleton />}><SlowComponent2 /></Suspense> {/* 1초 후 */}
<Suspense fallback={<Skeleton />}><SlowComponent1 /></Suspense> {/* 3초 후 */}
<Suspense fallback={<Skeleton />}><SlowComponent3 /></Suspense> {/* 2초 후 */}
팁 6: splitProps를 활용한 고차 컴포넌트 패턴
import { splitProps, mergeProps } from 'solid-js';
import type { JSX } from 'solid-js';
// HTML 속성을 그대로 전달하는 래퍼 컴포넌트
interface CardProps extends JSX.HTMLAttributes<HTMLDivElement> {
title: string;
hoverable?: boolean;
}
function Card(props: CardProps) {
const [local, rest] = splitProps(
mergeProps({ hoverable: false }, props),
['title', 'hoverable', 'children', 'class']
);
return (
<div
{...rest}
class={`card ${local.hoverable ? 'card-hoverable' : ''} ${local.class ?? ''}`}
>
<div class="card-header">
<h3>{local.title}</h3>
</div>
<div class="card-body">
{local.children}
</div>
</div>
);
}
// 사용 — onClick, style 등 모든 HTML 속성 전달 가능
<Card
title="사용자 정보"
hoverable
onClick={() => console.log('클릭')}
style="max-width: 400px;"
>
<p>카드 내용</p>
</Card>
정리
| 컴포넌트/패턴 | 용도 | 핵심 특징 |
|---|---|---|
<Show> | 조건부 렌더링 | when, fallback, render prop으로 타입 좁히기 |
<For> | 배열 렌더링 | 참조 기반 추적, DOM 이동 최적화 |
<Index> | 인덱스 기반 렌더링 | 위치 기반 추적, item이 Signal |
<Switch>/<Match> | 다중 조건 분기 | 첫 번째 truthy Match만 렌더링 |
<ErrorBoundary> | 에러 처리 | fallback(err, reset) 시그니처 |
<Suspense> | 비동기 로딩 | createResource와 자동 통합 |
mergeProps | 기본값 설정 | 반응형 props 병합 |
splitProps | props 분리 | 컴포넌트용 / HTML요소용 분리 |
| 구조 분해 금지 | 반응성 유지 | props, signal 모두 직접 접근 |
다음 장에서는 Solid.js의 전역 상태 관리(createStore)와 컴포넌트 간 데이터 공유 패턴을 살펴봅니다.