콘텐츠로 이동

Controlled vs Uncontrolled 컴포넌트

React에서 컴포넌트의 상태를 누가 관리하느냐에 따라 Controlled(제어)와 Uncontrolled(비제어) 컴포넌트로 나뉩니다. 이 개념은 폼 입력뿐만 아니라 멀티스텝 플로우, 모달 등 다양한 UI 패턴에 적용됩니다.

Controlled Component (제어 컴포넌트)

섹션 제목: “Controlled Component (제어 컴포넌트)”

React의 state로 입력값을 관리하는 방식입니다. 제출 전에 사용자 입력값을 실시간으로 추적하고 대응할 수 있다는 것이 가장 큰 이점입니다.

import { useState, useEffect } from "react";
export const ControlledForm = () => {
const [error, setError] = useState<string>("");
const [name, setName] = useState<string>("");
const [age, setAge] = useState<number>();
useEffect(() => {
if (name.length < 2) {
setError("이름은 2자 이상 입력해주세요.");
} else {
setError("");
}
}, [name]);
return (
<form>
<input
name="name"
type="text"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
{error && <p>{error}</p>}
<input
name="age"
type="number"
placeholder="Age"
value={age}
onChange={(e) => setAge(Number(e.target.value))}
/>
<input type="submit" />
</form>
);
};

Uncontrolled Component (비제어 컴포넌트)

섹션 제목: “Uncontrolled Component (비제어 컴포넌트)”

DOM이 직접 입력값을 관리하고, 필요할 때 ref로 값을 읽어오는 방식입니다.

import { FormEvent, useRef } from "react";
export const UncontrolledForm = () => {
const nameInputRef = useRef<HTMLInputElement>(null);
const ageInputRef = useRef<HTMLInputElement>(null);
const handleSubmit = (e: FormEvent) => {
if (nameInputRef.current && ageInputRef.current) {
console.log(nameInputRef.current.value);
console.log(ageInputRef.current.value);
}
e.preventDefault();
};
return (
<form onSubmit={handleSubmit}>
<input name="name" type="text" placeholder="Name" ref={nameInputRef} />
<input name="age" type="number" placeholder="Age" ref={ageInputRef} />
<input type="submit" />
</form>
);
};
  • createRef는 호출할 때마다 항상 새로운 객체를 반환하며, 클래스 컴포넌트에서 주로 사용
  • useRef는 항상 같은 객체를 반환하며, 함수 컴포넌트에서 권장
  • 공식 문서에서도 useRef 사용을 권장하고 있으므로, 굳이 레거시 코드를 사용할 필요가 없음

회원가입이나 설문조사처럼 한 단계씩 정보를 입력하는 멀티스텝 UI에 사용합니다. 컴포넌트 내부에서 현재 단계를 관리하므로 비제어 방식입니다.

import React, { ReactElement, ReactNode, useState } from "react";
interface Props {
children: ReactNode[];
onDone: boolean;
}
export const UncontrolledFlow = ({ children, onDone }: Props) => {
const [currentStepIndex, setCurrentStepIndex] = useState<number>(0);
const currentChild = React.Children.toArray(children)[
currentStepIndex
] as ReactElement<{ goNext: () => void }>;
const goNext = () => {
setCurrentStepIndex((prev) =>
prev >= React.Children.count(children) - 1 ? 0 : prev + 1
);
};
return (
React.isValidElement(currentChild) &&
React.cloneElement(currentChild, { goNext })
);
};

사용 예시:

function App() {
return (
<UncontrolledFlow onDone={false}>
<StepIndicator step={1} />
<StepIndicator step={2} />
<StepIndicator step={3} />
</UncontrolledFlow>
);
}

Controlled Flow: 외부에서 단계 관리

섹션 제목: “Controlled Flow: 외부에서 단계 관리”

부모 컴포넌트가 현재 단계와 데이터를 관리하는 제어 방식입니다.

interface Props {
children: ReactNode[];
currentIndex: number;
onNext: (data: object) => void;
}
export const ControlledFlow = ({ children, currentIndex, onNext }: Props) => {
const goNext = (dataFromStep: object) => {
onNext(dataFromStep);
};
const currentChild = React.Children.toArray(children)[
currentIndex
] as ReactElement<{ goNext: (data: object) => void }>;
return (
React.isValidElement(currentChild) &&
React.cloneElement(currentChild, { goNext })
);
};

부모에서 state lifting으로 상태를 관리하면 조건부 단계 추가 같은 유연한 로직이 가능합니다.

function App() {
const [data, setData] = useState({ data: 0 });
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const goNext = (dataFromStep: object) => {
setData({ ...data, ...dataFromStep });
setCurrentStepIndex(currentStepIndex + 1);
};
return (
<ControlledFlow onNext={goNext} currentIndex={currentStepIndex}>
<StepIndicator step={1} />
<StepIndicator step={2} />
{data?.data === 3 && <StepIndicator step={3} />}
<StepIndicator step={4} />
</ControlledFlow>
);
}

모달도 제어/비제어 패턴을 적용할 수 있습니다. 비제어 모달은 내부에서 열림/닫힘 상태를 관리하고, 제어 모달은 외부에서 관리합니다.

// 제어 모달 - 외부에서 상태 관리
type Props = {
shouldDisplay: boolean;
onClose: () => void;
children: ReactNode;
};
export const ControlledModal = ({ shouldDisplay, onClose, children }: Props) => {
return (
<>
{shouldDisplay && (
<div className="modal-background" onClick={onClose}>
<div className="modal-content" onClick={(e) => e.stopPropagation()}>
<button onClick={onClose}>Close</button>
{children}
</div>
</div>
)}
</>
);
};

제어 모달은 forwardRefuseImperativeHandle을 사용해서도 외부에서 제어할 수 있으며, 공통 컴포넌트를 만들 때 자주 사용되는 패턴입니다.

React 공식 문서에서는 React.Children API 사용에 주의를 권고합니다.

  • children은 React에서 불분명한 데이터 구조로 취급됨
  • 내부적으로는 배열로 변환되지만, 자식이 하나면 배열을 생성하지 않음
  • 렌더링 결과를 포함하지 않기 때문에 실수가 발생할 수 있음

그래도 children.map 대신 Children.map()isValidElement()를 결합하면 좀 더 안정적으로 children을 다룰 수 있습니다.