Controlled Component와 Uncontrolled Component 비교
Controlled Component와 Uncontrolled Component 비교
Controlled input은 React state가 현재 값을 소유하고 value와 onChange로 DOM을 맞춘다. Uncontrolled input은 DOM이 현재 값을 소유하고 React는 defaultValue로 초기값만 주거나 제출 시 FormData·ref로 읽는다. 어느 방식이 더 좋은지가 아니라 입력 중 React가 값을 알아야 하는지, 외부에서 값을 바꿔야 하는지, 검증과 성능 요구가 무엇인지로 선택한다.
목차
- #차이는 현재 값의 source of truth다
- #Controlled input의 데이터 흐름
- #Uncontrolled input의 데이터 흐름
- #value와 defaultValue는 역할이 다르다
- #제어 상태를 중간에 바꾸면 안 되는 이유
- #실시간 검증과 제출 시 검증
- #초기화와 외부 값 반영 방식
- #성능은 렌더 범위부터 줄인다
- #복잡한 폼에서는 두 방식을 혼합할 수 있다
- #file input은 별도로 생각한다
- #접근성과 브라우저 기본 기능
- #테스트 체크리스트
- #선택 기준
- #정리
- #관련 노트와 참고 자료
차이는 현재 값의 source of truth다
텍스트 input에는 현재 값이 있다. Controlled와 Uncontrolled를 가르는 질문은 간단하다.
지금 이 순간 input 값을 누가 소유하는가?
Controlled input에서는 React state가 원본이다.
function ControlledNameInput() {
const [name, setName] = useState("");
return (
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
);
}
Uncontrolled input에서는 DOM element가 현재 값을 보관한다.
function UncontrolledNameInput() {
return <input name="name" defaultValue="" />;
}
defaultValue는 첫 값만 지정한다. 사용자가 입력한 뒤 React prop이 다시 렌더되어도 DOM의 현재 값을 계속 강제하지 않는다.
| 항목 | Controlled | Uncontrolled |
|---|---|---|
| 현재 값 소유 | React state | DOM |
| 주요 props | value, checked |
defaultValue, defaultChecked |
| 입력 읽기 | state | submit의 FormData 또는 ref |
| 키 입력당 state update | 있음 | 기본적으로 없음 |
| 외부에서 값 변경 | 쉬움 | DOM API 또는 remount 필요 |
| 실시간 파생 UI | 자연스러움 | 별도 event 처리 필요 |
| 초기화 | state 변경 | form.reset() 또는 key |
한 form 안에서도 검색어는 controlled, 단순 메모는 uncontrolled, 업로드 파일은 DOM이 소유하는 식으로 필드별 요구에 따라 섞을 수 있다.
Controlled input의 데이터 흐름
Controlled input에서 사용자 입력은 다음 순환을 거친다.
sequenceDiagram
participant U as User
participant D as DOM input
participant R as React state
U->>D: 키 입력
D->>R: onChange(value)
R->>R: setState로 다음 렌더
R->>D: value prop으로 DOM 동기화가상의 쿠폰 코드 입력을 만들어 보자.
function CouponField() {
const [couponCode, setCouponCode] = useState("");
const normalizedCode = couponCode.trim().toUpperCase();
const valid = /^[A-Z0-9]{6,12}$/.test(normalizedCode);
return (
<div>
<label htmlFor="coupon-code">쿠폰 코드</label>
<input
id="coupon-code"
value={couponCode}
onChange={(event) => setCouponCode(event.target.value)}
aria-invalid={couponCode.length > 0 && !valid}
aria-describedby="coupon-code-help"
/>
<p id="coupon-code-help">
{couponCode.length === 0
? "영문과 숫자 6~12자를 입력하세요."
: valid
? `적용할 코드: ${normalizedCode}`
: "코드 형식이 올바르지 않습니다."}
</p>
</div>
);
}
React가 매 입력의 값을 아니까 즉시 다음 작업을 할 수 있다.
- 남은 글자 수 표시
- 다른 field의 enabled 상태 변경
- 입력에 따른 preview 렌더
- 특정 규칙으로 값 변환
- 외부 button으로 입력 초기화
- 여러 입력을 동기화
onChange에서 backing state를 동기적으로 갱신한다
value를 주고 onChange를 생략하면 읽기 전용 input이 된다.
<input value={name} />
수정 가능해야 한다면 event가 준 값을 동기적으로 state에 반영한다.
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
느린 서버 검증을 기다린 뒤 state를 바꾸면 키 입력이 되돌아가고 caret이 이상하게 움직일 수 있다.
async function handleChange(event: ChangeEvent<HTMLInputElement>) {
const nextValue = event.target.value;
await validateOnServer(nextValue);
setName(nextValue);
}
입력 state는 즉시 바꾸고 서버 검증 결과는 별도 state와 debounce로 관리한다.
function handleChange(event: ChangeEvent<HTMLInputElement>) {
setName(event.target.value);
}
Uncontrolled input의 데이터 흐름
제출할 때만 값이 필요하다면 browser form이 잘하는 일을 그대로 사용할 수 있다.
function ContactForm() {
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget;
const data = new FormData(form);
await saveContact({
name: String(data.get("name") ?? ""),
email: String(data.get("email") ?? ""),
newsletter: data.get("newsletter") === "on",
});
}
return (
<form onSubmit={handleSubmit}>
<label>
이름
<input name="name" required />
</label>
<label>
이메일
<input name="email" type="email" required />
</label>
<label>
<input name="newsletter" type="checkbox" />
뉴스레터 받기
</label>
<button type="submit">저장</button>
</form>
);
}
React state에는 각 키 입력이 들어오지 않는다. 브라우저가 input 값을 보관하고, submit 시 FormData가 성공적인 form control의 name/value를 모은다.
ref로 단일 field를 읽을 수도 있다.
function SearchShortcut() {
const inputRef = useRef<HTMLInputElement>(null);
function handleSearch() {
const query = inputRef.current?.value.trim() ?? "";
navigate(`/search?q=${encodeURIComponent(query)}`);
}
return (
<>
<input ref={inputRef} defaultValue="" />
<button type="button" onClick={handleSearch}>
검색
</button>
</>
);
}
여러 field를 일일이 ref로 만들기보다 form과 FormData를 사용하는 편이 단순하다.
Uncontrolled form을 FormData로 읽으려면 각 input에 name이 있어야 한다. id는 label 연결에 쓰이고 FormData의 key가 되지 않는다.
value와 defaultValue는 역할이 다르다
value는 현재 input 값은 항상 이 값이어야 한다는 선언이다.
<input value={name} onChange={handleNameChange} />
defaultValue는 처음 DOM을 만들 때 이 값으로 시작하라는 선언이다.
<input defaultValue="기본 배송 메모" />
부모 prop이 바뀌어도 이미 mount된 uncontrolled input의 현재 값은 자동으로 바뀌지 않는다.
function MemoInput({ initialMemo }: { initialMemo: string }) {
return <input defaultValue={initialMemo} />;
}
initialMemo가 A에서 B로 바뀌어도 사용자가 편집 중인 DOM 값을 보호한다. 이를 버그로 볼지 의도된 동작으로 볼지는 요구사항에 달려 있다.
사용자 선택이 바뀔 때 초기값도 완전히 새로 시작해야 한다면 key로 remount할 수 있다.
<MemoInput
key={selectedOrderId}
initialMemo={selectedOrder.memo}
/>
현재 값을 유지하면서 일부만 외부에서 바꿔야 한다면 controlled 방식이 더 자연스럽다.
checkbox와 radio는 value가 아니라 checked가 선택 상태를 제어한다.
<input
type="checkbox"
checked={agreed}
onChange={(event) => setAgreed(event.target.checked)}
/>
Uncontrolled 초기 선택은 defaultChecked다.
<input
type="checkbox"
name="newsletter"
defaultChecked
/>
제어 상태를 중간에 바꾸면 안 되는 이유
처음에는 value={undefined}였다가 데이터가 도착한 뒤 문자열이 되면 input이 uncontrolled에서 controlled로 바뀐다.
function ProfileName({ user }: { user?: User }) {
const [name, setName] = useState<string | undefined>(
user?.name,
);
return (
<input
value={name}
onChange={(event) => setName(event.target.value)}
/>
);
}
React는 input 수명 동안 누가 값을 소유하는지 바뀌어 경고한다. Controlled text input은 처음부터 문자열로 초기화한다.
const [name, setName] = useState(user?.name ?? "");
비동기 user가 나중에 도착한다면 초기 form state를 언제 적용할지 정책을 명확히 한다.
function ProfileName({ user }: { user: User }) {
return <ProfileForm key={user.id} user={user} />;
}
function ProfileForm({ user }: { user: User }) {
const [name, setName] = useState(user.name);
}
사용자 전환마다 form을 초기화하려는 의도를 key로 표현했다.
checkbox는 항상 boolean을 전달한다.
<input
type="checkbox"
checked={settings.emailEnabled ?? false}
onChange={handleEmailEnabledChange}
/>
controlled text input의 DOM 값은 문자열이어야 하므로 null을 그대로 넘기지 않는다. 도메인에서 null이 별도 의미라면 form state와 제출 변환 경계에서 명시적으로 매핑한다.
실시간 검증과 제출 시 검증
Controlled 방식은 입력할 때마다 파생 UI를 만들기 쉽지만 모든 오류를 첫 키부터 보여 주면 사용자 경험이 나빠질 수 있다.
const [email, setEmail] = useState("");
const [touched, setTouched] = useState(false);
const emailError =
touched && !isValidEmail(email)
? "이메일 형식을 확인해 주세요."
: null;
blur 이후 또는 제출 시도 이후에 오류를 표시한다.
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
onBlur={() => setTouched(true)}
aria-invalid={emailError !== null}
aria-describedby={emailError ? "email-error" : undefined}
/>
Uncontrolled form은 HTML constraint validation을 활용할 수 있다.
<input
name="email"
type="email"
required
maxLength={120}
/>
브라우저 검증만으로 도메인 규칙을 모두 보장할 수는 없다. 어떤 방식을 쓰든 서버는 신뢰할 수 없는 입력을 다시 검증해야 한다.
| 검증 | 실행 위치 | 예 |
|---|---|---|
| 브라우저 형식 | input attributes | required, type=email |
| 클라이언트 UX | React/form library | field 간 조건, 즉시 안내 |
| 서버 DTO | API boundary | 타입, 길이, 허용 값 |
| 도메인 규칙 | service/domain | 중복 이메일, 권한, 현재 상태 |
초기화와 외부 값 반영 방식
Controlled form은 state를 초기값으로 바꾸면 된다.
function handleReset() {
setName("");
setEmail("");
setAgreed(false);
}
여러 field를 하나의 reducer나 form library로 관리하면 reset action을 둔다.
Uncontrolled form은 browser API를 사용한다.
function ContactForm() {
const formRef = useRef<HTMLFormElement>(null);
function handleReset() {
formRef.current?.reset();
}
return (
<form ref={formRef}>
<input name="name" defaultValue="기본 이름" />
<button type="button" onClick={handleReset}>
초기화
</button>
</form>
);
}
form.reset()은 빈 값이 아니라 HTML의 initial/default value로 되돌린다.
서버에서 새 초기 데이터가 들어왔을 때 사용자 편집을 덮어쓸지 결정해야 한다.
- 사용자가 아직 편집하지 않았다 → 새 값 적용 가능
- 편집 중이다 → 덮어쓰기 전에 충돌 안내
- 다른 entity를 선택했다 → key로 form 전체 초기화 가능
Controlled인지와 별개로 dirty state와 데이터 버전을 관리해야 할 수 있다.
성능은 렌더 범위부터 줄인다
Controlled input은 키 입력마다 state update와 render가 발생한다. 그렇다고 바로 uncontrolled로 바꾸는 것이 첫 해결책은 아니다.
state가 너무 높은 곳에 있는지 먼저 본다.
function Application() {
const [name, setName] = useState("");
return (
<>
<SignupForm name={name} onNameChange={setName} />
<LargeDashboard />
</>
);
}
Dashboard가 name을 사용하지 않으면 state를 form 안으로 내린다.
function Application() {
return (
<>
<SignupForm />
<LargeDashboard />
</>
);
}
검색어가 큰 목록을 필터링해야 해서 함께 렌더되어야 한다면 입력 state와 느린 결과 렌더를 분리할 수 있다.
function ProductSearch() {
const [query, setQuery] = useState("");
const deferredQuery = useDeferredValue(query);
return (
<>
<input
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
<ProductResult query={deferredQuery} />
</>
);
}
입력은 즉시 최신 값을 보여 주고 느린 결과는 낮은 우선순위로 따라온다. 데이터 요청에는 debounce와 cancellation이 별도로 필요할 수 있다.
성능 최적화 순서는 다음이 실용적이다.
- Profiler와 INP로 실제 지연을 확인한다.
- input state를 form subtree 가까이 둔다.
- 무거운 파생 계산을 분리한다.
useDeferredValue, virtualization 등을 검토한다.- 필드별 실시간 반응이 필요 없다면 uncontrolled 또는 form library 전략을 고려한다.
복잡한 폼에서는 두 방식을 혼합할 수 있다
배송 폼에서 국가 선택은 다른 field를 즉시 바꾸므로 controlled가 적합하고, 배송 메모는 제출 때만 필요하므로 uncontrolled로 둘 수 있다.
function ShippingForm() {
const [country, setCountry] = useState("KR");
function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const data = new FormData(event.currentTarget);
submitShipping({
country,
memo: String(data.get("memo") ?? ""),
});
}
return (
<form onSubmit={handleSubmit}>
<label>
국가
<select
name="country"
value={country}
onChange={(event) => setCountry(event.target.value)}
>
<option value="KR">대한민국</option>
<option value="JP">일본</option>
</select>
</label>
<AddressFields country={country} />
<label>
배송 메모
<textarea name="memo" defaultValue="" />
</label>
<button type="submit">저장</button>
</form>
);
}
React Hook Form 같은 라이브러리는 uncontrolled registration을 중심으로 불필요한 렌더를 줄이면서 validation과 field state를 제공한다. 반대로 Formik처럼 controlled model을 중심으로 하는 도구도 있다. 라이브러리 이름보다 다음을 확인한다.
- field가 몇 개인가?
- 키 입력마다 다른 UI가 반응해야 하는가?
- field array와 동적 조건이 있는가?
- validation이 언제 실행되는가?
- dirty/touched/error를 누가 소유하는가?
- 서버 오류를 field에 어떻게 연결하는가?
file input은 별도로 생각한다
브라우저 보안상 JavaScript가 로컬 파일 경로를 임의로 지정할 수 없다. <input type="file">의 선택 파일은 event.target.files나 ref로 읽는다.
function AttachmentForm() {
const fileInputRef = useRef<HTMLInputElement>(null);
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const file = fileInputRef.current?.files?.[0];
if (!file) {
showError("파일을 선택해 주세요.");
return;
}
await uploadAttachment(file);
}
return (
<form onSubmit={handleSubmit}>
<label>
첨부 파일
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,application/pdf"
/>
</label>
<button type="submit">업로드</button>
</form>
);
}
선택한 File 객체나 파일명 preview를 state에 둘 수는 있지만 input의 실제 value를 문자열로 제어하려 하지 않는다. 초기화는 form reset, ref를 통한 value = "", 또는 key remount를 사용한다.
accept는 file picker의 힌트이지 보안 검증이 아니다. 서버에서 크기, MIME, magic bytes, 악성 파일을 다시 검사한다. 이는 S3 Presigned URL로 업로드 부하 분산하기의 완료 검증과 연결된다.
접근성과 브라우저 기본 기능
Controlled 여부와 상관없이 semantic form을 유지한다.
- 모든 field에 연결된 label을 둔다.
- 오류는 텍스트로 제공하고
aria-describedby로 연결한다. - 오류 field에는
aria-invalid를 사용한다. - submit button은
type="submit", 일반 button은type="button"을 명시한다. - disabled와 readOnly의 차이를 고려한다.
- autocomplete을 불필요하게 막지 않는다.
- Enter 제출과 browser password manager를 테스트한다.
<label htmlFor="shipping-email">이메일</label>
<input
id="shipping-email"
name="email"
type="email"
autoComplete="email"
required
aria-invalid={emailError !== null}
aria-describedby={emailError ? "shipping-email-error" : undefined}
/>
{emailError && (
<p id="shipping-email-error" role="alert">
{emailError}
</p>
)}
custom div 기반 input을 만드는 것보다 기본 input의 키보드, focus, form 기능을 활용한다.
테스트 체크리스트
Controlled input은 외부 state 변경도 DOM에 반영되는지 확인한다.
it("초기화 버튼이 controlled input을 비운다", async () => {
const user = userEvent.setup();
render(<ProfileForm />);
await user.type(screen.getByLabelText("이름"), "찬호");
await user.click(screen.getByRole("button", { name: "초기화" }));
expect(screen.getByLabelText("이름")).toHaveValue("");
});
Uncontrolled form은 실제 submit 결과를 검사한다.
it("DOM의 현재 값을 제출한다", async () => {
const user = userEvent.setup();
render(<ContactForm onSubmit={handleSubmit} />);
await user.type(screen.getByLabelText("이메일"), "dev@example.test");
await user.click(screen.getByRole("button", { name: "저장" }));
expect(handleSubmit).toHaveBeenCalledWith(
expect.objectContaining({ email: "dev@example.test" }),
);
});
추가로 확인할 항목:
- IME 한글 입력 중 값 변환이 조합을 깨뜨리지 않는가?
- controlled input의 value가
undefined로 시작하지 않는가? - checkbox는
checked, text는value를 사용하는가? - 잘못된 key로 input이 매 키마다 remount되지 않는가?
- async validation 응답 순서가 뒤바뀌지 않는가?
- reset이 기대한 initial value로 돌아가는가?
- file input을 문자열 value로 제어하지 않는가?
선택 기준
- 입력마다 preview나 다른 field가 바뀐다.
- 외부 button이나 props가 값을 변경해야 한다.
- 여러 input을 하나의 source of truth로 동기화한다.
- 실시간 formatting과 validation이 필요하다.
- 제출할 때만 값이 필요하다.
- 브라우저의 form과 constraint validation을 활용한다.
- 많은 field의 키 입력을 React state로 만들 필요가 없다.
- 기존 비React widget이나 form library가 DOM 값을 소유한다.
둘 중 하나를 프로젝트 전체 규칙으로 고정할 필요는 없다. 같은 컴포넌트 안에서도 field별 요구가 다르다.
정리
Controlled와 Uncontrolled의 핵심 차이는 성능이 아니라 현재 값의 소유권이다. Controlled input은 React state가 값을 결정하고 입력 event가 state를 갱신한다. Uncontrolled input은 DOM이 값을 보관하며 React는 초기값만 주거나 필요할 때 읽는다.
value와checked에는 동기적인onChange가 필요하다.- 초기값만 주려면
defaultValue와defaultChecked를 사용한다. - input 수명 중 controlled와 uncontrolled를 전환하지 않는다.
- controlled 성능 문제는 먼저 state 위치와 렌더 범위를 줄인다.
- 복잡한 form은 필드별로 두 방식을 혼합하거나 form library를 사용한다.
- file input과 서버 검증은 별도 경계를 갖는다.
입력 중 React가 그 값을 알아야 하는 이유가 분명하면 controlled, 제출 시 DOM의 최종 값만 필요하면 uncontrolled가 더 단순할 수 있다.