본문으로 건너뛰기
Advertisement

19.3 기본 개념

Qwik의 반응성 시스템과 핵심 훅들을 이해합니다. React의 useState, useEffect와 비교하면서 Qwik만의 독특한 개념을 익혀봅니다.


useSignal — 반응형 상태

Signal이란?

Signal은 Qwik의 기본 반응형 상태 단위입니다. React의 useState와 유사하지만 동작 방식이 근본적으로 다릅니다.

import { component$, useSignal } from '@builder.io/qwik';

export const SignalExample = component$(() => {
// Signal 생성: 초기값 설정
const count = useSignal(0);
const name = useSignal('홍길동');
const isVisible = useSignal(false);

// Signal 값 읽기: .value 접근자 사용
console.log(count.value); // 0

// Signal 값 쓰기: .value 직접 할당
// count.value = 5; ← 이렇게 해도 됨 (useState의 setter 함수 불필요)

return (
<div>
<p>카운트: {count.value}</p>
<p>이름: {name.value}</p>
<button onClick$={() => count.value++}>증가</button>
<button onClick$={() => name.value = '김철수'}>이름 변경</button>
</div>
);
});

Signal vs React useState 비교

// React useState
const [count, setCount] = useState(0);
// 값 읽기: count
// 값 쓰기: setCount(count + 1) 또는 setCount(prev => prev + 1)
// 변경 시: 컴포넌트 전체 재렌더링

// Qwik useSignal
const count = useSignal(0);
// 값 읽기: count.value
// 값 쓰기: count.value++ 또는 count.value = newVal
// 변경 시: Signal을 사용하는 DOM 부분만 업데이트 (세밀한 반응성)

세밀한 반응성 (Fine-grained Reactivity)

export const FineGrained = component$(() => {
const a = useSignal(0);
const b = useSignal(0);

// 이 렌더 함수는 처음 한 번만 실행됨
// a나 b가 변경되어도 컴포넌트 전체 재실행 없음!
console.log('컴포넌트 렌더링 (한 번만)');

return (
<div>
{/* a.value를 구독: a 변경 시 이 텍스트만 업데이트 */}
<p>A: {a.value}</p>

{/* b.value를 구독: b 변경 시 이 텍스트만 업데이트 */}
<p>B: {b.value}</p>

<button onClick$={() => a.value++}>A 증가</button>
<button onClick$={() => b.value++}>B 증가</button>
</div>
);
});

Signal 타입

import type { Signal } from '@builder.io/qwik';

// 기본 타입 추론
const num = useSignal(0); // Signal<number>
const str = useSignal('hello'); // Signal<string>
const bool = useSignal(false); // Signal<boolean>

// 명시적 타입 지정
const user = useSignal<User | null>(null);
const list = useSignal<string[]>([]);

// 타입 활용
function updateUser(sig: Signal<User | null>, data: User) {
sig.value = data;
}

Signal 컴포넌트 간 전달

// 부모에서 자식으로 Signal 전달
export const Parent = component$(() => {
const count = useSignal(0);

return (
<div>
<Child count={count} />
<p>부모에서도 보임: {count.value}</p>
</div>
);
});

interface ChildProps {
count: Signal<number>;
}

export const Child = component$<ChildProps>(({ count }) => {
return (
<div>
<p>자식에서 보임: {count.value}</p>
<button onClick$={() => count.value++}>
자식에서 증가
</button>
</div>
);
});

useStore — 복잡한 객체 상태

useStore 기본 사용

import { component$, useStore } from '@builder.io/qwik';

interface FormState {
name: string;
email: string;
age: number;
errors: {
name?: string;
email?: string;
age?: string;
};
}

export const UserForm = component$(() => {
const form = useStore<FormState>({
name: '',
email: '',
age: 0,
errors: {},
});

const validate = $(() => {
form.errors = {};
if (!form.name) form.errors.name = '이름을 입력하세요';
if (!form.email.includes('@')) form.errors.email = '올바른 이메일 입력';
if (form.age < 0 || form.age > 150) form.errors.age = '올바른 나이 입력';
return Object.keys(form.errors).length === 0;
});

return (
<form preventdefault:submit onSubmit$={async () => {
if (await validate()) {
console.log('폼 제출:', form.name, form.email);
}
}}>
<div>
<input
value={form.name}
onInput$={(e) => form.name = (e.target as HTMLInputElement).value}
placeholder="이름"
/>
{form.errors.name && <span class="error">{form.errors.name}</span>}
</div>

<div>
<input
type="email"
value={form.email}
onInput$={(e) => form.email = (e.target as HTMLInputElement).value}
placeholder="이메일"
/>
{form.errors.email && <span class="error">{form.errors.email}</span>}
</div>

<button type="submit">제출</button>
</form>
);
});

중첩 반응성 (Deep Reactivity)

export const DeepReactivity = component$(() => {
const state = useStore({
user: {
profile: {
name: '홍길동',
address: {
city: '서울',
zip: '12345'
}
}
},
items: [
{ id: 1, name: '사과', done: false },
{ id: 2, name: '바나나', done: true },
]
});

return (
<div>
{/* 중첩 객체 직접 변경 가능 */}
<p>{state.user.profile.address.city}</p>
<button onClick$={() => {
state.user.profile.address.city = '부산'; // 깊은 중첩도 반응성 유지
}}>
도시 변경
</button>

{/* 배열 조작도 반응성 유지 */}
{state.items.map(item => (
<div key={item.id}>
<input
type="checkbox"
checked={item.done}
onChange$={() => item.done = !item.done} // 배열 아이템 직접 변경
/>
{item.name}
</div>
))}
<button onClick$={() => {
state.items.push({ id: Date.now(), name: '새 항목', done: false });
}}>
항목 추가
</button>
</div>
);
});

useStore vs useSignal 선택 기준

// useSignal 사용: 단순 원시값 또는 배열/객체가 통째로 교체되는 경우
const count = useSignal(0);
const userList = useSignal<User[]>([]); // 배열 전체를 교체할 때

// useStore 사용: 복잡한 객체의 특정 필드만 업데이트할 때
const user = useStore({
name: '홍길동',
settings: { theme: 'dark' }
});
user.settings.theme = 'light'; // 객체 내부 변경 → useStore 유리

useTask$ — 부수 효과

기본 사용법

import { component$, useSignal, useTask$ } from '@builder.io/qwik';
import { isServer } from '@builder.io/qwik/build';

export const TaskExample = component$(() => {
const count = useSignal(0);
const doubled = useSignal(0);

// useTask$는 서버와 클라이언트 모두에서 실행
useTask$(({ track }) => {
// track()으로 반응성 등록
const current = track(() => count.value);

// count.value가 변경될 때마다 실행
doubled.value = current * 2;
console.log(`count: ${current}, doubled: ${doubled.value}`);
});

return (
<div>
<p>카운트: {count.value}</p>
<p>두 배: {doubled.value}</p>
<button onClick$={() => count.value++}>증가</button>
</div>
);
});

서버/클라이언트 실행 분기

import { useTask$ } from '@builder.io/qwik';
import { isServer, isBrowser } from '@builder.io/qwik/build';

export const ServerClientTask = component$(() => {
const data = useSignal('');

useTask$(async () => {
if (isServer) {
// 서버에서만 실행되는 코드
// window, document 사용 불가
// Node.js API, process.env 사용 가능
data.value = '서버에서 가져온 데이터';
console.log('서버에서 실행');
}

if (isBrowser) {
// 클라이언트에서만 실행되는 코드
data.value = localStorage.getItem('cached') || '기본값';
console.log('브라우저에서 실행');
}
});

return <p>{data.value}</p>;
});

track()으로 여러 Signal 추적

export const MultiTrack = component$(() => {
const firstName = useSignal('홍');
const lastName = useSignal('길동');
const fullName = useSignal('');

useTask$(({ track }) => {
// 두 Signal 모두 추적
const first = track(() => firstName.value);
const last = track(() => lastName.value);

fullName.value = `${first} ${last}`;
});

return (
<div>
<input
value={firstName.value}
onInput$={(e) => firstName.value = (e.target as HTMLInputElement).value}
/>
<input
value={lastName.value}
onInput$={(e) => lastName.value = (e.target as HTMLInputElement).value}
/>
<p>전체 이름: {fullName.value}</p>
</div>
);
});

cleanup 함수 (리소스 정리)

export const CleanupExample = component$(() => {
const active = useSignal(false);
const status = useSignal('대기 중');

useTask$(({ track, cleanup }) => {
const isActive = track(() => active.value);

if (!isActive) return;

// 타이머 또는 구독 시작
const timer = setInterval(() => {
status.value = `활성 (${new Date().toLocaleTimeString()})`;
}, 1000);

// cleanup: 다음 실행 전 또는 컴포넌트 제거 시 호출
cleanup(() => {
clearInterval(timer);
status.value = '정지';
});
});

return (
<div>
<p>{status.value}</p>
<button onClick$={() => active.value = !active.value}>
{active.value ? '정지' : '시작'}
</button>
</div>
);
});

useVisibleTask$ — 클라이언트 전용 작업

기본 사용법

import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';

export const ClientOnlyTask = component$(() => {
const windowWidth = useSignal(0);
const scrollY = useSignal(0);

// useVisibleTask$는 컴포넌트가 뷰포트에 보일 때 클라이언트에서 실행
useVisibleTask$(() => {
// window, document 안전하게 사용 가능
windowWidth.value = window.innerWidth;
scrollY.value = window.scrollY;

const handleResize = () => windowWidth.value = window.innerWidth;
const handleScroll = () => scrollY.value = window.scrollY;

window.addEventListener('resize', handleResize);
window.addEventListener('scroll', handleScroll);

// cleanup
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('scroll', handleScroll);
};
});

return (
<div>
<p>창 너비: {windowWidth.value}px</p>
<p>스크롤 위치: {scrollY.value}px</p>
</div>
);
});

eagerness 옵션

// useVisibleTask$ 실행 시점 제어
useVisibleTask$(() => {
// 기본값: 컴포넌트가 뷰포트에 보일 때 실행
}, { strategy: 'intersection-observer' }); // 기본값

useVisibleTask$(() => {
// 문서가 완전히 로드된 후 즉시 실행
}, { strategy: 'document-ready' });

useVisibleTask$(() => {
// 브라우저가 idle 상태일 때 실행 (requestIdleCallback)
}, { strategy: 'document-idle' });

실제 사용 예: 차트 라이브러리 초기화

import { component$, useSignal, useVisibleTask$ } from '@builder.io/qwik';

export const ChartComponent = component$(() => {
const chartRef = useSignal<HTMLCanvasElement>();
const data = useSignal([10, 20, 15, 30, 25]);

useVisibleTask$(({ track }) => {
const canvas = chartRef.value;
if (!canvas) return;

// Chart.js 같은 클라이언트 전용 라이브러리 초기화
// (서버에서는 window/canvas가 없으므로 useVisibleTask$ 필요)
const chart = new (window as any).Chart(canvas, {
type: 'bar',
data: {
labels: ['1월', '2월', '3월', '4월', '5월'],
datasets: [{
data: data.value,
backgroundColor: 'rgba(99, 102, 241, 0.5)'
}]
}
});

return () => chart.destroy(); // cleanup
});

return (
<div class="chart-container">
<canvas ref={chartRef} width="400" height="300" />
</div>
);
});

useComputed$ — 계산된 값

기본 사용법

import { component$, useSignal, useComputed$ } from '@builder.io/qwik';

export const ComputedExample = component$(() => {
const items = useSignal([
{ id: 1, name: '사과', price: 1000, qty: 3 },
{ id: 2, name: '바나나', price: 500, qty: 5 },
{ id: 3, name: '포도', price: 2000, qty: 2 },
]);
const taxRate = useSignal(0.1);

// useComputed$: 의존 Signal이 변경될 때 자동 재계산
const subtotal = useComputed$(() =>
items.value.reduce((sum, item) => sum + item.price * item.qty, 0)
);

const tax = useComputed$(() => subtotal.value * taxRate.value);

const total = useComputed$(() => subtotal.value + tax.value);

const formattedTotal = useComputed$(() =>
new Intl.NumberFormat('ko-KR', {
style: 'currency',
currency: 'KRW'
}).format(total.value)
);

return (
<div>
<ul>
{items.value.map(item => (
<li key={item.id}>
{item.name}: {item.price}원 × {item.qty}
</li>
))}
</ul>

<div class="summary">
<p>소계: {subtotal.value.toLocaleString()}</p>
<p>부가세 ({(taxRate.value * 100).toFixed(0)}%): {tax.value.toLocaleString()}</p>
<p><strong>합계: {formattedTotal.value}</strong></p>
</div>

<label>
세율:
<input
type="range" min="0" max="0.3" step="0.01"
value={taxRate.value}
onInput$={(e) => taxRate.value = parseFloat((e.target as HTMLInputElement).value)}
/>
{(taxRate.value * 100).toFixed(0)}%
</label>
</div>
);
});

비동기 계산 (useComputed$ with async)

// useComputed$는 async도 지원
const processedData = useComputed$(async () => {
const raw = rawData.value;
if (!raw.length) return [];

// 비동기 처리
const processed = await processData(raw);
return processed;
});

useResource$ — 비동기 데이터 페칭

기본 사용법

import {
component$,
useSignal,
useResource$,
Resource
} from '@builder.io/qwik';

interface Post {
id: number;
title: string;
body: string;
userId: number;
}

export const PostList = component$(() => {
const page = useSignal(1);

// useResource$: Signal에 반응하는 비동기 데이터 소스
const postsResource = useResource$<Post[]>(async ({ track, cleanup }) => {
// page Signal을 추적
const currentPage = track(() => page.value);

// AbortController로 이전 요청 취소
const controller = new AbortController();
cleanup(() => controller.abort());

const res = await fetch(
`https://jsonplaceholder.typicode.com/posts?_page=${currentPage}&_limit=5`,
{ signal: controller.signal }
);

if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
});

return (
<div>
{/* Resource 컴포넌트로 로딩/에러/성공 상태 처리 */}
<Resource
value={postsResource}
onPending={() => <div>로딩 중...</div>}
onRejected={(error) => <div>에러: {error.message}</div>}
onResolved={(posts) => (
<ul>
{posts.map(post => (
<li key={post.id}>
<h3>{post.title}</h3>
<p>{post.body.substring(0, 100)}...</p>
</li>
))}
</ul>
)}
/>

<div class="pagination">
<button
onClick$={() => page.value--}
disabled={page.value <= 1}
>
이전
</button>
<span>페이지 {page.value}</span>
<button onClick$={() => page.value++}>다음</button>
</div>
</div>
);
});

useResource$와 useSignal 결합 (검색 예제)

export const SearchPage = component$(() => {
const query = useSignal('');
const debouncedQuery = useSignal('');

// debounce 처리
useTask$(({ track, cleanup }) => {
track(() => query.value);

const timer = setTimeout(() => {
debouncedQuery.value = query.value;
}, 300);

cleanup(() => clearTimeout(timer));
});

const results = useResource$<SearchResult[]>(async ({ track, cleanup }) => {
const q = track(() => debouncedQuery.value);

if (!q || q.length < 2) return [];

const controller = new AbortController();
cleanup(() => controller.abort());

const res = await fetch(`/api/search?q=${encodeURIComponent(q)}`, {
signal: controller.signal
});
return res.json();
});

return (
<div>
<input
type="search"
value={query.value}
onInput$={(e) => query.value = (e.target as HTMLInputElement).value}
placeholder="검색어를 입력하세요 (2글자 이상)"
/>

<Resource
value={results}
onPending={() => <p>검색 중...</p>}
onRejected={(err) => <p>검색 오류: {err.message}</p>}
onResolved={(items) =>
items.length === 0
? <p>검색 결과 없음</p>
: <ul>{items.map(item => <li key={item.id}>{item.title}</li>)}</ul>
}
/>
</div>
);
});

직렬화 가능성(Serializability) — QRL 개념

왜 직렬화가 중요한가?

Qwik의 핵심 아이디어는 서버 상태를 HTML에 직렬화하는 것입니다. 이를 위해 모든 상태는 직렬화 가능해야 합니다.

// 직렬화 가능한 타입들
const signal1 = useSignal(42); // 숫자 ✓
const signal2 = useSignal('hello'); // 문자열 ✓
const signal3 = useSignal(true); // 불리언 ✓
const signal4 = useSignal([1, 2, 3]); // 배열 ✓
const signal5 = useSignal({ a: 1 }); // 단순 객체 ✓
const signal6 = useSignal(null); // null ✓

// 직렬화 불가능한 타입들
const bad1 = useSignal(() => {}); // 함수 ✗ (에러)
const bad2 = useSignal(new Map()); // Map ✗
const bad3 = useSignal(new Set()); // Set ✗
const bad4 = useSignal(document.body); // DOM 요소 ✗
const bad5 = useSignal(new MyClass()); // 클래스 인스턴스 ✗ (주의)

noSerialize()로 직렬화 제외

import { component$, useStore, noSerialize } from '@builder.io/qwik';

export const NonSerializable = component$(() => {
const state = useStore({
name: '홍길동',
// noSerialize로 마킹: HTML에 직렬화되지 않음 (클라이언트 전용)
thirdPartyLib: noSerialize(null as any),
});

useVisibleTask$(() => {
// 클라이언트에서만 초기화
state.thirdPartyLib = noSerialize(new SomeHeavyLibrary());
});

return <div>{state.name}</div>;
});

QRL (Qwik URL Reference) 이해

// QRL은 지연 로드되는 함수에 대한 참조입니다
// $ 사인을 사용하면 Optimizer가 자동으로 QRL을 생성합니다

import type { QRL } from '@builder.io/qwik';
import { $ } from '@builder.io/qwik';

// QRL 명시적 생성
const myHandler: QRL<() => void> = $(() => {
console.log('핸들러 실행');
});

// component$ 내부에서 동적 QRL
export const DynamicQRL = component$(() => {
const action = useSignal<'greet' | 'farewell'>('greet');

// 조건부 핸들러 ($ 내부에서 분기)
const handleClick = $(() => {
if (action.value === 'greet') {
alert('안녕하세요!');
} else {
alert('안녕히 가세요!');
}
});

return (
<div>
<select
value={action.value}
onChange$={(e) => action.value = (e.target as HTMLSelectElement).value as any}
>
<option value="greet">인사</option>
<option value="farewell">작별</option>
</select>
<button onClick$={handleClick}>실행</button>
</div>
);
});

$ 사인 규칙 — Optimizer가 하는 일

$ 사인 위치 규칙

// 규칙 1: 컴포넌트 최상위 레벨 또는 모듈 레벨에서 사용
export const MyComponent = component$(() => { // ✓ 모듈 레벨
const handler = $(() => { ... }); // ✓ 컴포넌트 최상위
return <button onClick$={handler}>클릭</button>;
});

// 규칙 2: 조건문 안에서 $ 생성 금지
export const Bad = component$(() => {
const show = useSignal(true);

// ✗ 이렇게 하면 안됨
// if (show.value) {
// const handler = $(() => { ... }); // 조건문 안에서 $ 생성 불가
// }

// ✓ 올바른 방법: 항상 최상위에서 생성
const handler = $(() => {
if (show.value) console.log('보임');
});

return <button onClick$={handler}>클릭</button>;
});

// 규칙 3: 반복문 안에서 $ 생성 금지
export const BadLoop = component$(() => {
const items = [1, 2, 3];

// ✗ 이렇게 하면 안됨
// const handlers = items.map(i => $(() => console.log(i)));

// ✓ 올바른 방법: 데이터를 이벤트로 전달
return (
<ul>
{items.map(item => (
<li key={item}>
<button onClick$={() => console.log(item)}>{item}</button>
</li>
))}
</ul>
);
});

$ 사인 있어야 하는 패턴

// 모든 $ 패턴 예시
import {
component$, // 컴포넌트 정의
useTask$, // 태스크/이펙트
useVisibleTask$, // 클라이언트 태스크
useComputed$, // 계산된 값
$, // 임의 함수 지연 로드
sync$, // 동기 QRL (특별한 경우)
} from '@builder.io/qwik';

import {
routeLoader$, // 서버 데이터 로더
routeAction$, // 폼 액션
server$, // 서버 전용 함수
globalAction$, // 전역 액션
} from '@builder.io/qwik-city';

실전 예제 모음

예제 1: 실시간 검색 필터

export const FilterList = component$(() => {
const searchQuery = useSignal('');
const allItems = useSignal([
'사과', '바나나', '포도', '딸기', '키위',
'망고', '파인애플', '수박', '복숭아', '메론'
]);

const filteredItems = useComputed$(() => {
const q = searchQuery.value.toLowerCase();
return allItems.value.filter(item => item.toLowerCase().includes(q));
});

return (
<div>
<input
type="search"
value={searchQuery.value}
onInput$={(e) => searchQuery.value = (e.target as HTMLInputElement).value}
placeholder="과일 검색..."
/>
<p>{filteredItems.value.length}개 결과</p>
<ul>
{filteredItems.value.map(item => (
<li key={item}>{item}</li>
))}
</ul>
</div>
);
});

예제 2: 자동 저장 폼

export const AutoSaveForm = component$(() => {
const content = useSignal('');
const saveStatus = useSignal<'idle' | 'saving' | 'saved'>('idle');

// 자동 저장: content 변경 3초 후 저장
useTask$(({ track, cleanup }) => {
const text = track(() => content.value);

if (!text) return;

const timer = setTimeout(async () => {
saveStatus.value = 'saving';
await new Promise(resolve => setTimeout(resolve, 500)); // 서버 요청 시뮬레이션
localStorage.setItem('draft', text);
saveStatus.value = 'saved';

// 3초 후 idle로 되돌리기
setTimeout(() => saveStatus.value = 'idle', 3000);
}, 3000);

cleanup(() => clearTimeout(timer));
});

// 초기 로드 시 저장된 내용 복원
useVisibleTask$(() => {
const saved = localStorage.getItem('draft');
if (saved) content.value = saved;
});

return (
<div>
<textarea
value={content.value}
onInput$={(e) => content.value = (e.target as HTMLTextAreaElement).value}
rows={10}
cols={50}
placeholder="자동 저장됩니다..."
/>
<div class="save-status">
{saveStatus.value === 'saving' && '저장 중...'}
{saveStatus.value === 'saved' && '저장 완료 ✓'}
{saveStatus.value === 'idle' && content.value && '(자동 저장 예정)'}
</div>
</div>
);
});

예제 3: 비동기 데이터와 에러 처리

interface GitHubUser {
login: string;
name: string;
avatar_url: string;
public_repos: number;
followers: number;
}

export const GitHubProfile = component$(() => {
const username = useSignal('');
const searchInput = useSignal('');

const userResource = useResource$<GitHubUser>(async ({ track, cleanup }) => {
const name = track(() => username.value);
if (!name) return null as any;

const controller = new AbortController();
cleanup(() => controller.abort());

const res = await fetch(`https://api.github.com/users/${name}`, {
signal: controller.signal
});

if (res.status === 404) throw new Error('사용자를 찾을 수 없습니다');
if (!res.ok) throw new Error(`API 오류: ${res.status}`);

return res.json();
});

return (
<div class="github-profile">
<div class="search-bar">
<input
type="text"
value={searchInput.value}
onInput$={(e) => searchInput.value = (e.target as HTMLInputElement).value}
placeholder="GitHub 사용자명"
onKeyDown$={(e) => {
if (e.key === 'Enter') username.value = searchInput.value;
}}
/>
<button onClick$={() => username.value = searchInput.value}>
검색
</button>
</div>

{username.value && (
<Resource
value={userResource}
onPending={() => (
<div class="loading">
<span>프로필 로딩 중...</span>
</div>
)}
onRejected={(error) => (
<div class="error">
<p>오류: {error.message}</p>
<button onClick$={() => username.value = ''}>다시 시도</button>
</div>
)}
onResolved={(user) =>
user ? (
<div class="profile-card">
<img src={user.avatar_url} alt={user.login} width={80} height={80} />
<h2>{user.name || user.login}</h2>
<p>@{user.login}</p>
<div class="stats">
<span>저장소: {user.public_repos}</span>
<span>팔로워: {user.followers}</span>
</div>
</div>
) : <p>검색어를 입력하세요</p>
}
/>
)}
</div>
);
});

고수 팁 섹션

팁 1: Signal 배열 vs 배열 Signal

// Signal 배열: 각 항목이 독립적으로 반응
// → 한 항목 변경 시 그 항목만 업데이트
const items = [useSignal('첫번째'), useSignal('두번째')];

// 배열 Signal: 배열 전체가 하나의 Signal
// → 배열 내 어떤 변경도 전체 재렌더링
const itemList = useSignal(['첫번째', '두번째']);
// 또는
const itemStore = useStore({ items: ['첫번째', '두번째'] });
// itemStore.items[0] = '변경' → 해당 부분만 업데이트

// 실무에서는 useStore의 중첩 반응성이 가장 유용

팁 2: useTask$ vs useVisibleTask$ 선택 기준

useTask$   → 서버 + 클라이언트 모두 실행
→ SSR 시 데이터 준비, 반응형 계산에 사용
→ window/document 사용 불가

useVisibleTask$ → 클라이언트 전용
→ DOM 조작, 브라우저 API, 서드파티 라이브러리 초기화
→ window/document 사용 가능
→ 뷰포트에 보일 때 실행 (기본값)

팁 3: 메모리 누수 방지

export const EventListener = component$(() => {
useVisibleTask$(({ cleanup }) => {
const handler = (e: MouseEvent) => console.log(e.clientX);
document.addEventListener('mousemove', handler);

// 반드시 cleanup으로 이벤트 리스너 제거!
cleanup(() => document.removeEventListener('mousemove', handler));
});

return <div>마우스 위치 추적 중</div>;
});

팁 4: 커스텀 훅 패턴

// src/hooks/use-local-storage.ts
import { useSignal, useVisibleTask$ } from '@builder.io/qwik';
import type { Signal } from '@builder.io/qwik';

// 커스텀 훅: localStorage와 동기화되는 Signal
export function useLocalStorage<T>(key: string, initialValue: T): Signal<T> {
const value = useSignal<T>(initialValue);

useVisibleTask$(({ track }) => {
// 초기 로드: localStorage에서 값 읽기
const stored = localStorage.getItem(key);
if (stored) {
try {
value.value = JSON.parse(stored);
} catch {
value.value = initialValue;
}
}
});

useVisibleTask$(({ track }) => {
// 값 변경 시 localStorage에 저장
const current = track(() => value.value);
localStorage.setItem(key, JSON.stringify(current));
});

return value;
}

// 사용 예
export const Settings = component$(() => {
const theme = useLocalStorage('theme', 'light');

return (
<button onClick$={() => theme.value = theme.value === 'light' ? 'dark' : 'light'}>
현재 테마: {theme.value}
</button>
);
});

팁 5: useResource$ 에러 재시도 패턴

export const RetryableResource = component$(() => {
const retryCount = useSignal(0);

const data = useResource$<any>(async ({ track }) => {
track(() => retryCount.value); // 재시도 트리거

const res = await fetch('/api/unstable-endpoint');
if (!res.ok) throw new Error('요청 실패');
return res.json();
});

return (
<Resource
value={data}
onPending={() => <p>로딩 중...</p>}
onRejected={(err) => (
<div>
<p>오류: {err.message}</p>
<button onClick$={() => retryCount.value++}>
재시도 ({retryCount.value})
</button>
</div>
)}
onResolved={(result) => <pre>{JSON.stringify(result, null, 2)}</pre>}
/>
);
});

요약

용도실행 환경
useSignal단순 반응형 상태서버 + 클라이언트
useStore복잡한 객체 상태서버 + 클라이언트
useTask$반응형 사이드 이펙트서버 + 클라이언트
useVisibleTask$클라이언트 전용 작업클라이언트만
useComputed$계산된 파생 값서버 + 클라이언트
useResource$비동기 데이터 페칭서버 + 클라이언트
개념핵심 포인트
Signal.value로 읽기/쓰기, 세밀한 반응성
직렬화원시값, 배열, 단순 객체만 가능
$ 규칙컴포넌트 최상위 레벨에서만 생성
QRL지연 로드되는 함수 참조
track()useTask$ 내에서 Signal 구독 등록
cleanup()리소스 정리 함수 등록

이제 Qwik의 기본 반응성 시스템을 이해했습니다. 다음 장에서는 QwikCity의 라우팅, 서버 사이드 기능, API 처리를 배웁니다.

Advertisement