리액트 디자인 패턴 : uncontrolled component pattern
TL;DR
uncontrolled component는 컴포넌트의 관심사를 달성하기 위해 스스로 내부 상태를 관리하는 컴포넌트입니다.
컴포넌트에 상태 관리 책임을 넘기고 편해지는 방법을 알아봅시다.
원문 번역입니다 : https://jjenzz.com/component-control-freak
우리는 종종 컴포넌트의 상태를 직접 제어하려 합니다.
리액트 튜토리얼(React’s own intro tutorial)을 포함하여, 상태 관리 튜토리얼이 도처에 널려있기 때문에,
상태 관리를 반드시 해야 한다는 강박에 빠지기 쉽습니다.
- 예를 들어, input에는 value와 onChange prop이 있습니다.
Uncontrolled components
Uncontrolled components는 상태 관리를 스스로 수행하는 컴포넌트 입니다.
꼭 필요할 때애만 사용자가 초기 상태를 지정하는 것을 허용합니다.
Uncontrolled Component 만들기
다음은 dumb(stateless) 컴포넌트가 자주 빌드되는 방법입니다.
몇몇 prop과 callback을 제공하면 dumb Counter 컴포넌트를 얻을 수 있습니다.
const Counter = ({ count = 0, onChange }) => {
// Effect with `count` dependency so effect runs every time `count` changes
React.useEffect(() => {
setTimeout(() => onChange(count + 1), 1000);
}, [count]);
return <span>{count}</span>;
};
const App = () => {
const [countOneValue, setCountOneValue] = React.useState(0);
const [countTwoValue, setCountTwoValue] = React.useState(10);
return (
<div>
<p>
<Counter count={countOneValue} onChange={setCountOneValue} />
</p>
<p>
<Counter count={countTwoValue} onChange={setCountTwoValue} />
</p>
</div>
);
};
예상대로 동작하지만 두 개의 제어되는 카운터 상태가 필요합니다.
Counter 컴포넌트를 사용하려는 사람은 모두 유사한 상태를 구현해야 합니다.
동일한 리프팅 상태를 반복하는 이러한 상황에 처했을 때 uncontrolled 컴포넌트로 변경하는 것이 도움이 됩니다.
즉, 부모에서 상태를 Counter로 이동하여 컴포넌트 스스로 상태를 관리하도록 합니다.
const App = () => (
<div>
<p>
<Counter />
</p>
<p>
<Counter defaultCount={10} />
</p>
</div>
);
const Counter = ({ defaultCount = 0 }) => {
const [count, setCount] = React.useState(defaultCount);
React.useEffect(() => {
setTimeout(() => setCount(prevCount => prevCount + 1), 1000);
}, [count]);
return <span>{countValue}</span>;
};
자체적으로 상태를 관리하지만, 예상대로 동작하는 매우 간단한 uncontrolled component를 얻었습니다.
API 컨슈머는 기대되는 동작을 위해 상태를 더 이상 제어할 필요가 없으며
결과적으로 상위 컴포넌트의 상용구를 제거할 수 있었습니다.
uncontrolled component control하기
- 버튼을 클릭하면 10씩 증가하길 원할 수 있습니다.
- 문제 없습니다. step prop을 추가할 수 있습니다.
- 갑자기 2의 배수로 증가하기를 원합니다.
const Counter = ({
count: countProp,
defaultCount = 0,
onChange = () => {},
}) => {
// local state for uncontrolled version
const [countState, setCountState] = React.useState(defaultCount);
// whether consumer is trying to control it or not. we use a ref because
// components should not switch between controlled/uncontrolled at runtime
const isControlled = React.useRef(countProp !== undefined).current;
// the count value we render depending on whether it is controlled
const count = isControlled ? countProp : countState;
// maintaining change callback in a ref (more on that later)
const handleChangeRef = React.useRef(onChange);
React.useLayoutEffect(() => {
handleChangeRef.current = onChange;
});
React.useEffect(() => {
const handleChange = handleChangeRef.current;
setTimeout(() => {
if (isControlled) {
handleChange(count + 1);
} else {
setCountState((prevCount = 0) => {
const nextCount = prevCount + 1;
handleChange(nextCount);
return nextCount;
});
}
}, 1000);
}, [isControlled, count]);
return <span>{count}</span>;
};
이 접근 방식은 효과 종속성으로 추가하지 않고 핸들러에 대한 업데이트된 참조를 제공하므로,
카운트가 변경될 때만 효과가 실행되도록 합니다.
const Counter = ({
count: countProp,
defaultCount = 0,
onChange = () => {},
}) => {
const [countState, setCountState] = React.useState(defaultCount);
const isControlled = React.useRef(countProp !== undefined).current;
const count = isControlled ? countProp : countState;
// ...
if (isControlled) {
onChange(count + 1);
} else {
// if component is uncontrolled, we set the internal state
setCountState((prevCount = 0) => {
const nextCount = prevCount + 1;
onChange(nextCount);
return nextCount;
});
}
// ...
return <span>{count}</span>;
};
isControlled prop의 정의 여부를 확인하는 isControlled 불리언을 유지합니다.
그런 다음 isControlled 불리언을 사용하여 컴포넌트를 내부 상태 관리(제어되지 않음)에서 props(제어됨) 사용으로 전환합니다.
즉 우리는, React form의 필드와 유사한 것을 만들었습니다.
제어되거나 제어되지 않을 수 있는 컴포넌트입니다.
Uncontrolled forms
const Contact = () => {
const [name, setName] = React.useState(‘’);
const [email, setEmail] = React.useState(‘’);
const [message, setMessage] = React.useState(‘’);
const [isSubscribed, setIsSubscribed] = React.useState(false);
function handleSubmit(event) {
fetch(‘/contact’, {
mode: ‘POST’,
body: JSON.stringify({ name, email, message, isSubscribed }),
});
event.preventDefault();
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input type=“text” value={name} onChange={event => setName(event.target.value)} />
</label>
<label>
Email
<input type=“email” value={email} onChange={event => setEmail(event.target.value)} />
</label>
<label>
Message
<textarea value={message} onChange={event => setMessage(event.target.value)} />
</label>
<label>
<input type=“checkbox” checked={isSubscribed} onChange={event => setIsSubscribed(event.checked)} />
Please check this box if you would like to subscribe to our newsletter
</label>
</form>
);
}
const Contact = () => {
function handleSubmit(event) {
const formData = new FormData(event.currentTarget);
const body = Object.fromEntries(formData.entries());
fetch(‘/contact’, { mode: ‘POST’, body: JSON.stringify(body) });
event.preventDefault();
}
return (
<form onSubmit={handleSubmit}>
<label>
Name
<input type=“text” name="name" />
</label>
<label>
Email
<input type=“email” name="email" />
</label>
<label>
Message
<textarea name="message" />
</label>
<label>
<input type=“checkbox” name="isSubscribed" />
Please check this box if you would like to subscribe to our newsletter
</label>
</form>
);
}
- 불필요한 상태 관리를 제거하여 컴포넌트를 단순화했습니다.
- 모든 키 입력이 전체 form을 렌더링하는 것을 방지하였습니다.
위는 매우 일반적입니다.
onSubmit 및 onChange 이벤트가 실행될 때, 필드의 객체 리터럴 표현을 제공하는 유틸 함수와 함께 Form 컴포넌트를 만듭니다
Uncontrolled 컴포넌트와 key prop
const DEFAULT_COMMENT = 'Sounds decent';
const App = () => {
const [selectedPostId, setSelectedPostId] = React.useState();
const [comment, setComment] = React.useState(DEFAULT_COMMENT);
React.useEffect(() => {
// reset state back to original
setComment(DEFAULT_COMMENT);
}, [selectedPostId]);
function handleSubmitComment(event) {
submitComment(comment);
event.preventDefault();
}
return (
<div>
<ul>
{['1', '2', '3'].map(postId => (
<li key={postId}>
<input
type="radio"
value={postId}
onChange={event => setSelectedPostId(event.target.value)}
checked={selectedPostId === postId}
/>{' '}
Post {postId}
</li>
))}
</ul>
{selectedPostId && (
<form onSubmit={handleSubmitComment}>
<h2>Comment on post {selectedPostId}</h2>
<textarea
value={comment}
onChange={event => setComment(event.target.value)}
/>
<br />
<button>comment</button>
</form>
)}
</div>
);
};
키 prop이 변경되면 컴포넌트를 다시 인스턴스화합니다. 즉 상태를 초기화 합니다.
이를 활용하여 comment form을 원래 상태로 되돌릴 수 있습니다.
const App = () => {
const [selectedPostId, setSelectedPostId] = React.useState();
return (
<div>
<ul>
{['1', '2', '3'].map(postId => (
<li key={postId}>
<input
type="radio"
value={postId}
onChange={event => setSelectedPostId(event.target.value)}
checked={selectedPostId === postId}
/>{' '}
Post {postId}
</li>
))}
</ul>
{selectedPostId && <Comment key={selectedPostId} id={selectedPostId} />}
</div>
);
};
const Comment = ({ id }) => {
function handleSubmitComment(event) {
const formData = new FormData(event.currentTarget);
submitComment(formData.get('comment'));
event.preventDefault();
}
return (
<form onSubmit={handleSubmitComment}>
<h2>Comment on post {id}</h2>
<textarea name="comment" defaultValue="Sounds decent" />
<br />
<button>comment</button>
</form>
);
};
- input은 uncontrolled 값을 사용해 보여주고
- 내부적으로 이전값 역할을 할 상태와 에러 플래그를 이용해 리렌더링 할 때,
- 요청 실패 시 key만 바꿔주도록 하면
- 리렌더링 시 이전값 역할을 하는 내부 상태를 defaultValue로 리렌더링
결론
- 이 상태가 정말 필요한가요?
- 제어되지 않는(uncontrolledl) API를 사용하면 복잡성을 줄일 수 있나요?
- 컴포넌트를 초기화 하려는 경우 uncontrolled/key prop 페어링이 더 가벼운 컴포넌트에 도움이 될 수 있습니다.
- 초기 상태를 복사해서 유지하지 마세요!
저는 이런 식으로 생각함으로써 응용 프로그램의 복잡성을 줄이는 데 성공했습니다.
여러분도 유용하게 사용하시길 바랍니다 🙂