콘텐츠로 이동

02. 폼 유효성 검사와 제출 버튼 제어

대상 독자: 입력이 여럿인 화면을 만들고, 그 결과를 컴포넌트 바깥에서 받아야 하는 개발자 다루는 개념: 상태 주입(작게는 체크박스 하나부터), Source/State, Store, :Compute, :Depend, Out(OnChange), Observer, 입력 다듬기(FocusLost)


로그인·회원가입 폼에서 반복되는 요구사항 넷입니다.

  1. 입력이 바뀔 때마다 실시간으로 유효성을 검사한다.
  2. 유효하지 않으면 에러 라벨을 보인다.
  3. 모든 필드가 유효할 때만 제출 버튼을 활성화하고 색을 바꾼다.
  4. 제출 결과는 폼이 아니라 바깥이 처리한다 — 서버로 보내는 것도, 다음 화면으로 넘기는 것도 폼의 일이 아닙니다.

q.Store는 시작하기 16에서 봤습니다 — 이름 붙은 Source들을 한 테이블에 담는 묶음이고, 전체 표면은 레퍼런스: Store에 있습니다.

Store에 원본 입력값을 모으고 :Compute로 판정을 파생시키면 앞의 셋이 전부 선언으로 끝나고, 그 묶음을 컴포넌트 밖에서 만들어 props로 넣으면 넷째도 따라옵니다.

-- 01장의 설정 모듈: quad_base에 quad_roblox를 설치하고 타입을 다시 내보낸다(시작하기 01 참고)
local q = require("@game/ReplicatedStorage/Client/UI/Quad")

2. 작은 것부터 — 값을 밖에서 받는 체크박스

섹션 제목: “2. 작은 것부터 — 값을 밖에서 받는 체크박스”

폼을 통째로 짜기 전에, 같은 모양의 가장 작은 판을 하나 먼저 봅니다. 체크박스입니다. 체크가 켜졌는지를 정작 필요로 하는 것은 체크박스 자신이 아니라 그것을 화면에 놓은 쪽이므로, 상태를 안에서 만들면 바깥이 값을 볼 길이 없습니다. 그래서 원천은 부르는 쪽이 만들어 props로 넘깁니다.

-- 재사용 컴포넌트(예: ReplicatedStorage/Client/UI/Checkbox)
local function Checkbox(props: { read Value: q.Source<boolean>, read Label: string }): TextButton
local checked = props.Value
-- 파생은 props 테이블 밖에서 만들어 둔다(§4의 주의 참고)
local labelText: q.State<string> = checked:Compute(function(c)
return `{if c:Get() then "☑" else "☐"} {props.Label}`
end)
return q.Elem "TextButton" {
Size = UDim2.new(1, 0, 0, 32),
BackgroundTransparency = 1,
TextXAlignment = Enum.TextXAlignment.Left,
TextColor3 = Color3.fromRGB(255, 255, 255),
Text = labelText,
Activated = function()
checked:Set(not checked:Get())
end,
}
end

컴포넌트가 하는 일은 둘뿐입니다 — 받은 원천을 읽어 그리고, 눌리면 그 자리에 되돌려 씁니다. 값을 어떻게 쓸지는 전부 바깥의 몫이라, 바깥은 같은 원천에 파이프를 붙여 자기 판정을 만듭니다. 아래 폼에서 실제로 필요한 판정 하나를 미리 만들어 보면 이렇습니다.

-- 부르는 쪽 — 원천을 만들고, 그 위에 판정을 얹는다
local agreed = q.Source(false)
-- 약관에 동의해야만 제출할 수 있다(지금은 동의 여부 그대로지만, 폼이 커지면 조건이 여기에 늘어난다)
local canSubmit: q.State<boolean> = agreed:Compute(function(c)
return c:Get()
end)
local submitColor: q.State<Color3> = canSubmit:Compute(function(enabled)
return if enabled:Get()
then Color3.fromRGB(0, 170, 255)
else Color3.fromRGB(80, 80, 90)
end)
local panel = q.Elem "Frame" {
q.Elem "UIListLayout" { Padding = UDim.new(0, 8) },
Checkbox { Label = "약관에 동의합니다", Value = agreed },
q.Elem "TextButton" {
Text = "가입하기",
Interactable = canSubmit, -- false면 눌리지 않는다
BackgroundColor3 = submitColor,
},
}

체크박스를 누르면 Interactable과 버튼 색이 같이 따라옵니다. 체크박스는 제출 버튼이 있는지도 모르고, 제출 버튼은 체크박스가 있는지도 모릅니다 — 둘을 잇는 것은 밖에서 만든 원천 하나뿐입니다.

읽기만 하면 되는 컴포넌트라면 Source 대신 파생된 State를 넘기세요. State에는 :Set이 없어서 받은 쪽이 값을 바꿀 길이 없고, 그래서 “이 컴포넌트는 읽기만 한다”가 넘기는 것의 종류로 드러납니다(레퍼런스: State).

이 모양을 값 여럿·검증·제출까지 키운 것이 아래의 폼입니다. 원천이 하나에서 여럿이 되니 그것을 Store 하나에 모으고, 판정이 하나에서 여럿이 되니 :Compute를 겹치고, 결과를 바깥으로 내보낼 자리가 하나 더 필요해질 뿐 — 밖에서 만들어 넣는다는 뼈대는 그대로입니다.


3. 폼 상태를 테이블 하나로 묶기

섹션 제목: “3. 폼 상태를 테이블 하나로 묶기”

q.Store는 이름 붙은 Source들을 담는 테이블입니다. 생성자에 넘기는 값은 전부 Source여야 하고(아니면 그 자리에서 거부합니다), 꺼낼 때는 store.Username처럼 필드로 읽으면 그 Source 자체가 나옵니다.

여기서 만드는 것은 Store 하나가 아니라, 그 폼 한 벌이 쓰는 상태 전부를 담은 테이블 하나입니다 — 원본 입력(Store), 파생된 판정들, 그리고 제출 결과가 담기는 자리까지. 이 테이블이 그대로 컴포넌트의 props로 들어갑니다.

-- 폼 상태만 모아 둔 모듈(예: ReplicatedStorage/Client/UI/RegistrationFormState).
-- 화면을 만들지 않으므로 UI 없이도 require해서 검증할 수 있습니다.
type Submission = { Username: string, Password: string }
local function makeFormState()
-- [1] 원본 입력값
local store = q.Store({
Username = q.Source(""),
Password = q.Source(""),
})
-- [2] 필드별 판정 — 순수 파생 상태
local isUsernameValid: q.State<boolean> = store.Username:Compute(function(self)
return #self:Get() >= 4
end)
local isPasswordValid: q.State<boolean> = store.Password:Compute(function(self)
local text = self:Get()
return #text >= 8 and string.find(text, "%d") ~= nil
end)
-- [3] 둘을 하나로 — 후행 의존성으로 노드 하나만 더 만든다
local canSubmit: q.State<boolean> = isUsernameValid:Compute(function(validUser, _previous, validPass)
return validUser:Get() and validPass:Get()
end, isPasswordValid)
-- [4] 제출 결과가 담기는 자리 — 바깥으로 나가는 하나뿐인 출구
local submitted = q.Source<<Submission?>>(nil)
return {
Store = store,
IsUsernameValid = isUsernameValid,
IsPasswordValid = isPasswordValid,
CanSubmit = canSubmit,
Submitted = submitted,
}
end
type FormState = typeof(makeFormState())
  • 돌려주는 것은 평범한 레코드입니다 — Store는 그중 한 필드로 들어앉고, 나머지는 파생 노드와 결과 자리입니다. 이름은 마음대로 정해도 되지만 이 문서는 Store/CanSubmit/Submitted로 부릅니다.
  • FormState 타입을 손으로 적을 필요는 없습니다. typeof(makeFormState())가 반환 테이블의 모양을 그대로 타입으로 씁니다(모듈로 나눌 땐 Submission과 함께 export type으로 내보내세요 — 부르는 쪽이 둘 다 씁니다).
  • q.Source<<Submission?>>(nil)처럼 타입 인자를 명시해야 Source<Submission?>가 됩니다 — 값만 보고는 nil밖에 알 수 없기 때문입니다.

폼 상태는 컴포넌트 밖에서 만들어 주입합니다 — §2의 체크박스와 같은 이유입니다. 전역에 싱글톤으로 두면 그 폼을 두 번 띄울 수 없고, 반대로 컴포넌트 안에서 만들어 버리면 바깥이 결과를 볼 길이 없습니다. makeFormState()를 부르는 쪽마다 한 벌씩 만들면 둘 다 풀립니다(근거는 §7).

state:Compute(function(self, previous, ...deps) ... end, ...deps)
  • 첫 번째는 state 자신입니다. 값이 아니라 핸들이므로 :Get()으로 읽습니다.
  • 두 번째는 직전 계산 결과(previous)입니다 — 첫 사이클엔 nil입니다. 여기에 의존성이 오지 않습니다.
  • 세 번째부터가 뒤에 넘긴 의존성들이고, 이들도 전부 핸들이라 :Get()으로 읽습니다.

:Depend(other)로 구독만 넓히고 클로저로 직접 읽는 방법도 있습니다. 노드가 하나 더 생길 뿐 금지된 방법은 아닙니다.

-- makeFormState 안에서, [3] 대신
local canSubmit = isUsernameValid:Depend(isPasswordValid):Compute(function(validUser)
return validUser:Get() and isPasswordValid:Get() -- :Depend한 값은 클로저로 읽는다
end)

⚠️ :Depend로 모은 값은 콜백에 포지셔널로 넘어오지 않습니다. 두 번째 자리에 오는 것은 여전히 previous입니다.


4. UI 조립 — 컴포넌트는 상태를 받기만 한다

섹션 제목: “4. UI 조립 — 컴포넌트는 상태를 받기만 한다”

컴포넌트가 하는 일은 받은 상태를 화면에 잇는 것뿐입니다. 상태를 만들지도, 결과를 어디로 보낼지 정하지도 않습니다.

local function RegistrationForm(props: { read Form: FormState }): Frame
local form = props.Form
local store = form.Store
-- 파생 상태는 props 테이블 밖에서 만들어 둔다(아래 주의 참고).
-- 보이는 모양(색·표시 여부)은 폼 상태가 아니라 이 컴포넌트의 것이다.
local showUsernameError: q.State<boolean> = form.IsUsernameValid:Compute(function(valid)
return not valid:Get()
end)
local submitColor: q.State<Color3> = form.CanSubmit:Compute(function(enabled)
return if enabled:Get()
then Color3.fromRGB(0, 170, 255)
else Color3.fromRGB(80, 80, 90)
end)
return q.Elem "Frame" {
Size = UDim2.new(0, 320, 0, 240),
BackgroundColor3 = Color3.fromRGB(30, 30, 35),
-- 세로로 쌓는다(각 요소의 Size 같은 모양 프로퍼티는 줄이려고 생략했다)
q.Elem "UIListLayout" { Padding = UDim.new(0, 8) },
-- 아이디 입력창
q.Elem "TextBox" {
PlaceholderText = "아이디 (4자 이상)",
Text = store.Username, -- in: 상태 → 입력창
q.Out "TextBox" "Text" (store.Username), -- out: 입력창 → 상태 (OnChange 위 슈거)
},
-- 아이디 에러 메시지
q.Elem "TextLabel" {
Text = "아이디는 4글자 이상이어야 합니다.",
TextColor3 = Color3.fromRGB(255, 100, 100),
Visible = showUsernameError,
},
-- 비밀번호 입력창
q.Elem "TextBox" {
PlaceholderText = "비밀번호 (8자 이상, 숫자 포함)",
Text = store.Password,
q.Out "TextBox" "Text" (store.Password),
},
-- 제출 버튼 — 결과를 직접 처리하지 않고 상태에 적기만 한다
q.Elem "TextButton" {
Text = "가입하기",
Interactable = form.CanSubmit, -- false면 눌리지 않는다
BackgroundColor3 = submitColor,
MouseButton1Click = function()
-- Interactable로 이미 막히지만, 상태 기준으로 한 번 더 확인한다
if not form.CanSubmit:Get() then
return
end
form.Submitted:Set({
Username = store.Username:Get(),
Password = store.Password:Get(),
})
end,
},
}
end

Text = store.Username으로 값을 내려보내면서 같은 Text를 q.Out으로 되쓰는 모양이 순환처럼 보이지만, 되먹임은 생기지 않습니다 — 되쓴 값은 입력창이 이미 가진 값이고, Roblox는 같은 값을 다시 대입해도 변경 이벤트를 발화하지 않습니다(엔진 동작). quad 쪽은 같은 값에도 emit을 하므로 q.Out은 새 값이 Source의 현재 값과 같으면 :Set을 건너뜁니다 — 첫 쓰기가 되돌아오는 메아리를 막기 위해서입니다.

q.Out "Class" "Name" (src)은 q.OnChange "Class" "Name" (fn) 위에 얹은 슈거입니다 — 둘 다 숫자 키 자리에 놓는 디스크립터이고(문자 키가 아닙니다), 콜백을 직접 써야 할 때(값을 가공해서 넣거나 다른 상태를 같이 바꿀 때)는 OnChange를 씁니다. 콜백은 엔진이 주는 인자만 받습니다 — self는 넘어오지 않습니다. 이벤트 핸들러(MouseButton1Click 등)는 반대로 문자 키에 놓습니다. 읽기 전용 프로퍼티까지 포함한 이름 표면과 초기값 발화 계약은 레퍼런스: q.OnChange·q.Out이 다룹니다.

⚠️ :Compute를 props 테이블 안에 인라인으로 쓰면 에디터 타입 검사가 깨집니다. 함수 인자로 넘기는 테이블 리터럴 안에서는 무주석 콜백의 파라미터가 풀리지 않습니다(런타임은 정상이지만 편집기에 빨간 줄이 그어집니다). 위처럼 별도 문장으로 빼서 타입을 붙인 지역 변수에 담거나, 콜백 파라미터에 q.StateData<T> 주석을 다세요. 숫자 키 자리에 놓는 값은 이 문제를 겪지 않습니다.


5. 밖에서 만들어 넘기고, 밖에서 결과 받기

섹션 제목: “5. 밖에서 만들어 넘기고, 밖에서 결과 받기”

부르는 쪽이 폼 상태를 만들어 넘기고, 같은 테이블의 Submitted를 관측합니다. 상태와 컴포넌트를 각각 모듈로 빼 두었다면(makeFormState를 내보내는 모듈과 return RegistrationForm하는 모듈), 진입점은 그 둘을 require합니다.

-- 진입점(예: Main.client.luau)
local form = makeFormState()
local screen = q.Elem "ScreenGui" {
RegistrationForm { Form = form },
-- 숫자 키 자리의 Observer — 이 ScreenGui가 사는 동안만 관측한다
-- (파라미터 타입을 적어야 strict가 payload의 필드를 검사합니다 — 인라인 자리에선 타입이 unknown으로 굳습니다)
form.Submitted:Observer(function(target: q.StateData<Submission?>)
local payload = target:Get()
if payload == nil then
return -- 묶이는 순간의 첫 발화 — 아직 제출 전이다
end
-- 여기서 서버로 보내거나 다음 화면으로 넘긴다
print("가입 요청:", payload.Username)
end),
}
  • 첫 발화는 nil을 들고 옵니다. Observer는 값을 실어주지 않고 만들어질 때 한 번 불리므로(실측: 숫자 키 자리에 놓기 전에 이미 1회), 아직 제출이 없는 상태를 nil로 걸러내야 합니다. 제출 버튼을 누르면 그때 2회째가 오고, 그 시점의 Submitted에 방금 적힌 payload가 들어 있습니다.
  • 관측도 화면과 함께 죽습니다. q.dispose(screen)(quad가 관리하는 값을 확인하며 파괴하는 정본 경로 — 시작하기 10)으로 화면을 내린 뒤에 Submitted를 바꿔도 콜백은 더 불리지 않습니다(실측: 발화 수 2 그대로). 화면보다 오래 사는 소비자가 필요하면 숫자 키 자리 대신 observer:Subscribe()로 전역 구독을 잡으세요 — 대신 그 수명은 직접 책임집니다(레퍼런스: Observer).
  • 같은 폼을 두 번 띄워도 섞이지 않습니다. makeFormState()를 두 번 부르면 두 벌이 독립적으로 돕니다(실측: 한쪽에만 아이디를 넣으면 다른 쪽 판정은 그대로 false).
상태 대신 콜백으로 받고 싶다면

props.OnSubmit 콜백을 받는 모양도 됩니다. Submitted 자리를 대신하는 것이지 둘을 같이 두는 것이 아닙니다 — 출구가 둘이면 어느 쪽이 정본인지 부르는 쪽이 알 수 없습니다.

-- props에 콜백이 하나 늘고, makeFormState의 [4] Submitted 필드는 사라집니다
type Props = { read Form: FormState, read OnSubmit: (Submission) -> () }
local function submit(props: Props)
if not props.Form.CanSubmit:Get() then
return
end
props.OnSubmit({
Username = props.Form.Store.Username:Get(),
Password = props.Form.Store.Password:Get(),
})
end

§4의 버튼은 MouseButton1Click = function() submit(props) end이 되고, 부르는 쪽은 RegistrationForm { Form = form, OnSubmit = function(payload) … end }으로 넘깁니다.

대신 잃는 것이 둘입니다. 관측자가 하나로 고정되고(둘이 보려면 부르는 쪽이 직접 갈라야 합니다), 콜백을 언제까지 부르는지가 계약에 안 남습니다 — 화면이 죽은 뒤의 호출을 막는 것은 Observer처럼 quad가 해 주는 일이 아니라 부르는 쪽 몫이 됩니다.


6. 입력 다듬기 — 앞뒤 공백을 깎은 값이 필요할 때

섹션 제목: “6. 입력 다듬기 — 앞뒤 공백을 깎은 값이 필요할 때”

지금까지의 폼은 입력을 받은 그대로 판정합니다. 아이디 칸에 abc (끝에 공백)를 넣으면 네 글자로 세어 통과하고, 제출 payload에도 공백이 그대로 실립니다. 판정과 제출에는 앞뒤 공백을 깎은 값을 쓰고 싶을 때가 많습니다. 깎는 함수는 평범한 Luau입니다.

local function trim(s: string): string
return (string.gsub(s, "^%s*(.-)%s*$", "%1"))
end

먼저 떠오르는 모양 — 입력창에 다듬은 값을 꽂기

섹션 제목: “먼저 떠오르는 모양 — 입력창에 다듬은 값을 꽂기”

다듬은 값을 파생으로 만들고, 그걸 입력창의 Text에 꽂고, 되쓰기는 원본으로 받는 모양입니다.

-- 권하지 않는 모양
local trimmedUsername: q.State<string> = store.Username:Compute(function(self)
return trim(self:Get())
end)
q.Elem "TextBox" {
Text = trimmedUsername, -- 다듬은 값을 입력창에
q.Out "TextBox" "Text" (store.Username), -- 입력창 → 원본
}

이 모양은 정의된 경로이고, 끝없이 돌지도 않습니다. 사용자가 a 를 치면 q.Out이 원본을 "a "로 바꾸고, 파생이 "a"를 만들어 입력창에 쓰고, 그 쓰기가 엔진의 변경 신호로 다시 q.Out에 닿아 원본이 "a"가 되고, 다듬은 값도 "a"라 더 쓸 것이 없어 멈춥니다. Observer가 자기 상류를 되쓰는 것은 정의되지 않은 동작이지만, 이렇게 엔진을 한 바퀴 돌아 q.Out·OnChange로 올라오는 왕복은 그와 달리 정상 경로입니다. 그래도 권하지 않는 이유가 둘 있습니다.

첫째, 원본을 보는 다른 소비자가 한 번의 입력에 두 번 흔들립니다. Roblox의 기본 시그널 설정인 Deferred에서는 엔진이 변경 신호를 다음 재개점으로 미뤄 배달하므로, 원본을 보는 소비자는 다듬기 전 값 "a "를 먼저 받고 엔진이 다듬은 쓰기의 신호를 배달한 뒤에 "a"를 한 번 더 받습니다. 두 번 사이의 간격은 quad가 아니라 엔진의 배달 시점이 정합니다([2026-10-01 기준] 이 두 번 받기는 배달을 미루는 흉내를 낸 mock으로 확인했고, 실기기에서 간격을 재지는 않았습니다). 플레이스가 Immediate 설정이면 되쓰기가 원래 전파 도중에 끼어들어, 소비자가 무슨 값을 몇 번 받는지가 또 달라집니다(신호를 동기로 내는 테스트용 mock에서는 다듬은 값만 한 번 받았습니다). 어느 쪽이든 폼 상태를 보는 쪽이 신호 설정에 따라 다른 것을 보게 됩니다.

둘째, 더 큰 이유는 타이핑하는 사람이 불편하다는 것입니다. 끝 공백이 치는 즉시 지워지므로 hello world를 칠 수 없습니다 — hello 뒤에 띄어쓰기를 치는 순간 입력창이 hello로 돌아가고, 이어 친 w가 붙어 hellow가 됩니다. 또 프로그램이 입력 중인 Text를 덮어쓰면 커서가 있던 자리가 사라지거나 옮겨질 수 있습니다. 덮어쓴 뒤 커서를 어디에 두는지는 엔진이 정하는 동작이고, 이 문서는 그것을 실기기에서 확인하지 않았습니다([2026-10-01 기준] 미실측).

권하는 모양 — 원본은 그대로, 다듬은 값은 파생으로, 확정은 이벤트에서

섹션 제목: “권하는 모양 — 원본은 그대로, 다듬은 값은 파생으로, 확정은 이벤트에서”

입력창은 원본 Source 하나와 양방향으로 둡니다(§4 그대로). 다듬은 값은 파생으로 만들고, 그 값이 필요한 쪽 — 판정과 제출 — 이 파생을 읽습니다.

-- makeFormState 안에서, [2]의 아이디 판정을 다듬은 값 위로 옮긴다
local username: q.State<string> = store.Username:Compute(function(self)
return trim(self:Get())
end)
local isUsernameValid: q.State<boolean> = username:Compute(function(self)
return #self:Get() >= 4
end)
-- 반환 레코드에 `Username = username`을 더한다 — 제출과 확정이 이 값을 읽는다

입력창에 보이는 값까지 다듬어야 하면, 사용자가 입력을 마친 시점의 이벤트에서 한 번 적습니다. TextBox의 FocusLost는 포커스를 잃을 때 오고, Enter로 끝낸 경우도 여기 포함됩니다(첫 인자 enterPressed가 true). 이벤트는 프로퍼티와 같은 문자 키에 함수로 놓습니다(레퍼런스: 문자 키 — 프로퍼티와 이벤트).

-- RegistrationForm 안의 아이디 입력창
q.Elem "TextBox" {
PlaceholderText = "아이디 (4자 이상)",
Text = store.Username, -- 표시는 원본 그대로 — 치는 동안은 건드리지 않는다
q.Out "TextBox" "Text" (store.Username),
FocusLost = function()
-- 입력을 마친 시점에 다듬은 값을 원본에 적는다
store.Username:Set(form.Username:Get())
end,
},

제출 버튼의 payload도 Username = form.Username:Get()으로 바꿉니다. 이제 치는 동안에는 입력창이 사용자가 친 그대로이고, 판정은 다듬은 값으로 바로바로 바뀌며, 포커스를 잃는 순간 입력창도 다듬은 값으로 정리됩니다.

이벤트 핸들러 안의 :Set은 값이 들어오는 자리에서 하는 것이라 정상입니다 — §2의 체크박스가 Activated에서 checked:Set하던 것과 같은 자리입니다. 그 :Set이 입력창 Text를 다듬은 값으로 쓰고, 그 쓰기가 엔진 신호로 q.Out에 다시 닿아도 원본이 이미 같은 값이라 q.Out이 건너뜁니다. 그래서 원본을 보는 소비자는 확정 한 번에 한 번만 받습니다.

비밀번호처럼 공백도 값인 필드는 다듬지 마세요 — 다듬을지는 필드마다 정하는 것이고, 원본을 그대로 두는 이 모양이라면 필드마다 고르기가 쉽습니다.

길이 제한 — 잘라서 되쓸 땐 :Set

섹션 제목: “길이 제한 — 잘라서 되쓸 땐 :Set”

다듬기와 달리 최대 길이는 치는 순간 막아야 합니다. 넘친 글자가 입력창에 보이면 안 되기 때문입니다. 그래서 확정 이벤트가 아니라 되쓰기 자리에서 자르는데, 이때는 q.Out 대신 OnChange 콜백을 직접 쓰고, 잘린 값을 :Set 으로 적습니다.

local MAX = 12
local function clip(s: string, max: number): string
local n = utf8.len(s)
if n == nil or n <= max then
return s
end
return string.sub(s, 1, (utf8.offset(s, max + 1) :: number) - 1)
end
q.Elem "TextBox" {
Text = store.Username,
q.OnChange "TextBox" "Text" (function(text: string)
store.Username:Set(clip(text, MAX)) -- SetIfChanged가 아니라 Set
end),
}

:SetIfChanged(그리고 같은 값을 건너뛰는 q.Out)로는 이 경우가 막히지 않습니다. 원본이 이미 열두 글자일 때 열세 번째 글자를 치면, 잘린 값이 원본과 같아서 아무 일도 일어나지 않습니다. 그러면 입력창에는 넘친 글자가 그대로 남습니다 — 프로퍼티 바인딩은 원본이 새로 발행할 때만 입력창을 다시 쓰기 때문입니다. :Set은 같은 값도 전파하므로(source:Set(v)), Text = store.Username 바인딩이 입력창을 잘린 값으로 되돌립니다. 대가는 원본을 보는 소비자가 넘친 키 하나마다 같은 값을 한 번 더 받는다는 것입니다.

이 :Set도 이벤트 핸들러(엔진 → 원본 방향) 안의 쓰기라 정상 경로입니다 — 위 FocusLost와 같은 자리입니다.


  1. 폼 상태는 밖에서 만들어 props로 주입하기. 이유가 셋입니다 — 같은 폼을 두 번 띄워도 서로 독립이고, 상위가 결과를 소비할 수 있고, UI를 켜지 않고 상태만 require해서 검증할 수 있습니다(06. 헤드리스 테스트). 전역 싱글톤은 첫째를, 컴포넌트 안에서 만드는 것은 나머지 둘을 막습니다.
  2. 폼 상태와 표현을 가르기. 판정(CanSubmit)은 폼 상태의 것이고, 그걸 무슨 색으로 보일지(submitColor)는 컴포넌트의 것입니다. 이 선이 지켜져야 같은 폼 상태에 다른 화면을 붙일 수 있습니다.
  3. 결과는 상태로 내보내기. 컴포넌트는 Submitted:Set까지만 하고, 그것을 서버로 보낼지 화면을 넘길지는 바깥이 정합니다.
  4. 되쓰기는 q.Out, 직접 쓰는 OnChange 콜백에서는 Source:Set만. 콜백 안에서 다른 인스턴스의 프로퍼티를 직접 건드리지 마세요 — 상태만 바꾸면 나머지는 디스패치가 합니다.
  5. 여러 필드를 묶을 땐 후행 의존성. stateA:Compute(fn, stateB) 한 줄이면 중간 노드 없이 하나로 모입니다.
  6. :Compute 콜백은 순수하게. 계산 중에 다른 상태를 :Set하지 마세요.
  7. 잘라서 되쓰는 자리(최대 길이)는 :Set. 잘린 값이 원본과 같을 때도 입력창을 되돌려야 하므로 같은 값을 건너뛰는 :SetIfChanged·q.Out으로는 안 됩니다(§6 길이 제한).
  8. 입력 다듬기는 원본을 두고 파생으로. 입력창은 원본 Source와 양방향으로 두고, 다듬은 값은 :Compute로 따로 만들어 판정·제출이 읽게 하세요. 입력창 표시까지 다듬으려면 FocusLost 같은 확정 시점 이벤트에서 한 번 :Set합니다.

`:Compute` 콜백의 인자 자리

state:Compute(function(...) end, ...deps)에서 콜백이 받는 인자의 순서는 어떻게 되나요?

  • 첫째는 state 자신(핸들), 둘째는 직전 계산 결과, 셋째부터가 뒤에 넘긴 의존성들입니다
  • 첫째가 직전 계산 결과이고 둘째부터 뒤에 넘긴 의존성들입니다
  • :Depend로 모은 값도 둘째 자리부터 포지셔널로 이어서 들어옵니다

첫 인자와 의존성은 값이 아니라 핸들이라 :Get()으로 읽고, 둘째 자리는 첫 사이클에 nil인 previous입니다. :Depend로 모은 값은 포지셔널로 넘어오지 않으므로 클로저로 직접 읽어야 합니다.

폼 상태를 어디서 만드나

폼 상태를 컴포넌트 안이 아니라 밖에서 만들어 props로 주입하는 이유는 무엇인가요?

  • 같은 폼을 두 번 띄워도 서로 독립이고, 상위가 결과를 소비할 수 있고, UI 없이 상태만 검증할 수 있기 때문입니다
  • 전역 싱글톤 하나로 두면 세 이점이 모두 살아나므로 그 모양을 권하기 때문입니다
  • 컴포넌트 안에서 만들면 같은 폼을 두 번 띄우는 것만 막히기 때문입니다

전역 싱글톤은 첫째(같은 폼 두 벌)를 막고, 컴포넌트 안에서 만드는 것은 나머지 둘(결과 소비·헤드리스 검증)을 막습니다. makeFormState()를 부르는 쪽마다 한 벌씩 만들면 셋이 다 풀립니다.

제출 결과를 관측할 때

Submitted를 :Observer로 관측할 때 첫 발화에서 주의할 점은 무엇인가요?

  • Observer는 만들어질 때 한 번 불리므로 그 시점의 값은 아직 nil입니다 — nil을 걸러내야 합니다
  • 콜백에 제출 payload가 인자로 실려 오므로 그 인자를 바로 쓰면 됩니다
  • 숫자 키 자리에 놓기 전에는 한 번도 불리지 않으므로 첫 발화를 걱정할 필요가 없습니다

Observer는 값을 실어 주지 않아 콜백 안에서 :Get()으로 읽고, 숫자 키 자리에 놓기 전에 이미 1회 불립니다. 제출 버튼을 누르면 그때 2회째가 오고 그 시점의 값에 payload가 들어 있습니다.

입력 다듬기

아이디 입력창에서 앞뒤 공백을 깎은 값으로 판정하고, 입력창 표시도 결국 다듬은 값으로 정리하고 싶습니다. 이 문서가 권하는 모양은 무엇인가요?

  • 입력창은 원본 Source와 양방향으로 두고, 판정은 :Compute로 다듬은 파생을 읽고, 표시는 FocusLost에서 다듬은 값을 원본에 :Set합니다
  • 입력창 Text에 다듬은 파생을 꽂고 q.Out으로 원본에 되쓰면, 치는 동안에도 문제없이 다듬어집니다
  • 원본을 관측하는 Observer 콜백 안에서 다듬은 값을 원본에 다시 :Set합니다

Text에 다듬은 파생을 꽂는 모양은 멈추긴 하지만 끝 공백이 치는 즉시 지워져 hello world를 칠 수 없고, 원본을 보는 쪽이 한 번의 입력에 값을 두 번 받을 수 있습니다. Observer가 자기 상류를 되쓰는 것은 정의되지 않은 동작입니다. 확정 시점 이벤트의 :Set은 값이 들어오는 자리라 정상이고, 그 뒤의 엔진 신호는 q.Out이 같은 값이라 건너뜁니다.

퀴즈 진행 상황

0 / 0 답변한 문제 (0%)

0 정답