State
State는 파생 노드입니다. 값을 직접 쓸 수 없고, 상류(Source나 다른 State)에서 계산돼 나옵니다. 런타임 생성자가 없습니다 — State 값은 :Compute / :Depend / :Gate(그리고 그 위에 얹힌 :Apply 팩토리들)의 결과로만 생깁니다. 쓰기가 필요하면 Source입니다.
이 페이지의 심볼: state:Get() · state:Compute(fn, ...deps) · state:Depend(...) · state:Apply(factory) · state:Observer(fn) · state:Gate(setup)
-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)local q = require("@game/ReplicatedStorage/Client/UI/Quad")타입 표기 — 타입의 출처는 quad-types 패키지이고, 01장의 설정 모듈이 자주 쓰는 아홉을 다시 내보내므로 이 문서는 q.State<T>처럼 적습니다. quad-base를 직접 require한 모듈에는 State라는 값도, State<T>라는 타입도 없습니다 — 설정 모듈을 안 거친다면 QuadTypes.State<T>로 적으세요.
export type StateData<T> = { read __quadState: true, read __quadStateValue: T, Get: (self: StateData<T>) -> T }
export type State<T> = StateData<T> & { Compute: <U>(self: StateData<T>, fn: (self: StateData<T>, previous: U?, ...any) -> U, ...any) -> State<U>, Depend: (self: StateData<T>, ...any) -> State<T>, Apply: (<U>(self: StateData<T>, factory: (State<T>) -> U) -> U) & ((self: StateData<T>, factory: { __apply: (self: any, state: any) -> any }) -> any), Observer: (self: StateData<T>, fn: ObserverFn<T>?) -> Observer, Gate: (self: StateData<T>, setup: GateSetup) -> State<T>,}StateData<T>는 “값을 읽을 수 있는 부분”만 떼어낸 것이고, 콜백 파라미터 자리에 오는 lazy 핸들이 이 타입입니다. __quadStateValue는 타입에만 있는 팬텀 필드입니다 — 런타임 값에는 없으니 읽지 마세요. State<T>는 주석 자리에서 불변입니다(State<number>는 State<number | UDim> 자리에 들어가지 않습니다). 값을 넘겨받는 자리들은 그래서 공변 마커 타입을 요구합니다 — Quadnomicon Vol. 4.
print(state)는 캐시가 유효할 때 State(<값>), 아직 계산 전이거나 무효화된 상태면 State(?)로 찍힙니다. 출력을 위해 계산을 돌리지 않기 때문입니다. :Gate가 끼운 게이트 노드는 같은 모양을 이름만 바꿔 씁니다 — Gate(2), 캐시가 무효면 Gate(?).
state:Get()
섹션 제목: “state:Get()”시그니처
Get: (self: StateData<T>) -> T반환 — 이 노드의 현재 값.
동작
- lazy입니다. 아무도
:Get()을 부르지 않으면:Compute의 함수는 돌지 않습니다. 반대로 값이 이미 최신이면:Get()을 여러 번 불러도 재계산은 없습니다. - 재계산이 필요하면 한 번 계산합니다. 닫힌 게이트 뒤에 있어 통지가 못 온 상류 변경도
:Get()이 알아채 다시 계산하므로, 정의된 경로에서:Get()은 언제나 “가장 최신 입력으로 계산된 값”을 돌려줍니다. 계산 함수가 도는 도중에 상류가 움직이는 것(계산 함수 안의:Set·yield)은 아래 규칙대로 정의되지 않은 동작이고, 그때 그:Get()은 한 파동 뒤처진 값을 돌려줄 수 있습니다(지금은 다음:Get()이 따라잡습니다 — 관측, 계약 아님). - 이 되감기는 값만 앞당깁니다. 통지(구독자 발화)는 진짜 emit이 올 때까지 기다립니다.
- ⚠️ 계산 함수가 매 회차마다 자기 상류를 다시
:Set한다면 이 루프는 수렴하지 않습니다. 정의되지 않은 동작입니다(계산 함수 안의:Set자체가 그렇습니다 — 아래state:Compute).
state:Compute(fn, ...deps)
섹션 제목: “state:Compute(fn, ...deps)”시그니처
Compute: <U>(self: StateData<T>, fn: (self: StateData<T>, previous: U?, ...any) -> U, ...any) -> State<U>인자
| 이름 | 타입 | 설명 |
|---|---|---|
fn |
(self, previous?, ...deps) -> U |
계산 함수. 함수가 아니면 에러. |
...deps |
State/Source |
후행 의존성. 리시버 외에 더 구독할 노드들. nil은 에러. |
반환 — State<U> 노드 하나. 의존성이 여럿이어도 노드는 하나입니다(합치는 중간 노드를 만들지 않습니다).
동작
반응형 콜백은 파동 도중 yield하지 않고, 꼬인 파동을 풀어 줄 도구가 없는 곳에서 자기 입력을 되먹이지 않습니다. 계산 함수는 어떤 State도 :Set하지 않습니다 — 읽어서 조합해 돌려줄 뿐입니다. 계산 함수 안(그 함수가 부른 것 안 포함)의 :Set/:Emit과 yield는 정의되지 않은 동작입니다. 예외는 하나 — Effect가 자기 의존성을 :Set하면 끝난 뒤 한 번 더 도는데, 지연 재실행이 그 꼬임을 푸는 정의된 동작입니다. 이 밖은 정의되지 않습니다. q.debug를 켜면 계산 함수 도중의 :Set/:Emit과, 멈춘 계산 위에 새 계산이 시작되는 것(yield)이 경고 한 줄로 드러납니다(동작은 그대로).
- 콜백이 받는 것은 값이 아니라 lazy 핸들입니다. 첫 인자
self는 리시버(:Compute를 부른 그 노드)의 핸들이고, 세 번째 인자부터는...deps가 넘긴 순서 그대로의 핸들입니다. 전부:Get()으로 읽습니다. - 예외는 두 번째 인자
previous입니다 — 이건 핸들이 아니라 이 노드가 직전에 계산해 둔 결과값 자체이고, 아직 한 번도 계산하지 않았으면nil입니다. 노드마다 독립적으로 기억됩니다.previous는 값의 연속성(누적·직전 값과의 비교)을 위한 것이지 자원 소유·정리를 위한 것이 아닙니다. 계산 함수가previous로 받은 Instance나 연결을Destroy/q.dispose하고 새로 만드는 모양은 쓰지 마세요 — 계산 함수는 순수해야 하고(언제 몇 번 돌지는 읽는 쪽이 정합니다 — 시작하기 19장), 실행이 겹치면(yield·재진입) 각 실행이 시작할 때 같은previous를 읽으므로 같은 값을 두 번 정리하게 되거나, 어떤 결과는previous로 넘어오지 못하고 버려져 정리되지 않습니다(정의되지 않은 동작). 만들고 치워야 하는 자원은Effect의 cleanup으로 다루세요.
- 조건에 따라
self:Get()을 건너뛰는 계산도 그대로 허용됩니다 — 읽지 않으면 그 회차엔 상류를 계산하지 않습니다. - 계산 함수는 값 한 세대에 한 번만 돕니다. 계산 함수가 자기 노드의 값을 읽으면(직접, 또는 하류 State를 거쳐 — 의존성 순환) 그
:Get()줄에서 아래 문구로 에러가 납니다 — 순환은 만든 쪽의 실수라 그 자리에서 잡힙니다. 계산 함수가 던지면 그 세대의 재읽기도 같은 문구로 에러입니다. 관측(계약 아님): 지금은 상류가 바뀌어 새 세대가 되면 계산 함수가 다시 돌아, 잘못된 값을 고쳐:Set하면 그 노드가 회복됩니다(그 상류가Blocker·Debounce뒤에서 유보 중이어도 같습니다 — 게이트는 통지만 붙들지 값을 가리지 않습니다). 이것은 던진 노드를 영구히 죽이지 않는 지금 구현의 결과이지 약속이 아니니, 일시적으로 던지는 계산 함수에 기대어 코드를 짜지 마세요. 던지는 순간 그 값을 읽던 소비자는 자기 자리의 계약대로 남습니다 — 읽던Effect는 죽습니다(Quad0088류 —q.Effect의 계약). 관측(계약 아님): 같은 세대에 그 값을 읽은:List원소가 있으면 그 Slot의Length발행이 멈추고, 어느 소비자가 예외를 받는지는 구독 순회 순서라 정해져 있지 않습니다. 잡히지 않는 순환 하나: 계산 함수가 자기 의존성을:Set한 뒤 자기 값을 읽으면 그:Set이 새 세대를 만들어 재진입으로 보이지 않고 스택 오버플로가 납니다(계산 함수 안에서 자기 의존성을 쓰는 것 자체가 정의되지 않은 동작).Quad0184 State: Compute fn is already running for this value - a dependency cycle (the fn reads its own value, directly or through a downstream State), a yield inside the fn, or an earlier read that threw (it runs again once an upstream changes) - 계산 함수 안의 yield는 정의되지 않은 동작입니다 — 계산은 읽는 쪽의
:Get()안에서 동기로 돕니다. UB에서의 현재 관측(계약 아님): 멈춘 사이 다른 코루틴이 같은 세대를 읽으면 위 문구로 에러가 납니다. 겹친 계산들이 어느 결과를 남기는지는 정하지 않습니다. - 여러
State중 하나를 고르는 계산은 고를State들을...deps로 넣고:Get()한 값을 돌려주는 것이 정식입니다. 그러면 한 원천의 변경이 선택과 값을 함께 바꿔도 이 노드가 한 파동을 접어 한 번만 내보냅니다.State자체를 돌려주는 계산(State<State<T>>)도 지원되지만 의미가 다릅니다 — 바깥 노드는 안쪽State를 읽지(pull) 않으므로 안쪽이 지금 파동을 받았는지 모르고, 그래서 관여하지 않는 것이 이 구조의 의도된 동작입니다. 같은 원천에서 한 번에 선택과 안쪽 값이 함께 바뀌면, 소비하는 자리에는 빠진 가지의 (그 자체로는 옳은) 새 값이 한 번 쓰였다가 새 선택으로 바뀔 수 있습니다 — 그 값이 이미 다른 곳에 마운트된 Instance면 숫자 키 자리든 Slot 원소 자리든Quad0156으로 드러납니다. 고르는 계산은 대부분 값으로 접는 쪽이 낫습니다. - 계산 결과로 Modifier를 돌려줄 수 없습니다:
Quad0181 State: a Compute function returned a Modifier - State/Source cannot hold Modifiers - 인자 검증:
Quad0187 State: Compute fn must be a function (got {typeof(fn)})/Quad0186 State: dep #{i + 1} is nil/Quad0183 State: dep #{i} is not a State/Source. 번호는 리시버가 1번이라, 후행 의존성의 첫 자리가#2입니다. 검사는nil을 먼저 전부 보고 그다음 타입을 보므로a:Depend(5, nil)은#2가 아니라dep #3 is nil을 냅니다(Effect는 자리 순서대로 한 번에 봅니다 —q.Effect(fn, nil, 5)는Quad0095 Effect: dep #1 is nil,q.Effect(fn, 5, nil)은Quad0096 Effect: dep #1 is not a State/Source/Ref— [2026-09-27 mock 실측]).
strict 캐비엇 — 반환이 State<U>로 온전히 추론되지 않는 자리가 있어, 파생 노드를 만드는 줄에는 결과 타입을 주석하는 것이 이 코퍼스의 관례입니다. 또 ...deps가 ...any이므로 콜백 안에서 의존성 파라미터에 타입 주석을 다세요.
예제
local width = q.Source(2)local height = q.Source(10)
local area: q.State<number> = width:Compute(function(self, previous, other: q.StateData<number>) return self:Get() * other:Get()end, height)
print(area:Get()) --> 20height:Set(20)print(area:Get()) --> 40관련 — How-To 02 폼 검증 · Quadnomicon Vol. 1
state:Depend(...)
섹션 제목: “state:Depend(...)”시그니처
Depend: (self: StateData<T>, ...any) -> State<T>인자 — 넘긴 가변 인자는 전부 추가 의존성입니다(:Compute와 같은 검증을 받습니다).
반환 — State<T>. 값은 리시버의 것을 그대로 통과시키는 노드입니다.
동작
- 값은 그대로 두고 구독 범위만 넓히는 노드를 하나 만듭니다. “A의 값을 쓰되, B가 움직여도 다시 발행되어야 한다”가 필요할 때 씁니다.
:Compute처럼 계산 함수를 받지 않으므로previous같은 개념도 없습니다.- 넘긴 의존성의 값을 읽어 오지 않습니다. 이름이
:Compute(fn, ...deps)·q.Effect(fn, ...deps)의deps와 같은 낱말인 이유입니다 — 그 deps만 따로 거는 연산입니다. 값이 필요하면 뒤에 붙인:Compute안에서 직접:Get()하세요. :Observer(fn)와 헷갈리지 마세요 —:Observer는 fn이 이 State를 관측하는 구독 핸들이고,:Depend는 State가 다른 State의 변경을 따라가는 새 노드입니다(콜백이 없습니다).
예제
local text = q.Source("hello")local locale = q.Source("ko")
-- text의 값 그대로, 다만 locale이 바뀌어도 다시 흐른다local shown: q.State<string> = text:Depend(locale)print(shown:Get()) --> hellostate:Apply(factory)
섹션 제목: “state:Apply(factory)”시그니처
Apply: (<U>(self: StateData<T>, factory: (State<T>) -> U) -> U) & ((self: StateData<T>, factory: { __apply: (self: any, state: any) -> any }) -> any)인자
| 이름 | 타입 | 설명 |
|---|---|---|
factory |
함수 또는 __apply를 가진 객체 |
이 노드를 받아 무언가를 돌려주는 애플리커티브 팩토리. |
반환 — 함수 팔은 팩토리가 돌려주는 것 그대로, 객체 팔은 any입니다.
동작
- 함수를 넘기면
factory(self)를 그대로 부릅니다. - 객체를 넘기면 메소드 형태로
factory:__apply(self)를 부릅니다 — 이 자리의self는 팩토리 객체입니다.Blocker와Debounce/Throttle이 이 팔로 붙습니다(q.Animate { … }는 함수를 돌려주는 팩토리라 함수 팔입니다). - 둘 중 어느 쪽도 아니면
Quad0191 State: Apply factory must be a function or an object with an __apply method (got {typeof(factory)})
strict 캐비엇 — 두 팔은 유니온이 아니라 교집합 오버로드입니다(객체가 너비 서브타이핑으로 들어가야 하기 때문). 객체 팔의 반환이 any이므로 결과 타입은 호출부에서 명시하세요:
local raw = q.Source(0)local blocker = q.Blocker()local gated: q.State<number> = raw:Apply(blocker)관련 — sugar/06-blocker · How-To 05 테마와 동적 스타일
state:Observer(fn)
섹션 제목: “state:Observer(fn)”시그니처
Observer: (self: StateData<T>, fn: ObserverFn<T>?) -> Observerexport type ObserverFn<T> = (targetState: StateData<T>, self: Observer, emitFrom: (Epoch | EpochSet)?) -> ()값을 실어주지 않는 leaf 구독 핸들을 만듭니다. 콜백은 값이 아니라 관측 대상 핸들·자기 자신·출처를 받고, 등록 시점에 즉시 한 번 발화합니다. 전체 계약(구독 네 진입점, 보류와 캐치업)은 05-observer-effect 가 정본입니다.
인자 검증: Quad0190 State: Observer fn must be a function or nil (got {typeof(fn)}) - nil makes the always-observe utility
state:Gate(setup)
섹션 제목: “state:Gate(setup)”시그니처
Gate: (self: StateData<T>, setup: GateSetup) -> State<T>export type GateEmit = (commit: boolean?) -> booleanexport type GateSetup = (emit: GateEmit) -> () -> ()인자
| 이름 | 타입 | 설명 |
|---|---|---|
setup |
(emit) -> onUpstreamEmit |
게이트 정책. 생성 시 딱 한 번 불리고, 상류 emit마다 불릴 클로저를 돌려줘야 합니다. |
반환 — 같은 T의 State<T>. 전파 경로에 게이트 노드 하나가 끼어든 것뿐입니다.
동작
- 값은 절대 가리지 않습니다. 게이트가 막는 것은 통지뿐이라, 유보 중에도
:Get()은 최신 값을 돌려줍니다. - 상류에서 emit이 오면 게이트는 그 출처를 흡수 집합에 모아 두고
setup이 돌려준 클로저를 부릅니다. 그 클로저가emit을 어떻게 쓸지가 정책의 전부입니다. emit()또는emit(true)— 모아둔 배치를 지금 한 번에 내려보냅니다.emit(false)— 모아둔 배치를 버립니다(전파도, 기록도 없음). 반환값은 “모아둔 게 있었는가”이며, 배치가 비어 있으면 아무 일도 하지 않고false입니다.- 유보 중에 여러 번 온 변경은 한 번의 통지로 합쳐집니다. 내려가는 출처는 원래
Source가 아니라 게이트가 만든 배치(집합)입니다. 게이트가 여럿이면 하류가 받는 횟수는 그래프·순회 순서에 따라 달라질 수 있습니다(Blocker의:Off()절) — 횟수가 아니라 값에 기대세요. - 유보 중에 새로 만든 하류
Observer·Effect는 첫 실행에서 최신 값을 읽지만 유보된 통지는 받은 적이 없으므로, 게이트가 배치를 내려보낼 때 같은 값으로 한 번 더 돕니다(둘이 같습니다 — [2026-09-30 mock 실측]). - 게이트 위에 게이트를 얹을 수 있습니다. 위쪽 게이트가 내려보낸 배치는 참조로 들고 있지 않고 풀어서 자기 집합에 합칩니다.
- 게이트가 내려보내는 도중 하류의
Effect가 게이트의 상류를:Set하면(자기 의존성을 되쓰는 정의된 경로) 그 변경은 새로 모이기 시작한 집합에 들어가 정책을 다시 거칩니다. 하류Observer가 상류를 되먹이는 것은 정의되지 않은 동작입니다(05-observer-effect). - 인자 검증:
Quad0188 State: Gate setup must be a function (emit) -> onUpstreamEmit (got {typeof(setup)})/Quad0189 State: Gate setup must return the onUpstreamEmit function
예제
local raw = q.Source(0)local release: ((commit: boolean?) -> boolean)?
-- 상류 변경을 무조건 붙잡아 두고, release()를 부를 때만 흘려보내는 정책local held: q.State<number> = raw:Gate(function(emit) release = emit return function() end -- 상류 emit이 와도 아무것도 하지 않는다 = 유보end)
local seen = {}held:Observer(function(target) table.insert(seen, target:Get())end):Subscribe()
raw:Set(1)raw:Set(2)print(#seen, held:Get()) --> 1 2 (통지는 설치 발화 한 번뿐, 값은 이미 최신)print((release :: any)()) --> true (모아둔 배치를 흘려보냄 → 구독자 1회 발화)관련 — sugar/06-blocker(Blocker는 이 위의 정책입니다) · 05-observer-effect
답변함: 0 / 0 (0%)