콘텐츠로 이동

싱글턴 폴백

모든 Store가 Provider를 필요로 하는 건 아니에요. 인증 상태, 테마 설정, 피처 플래그 등은 진정한 의미의 전역 상태예요. 앱당 하나의 인스턴스만 존재하고, 모든 컴포넌트가 이를 읽어요.

이런 Store의 경우, Provider 패턴은 불필요한 의례적 코드를 추가할 뿐이에요. 인스턴스를 생성하고, 앱 전체를 감싸고, context를 통해 전달하는 과정을 거쳐도 결국 싱글턴과 동일한 동작을 하게 돼요.

useStore(Def)StoreProvider 외부에서 호출하면, 자동으로 전역 싱글턴 인스턴스로 폴백해요. 별도의 래핑이나 설정이 필요 없어요:

import { useStore } from '@hurum/react'
import { AuthStore } from './store'
function UserMenu() {
const store = useStore(AuthStore)
const user = store.use.currentUser()
const loggedIn = store.use.isLoggedIn() // computed
if (!loggedIn) return <LoginButton />
return <span>Hello, {user.name}</span>
}

Provider도, 설정도, 보일러플레이트도 필요 없어요.

useStore(Def)가 반환한 핸들의 use proxy에서 모든 필드(computed 값 포함)를 hook으로 접근해요:

function CounterDisplay() {
const store = useStore(CounterStore)
const count = store.use.count() // raw state
const doubled = store.use.doubled() // computed
return <p>{count} (doubled: {doubled})</p>
}

각 hook은 해당 필드에 대한 useSyncExternalStore 구독을 생성해요. count가 변경되어도 multiplier가 동일하면, count를 읽는 컴포넌트만 리렌더링돼요.

여러 필드를 결합한 파생 상태가 필요한 경우:

function CounterSummary() {
const store = useStore(CounterStore)
const summary = store.useSelector((state) => ({
count: state.count,
product: state.product,
}))
return <p>{summary.count} x {summary.product}</p>
}

selector는 모든 상태 변경 시 실행되지만, 반환 값이 이전 값과 구조적으로 다를 때만 컴포넌트가 리렌더링돼요.

싱글턴에 Intent를 디스패치해요:

function CounterControls() {
const store = useStore(CounterStore)
return (
<button onClick={() => store.send.plusClicked({ amount: 1 })}>
+1
</button>
)
}

store.send는 이름 기반 단축 문법과 직접 Intent 디스패치를 모두 지원해요:

store.send.plusClicked({ amount: 1 })
store.send(CounterIntents.plusClicked({ amount: 1 }))
store.send(CounterIntents.plusClicked, { amount: 1 })

세 가지 모두 동일해요.

전역 싱글턴은 지연 생성돼요 — 첫 번째 useStore(Def) hook이 Provider 외부에서 호출될 때 생성돼요. 명시적으로 초기화할 필요가 없어요.

다음과 같은 경우 싱글턴 폴백은 적합하지 않아요:

  • 여러 인스턴스가 필요한 경우. 세 개의 차트 위젯이 있는 대시보드, 나란히 있는 두 개의 구매 폼, 각 항목이 자체 Store를 가지는 리스트 — 싱글턴은 하나의 인스턴스만 제공하지만, 여러 개가 필요해요.
  • 초기 상태에 서버 데이터가 필요한 경우. Store.create({ initialState })를 사용하면 서버 데이터를 주입할 수 있어요. 싱글턴은 기본 상태로만 시작해요.
  • 범위가 지정된 의존성이 필요한 경우. 서로 다른 인스턴스가 다른 deps를 필요로 할 수 있어요(예: 하나는 실제 API 클라이언트, 다른 하나는 mock).
  • 서버에서 렌더링하는 경우. 서버에서는 전역 상태가 없어요. 절대로.

이 중 하나라도 해당되면 StoreProvider + useStore()로 전환하세요.

app.tsx
import { useStore } from '@hurum/react'
import { TodoStore } from './store'
function TodoList() {
const store = useStore(TodoStore)
const todos = store.use.todos()
const filter = store.use.filter()
const filteredTodos = store.use.filteredTodos() // computed
return (
<div>
<select
value={filter}
onChange={(e) => store.send.filterChanged({ filter: e.target.value })}
>
<option value="all">All</option>
<option value="active">Active</option>
<option value="completed">Completed</option>
</select>
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => store.send.todoToggled({ id: todo.id })}
/>
{todo.title}
</li>
))}
</ul>
</div>
)
}
  • useStore — Provider 시나리오를 위한 context 인식 접근
  • Provider & withProvider — 범위가 지정된 인스턴스가 필요한 이유와 사용 방법