$state: 반응형 상태 만들기

$state: 반응형 상태 만들기

버튼을 누르면 숫자가 올라가고, 그 숫자가 화면에 그대로 반영되어야 한다고 생각해볼게요. 이렇게 값이 바뀌면 UI가 따라 반응하도록 만드는 상태를 반응형 상태(reactive state) 라고 해요. Svelte 5에서는 $state 룬이 이 역할을 해요.

다른 프레임워크와 달리 Svelte에는 상태를 다루는 별도 API가 없어요. $state 로 만든 값은 그냥 숫자나 객체일 뿐이고, 평범한 변수를 다루듯 재할당하면 UI가 알아서 갱신돼요.

출처: Svelte 공식 문서 — $state

기본 사용법

<script>
	let count = $state(0);
</script>

<button onclick={() => count++}>
	clicks: {count}
</button>

count는 객체나 함수가 아니라 그냥 숫자예요. count++ 로 바꾸면 그 값에 의존하는 화면 부분만 다시 그려져요.

깊은 상태(Deep state)

$state 를 배열이나 단순 객체와 함께 쓰면 결과는 깊게 반응하는 state proxy 가 돼요. Proxy 덕분에 Svelte는 속성을 읽거나 쓸 때 코드를 실행할 수 있고, array.push(...) 같은 메서드까지 감지해서 세밀하게 갱신해요.

let todos = $state([
	{
		done: false,
		text: 'add more todos'
	}
]);

배열에 새 객체를 push해도 그 객체는 자동으로 proxy가 되어요.

let todos = [{ done: false, text: 'add more todos' }];
// ---cut---
todos.push({
	done: false,
	text: 'eat lunch'
});

⚠️ proxy의 속성을 바꿔도 원본 객체는 변하지 않아요. proxy 핸들러가 필요하다면 $state 로 감싼 뒤에 직접 추가해야 해요.

주의할 점은, 반응형 값을 구조분해(destructure) 하면 그 참조는 더 이상 반응하지 않는다는 거예요.

let todos = [{ done: false, text: 'add more todos' }];
// ---cut---
let { done, text } = todos[0];

// 이 코드는 `done` 값에 영향을 주지 않아요
todos[0].done = !todos[0].done;

클래스와 $state

클래스 인스턴스는 proxy가 되지 않아요. 대신 클래스 필드(public/private 모두)나 constructor 안의 첫 할당에 $state 를 쓸 수 있어요.

class Todo {
	done = $state(false);

	constructor(text) {
		this.text = $state(text);
	}

	reset() {
		this.text = '';
		this.done = false;
	}
}

클래스 메서드를 이벤트 핸들러로 바로 넘기면 this 가 컴포넌트가 아니라 <button> 을 가리키는 문제가 생겨요. 인라인 함수로 감싸거나, 클래스 정의에서 화살표 함수를 쓰면 돼요.

<button onclick={() => todo.reset()}>
	reset
</button>

Set, Map, Date, URL 같은 내장 클래스의 반응형 버전은 svelte/reactivity 에서 import 해서 쓸 수 있어요.

$state.raw

배열이나 객체를 깊게 반응형으로 만들고 싶지 않다면 $state.raw 를 써요. $state.raw 로 만든 상태는 변형(mutate)할 수 없고 재할당(reassign)만 가능해요.

let person = $state.raw({
	name: 'Heraclitus',
	age: 49
});

// 이건 효과가 없어요
person.age += 1;

// 이건 새 person을 만들기 때문에 동작해요
person = {
	name: 'Heraclitus',
	age: 50
};

큰 배열·객체를 어차피 바꿀 일이 없을 때 반응형으로 만들 비용을 줄여 성능을 올릴 수 있어요. raw 상태 안에 반응형 상태를 담는 것도 가능해요.

$state.snapshot

깊게 반응하는 $state proxy의 정적 스냅샷을 찍으려면 $state.snapshot 을 써요. 외부 라이브러리나 structuredClone 처럼 proxy를 기대하지 않는 쪽에 값을 넘길 때 유용해요.

<script>
	let counter = $state({ count: 0 });

	function onclick() {
		// `Proxy {...}` 가 아니라 `{ count: ... }` 가 찍혀요
		console.log($state.snapshot(counter));
	}
</script>

$state.eager

await 식 안에서 쓰이는 상태는 업데이트가 동기화되어서, 상태가 바뀌어도 UI에 즉시 반영되지 않을 수 있어요. 사용자 클릭 직후 내비게이션 바 같은 곳을 바로 갱신하고 싶다면 $state.eager(value) 를 써요.

<nav>
	<a href="/" aria-current={$state.eager(pathname) === '/' ? 'page' : null}>home</a>
	<a href="/about" aria-current={$state.eager(pathname) === '/about' ? 'page' : null}>about</a>
</nav>

이 기능은 사용자 동작에 대한 피드백을 줄 때만 아껴서 써야 해요. 일반적으로는 Svelte가 업데이트를 조율하게 두는 편이 더 좋은 사용자 경험을 만들어요.

상태를 함수로 넘기기

JavaScript는 값에 의한 전달(pass-by-value) 언어예요. 함수를 호출할 때 넘기는 건 변수가 아니라 값이에요. 그래서 $state 로 선언한 값을 참조하면 그 현재 값에 접근하는 셈이에요. 함수가 '지금 이 순간의 값'에 계속 접근해야 한다면, 평범한 값 대신 함수(또는 proxy의 getter)를 넘겨야 해요.

let a = $state(1);
let b = $state(2);

이 경우 a 는 그 순간의 값(1)이 담긴 숫자예요. 항상 최신 값을 넘기고 싶다면 () => a 처럼 함수로 감싸는 방식을 써요.

모듈 간 상태 공유

.svelte.js.svelte.ts 파일에 상태를 선언할 수 있지만, 직접 재할당되는 상태는 export 할 수 없어요. 컴파일러는 한 번에 한 파일만 처리하기 때문에, 다른 파일이 count 를 import 하면 Svelte가 각 참조를 $.get/$.set 으로 감싸야 한다는 걸 알지 못해요.

상태를 모듈 간에 공유하는 방법은 두 가지예요. 객체의 속성을 바꾸는 방식으로 재할당하지 않기:

export const counter = $state({
	count: 0
});

export function increment() {
	counter.count += 1;
}

아니면 직접 export 하지 않고 접근 함수로 감싸기:

let count = $state(0);

export function getCount() {
	return count;
}

export function increment() {
	count += 1;
}

더 알아보기