State로 입력에 반응하기
React는 선언적인 방식으로 UI를 조작합니다. UI의 개별 부분을 직접 조작하는 대신 컴포넌트가 있을 수 있는 다양한 상태를 설명하고 사용자 입력에 반응하여 그 사이를 전환합니다. 이는 디자이너가 UI에 대해 생각하는 방식과 유사합니다.
학습 내용
- 선언적 UI 프로그래밍과 명령형 UI 프로그래밍의 차이점
- 컴포넌트가 있을 수 있는 다양한 시각적 상태를 열거하는 방법
- 코드에서 다양한 시각적 상태 사이의 변경을 트리거하는 방법
- 선언적 UI가 명령형과 비교하는 방법
UI 인터랙션을 디자인할 때 사용자 동작에 따라 UI가 어떻게 변할지 생각해 보셨을 겁니다. 사용자가 답을 제출할 수 있는 양식을 생각해 봅시다:
- 양식에 무언가를 입력하면 '제출' 버튼이 활성화됩니다.
- '제출'을 누르면 양식과 버튼이 모두 비활성화되고 스피너가 나타납니다.
- 네트워크 요청이 성공하면 양식이 숨겨지고 "감사합니다" 메시지가 표시됩니다.
- 네트워크 요청이 실패하면 오류 메시지가 나타나고 양식이 다시 활성화됩니다.
명령형 프로그래밍에서 위의 내용은 상호작용을 구현하는 방법에 직접적으로 해당합니다. 방금 발생한 상황에 따라 UI를 조작하기 위한 정확한 지침을 작성해야 합니다. 다른 방법으로 생각해 볼 수 있습니다. 자동차를 타고 가는 사람 옆에서 어디로 가야 하는지 차례대로 알려준다고 상상해 보세요.
컴퓨터는 사용자가 어디로 가고 싶은지 모른 채 명령만 따를 뿐입니다. (방향을 잘못 잡으면 엉뚱한 곳으로 가게 됩니다!) 스피너부터 버튼까지 각 요소에 "명령"을 내려 컴퓨터에 UI를 업데이트하는 방법을 알려줘야 하므로 명령형이라고 합니다.
이 명령형 UI 프로그래밍 예시에서는 폼이 React 없이 빌드되었습니다. 내장된 브라우저 DOM을 사용합니다:
async function handleFormSubmit(e) {
e.preventDefault();
disable(textarea);
disable(button);
show(loadingMessage);
hide(errorMessage);
try {
await submitForm(textarea.value);
show(successMessage);
hide(form);
} catch (err) {
show(errorMessage);
errorMessage.textContent = err.message;
} finally {
hide(loadingMessage);
enable(textarea);
enable(button);
}
}
function handleTextareaChange() {
if (textarea.value.length === 0) {
disable(button);
} else {
enable(button);
}
}
function hide(el) {
el.style.display = 'none';
}
function show(el) {
el.style.display = '';
}
function enable(el) {
el.disabled = false;
}
function disable(el) {
el.disabled = true;
}
function submitForm(answer) {
// Pretend it's hitting the network.
return new Promise((resolve, reject) => {
setTimeout(() => {
if (answer.toLowerCase() == 'istanbul') {
resolve();
} else {
reject(new Error('Good guess but a wrong answer. Try again!'));
}
}, 1500);
});
}
let form = document.getElementById('form');
let textarea = document.getElementById('textarea');
let button = document.getElementById('button');
let loadingMessage = document.getElementById('loading');
let errorMessage = document.getElementById('error');
let successMessage = document.getElementById('success');
form.onsubmit = handleFormSubmit;
textarea.oninput = handleTextareaChange;
UI를 조작하는 것은 고립된 예제에서는 충분히 잘 작동하지만, 더 복잡한 시스템에서는 관리하기가 기하급수적으로 어려워집니다. 다음과 같이 다양한 양식으로 가득 찬 페이지를 업데이트한다고 상상해 보세요. 새로운 UI 요소나 새로운 상호작용을 추가하려면 기존의 모든 코드를 주의 깊게 확인하여 버그가 발생하지 않았는지(예: 무언가를 표시하거나 숨기는 것을 잊어버리는 등) 확인해야 합니다.
React는 이 문제를 해결하기 위해 만들어졌습니다.
React에서는 UI를 직접 조작하지 않습니다. 즉, 컴포넌트를 직접 활성화, 비활성화, 표시 또는 숨기지 않습니다. 대신 표시할 내용을 선언하면 React가 UI를 업데이트하는 방법을 알아냅니다. 택시를 타고 기사에게 정확히 어디로 갈지 말하지 않고 어디로 가고 싶은지 말한다고 생각해보세요. 목적지까지 데려다주는 것은 운전기사의 몫이며, 운전기사는 여러분이 미처 생각하지 못한 지름길을 알고 있을 수도 있습니다!
선언적으로 UI에 대해 생각하기
위에서 폼을 명령형으로 구현하는 방법을 살펴봤습니다. React에서 사고하는 방법을 더 잘 이해하기 위해 아래에서 이 UI를 React로 다시 구현하는 과정을 살펴보겠습니다:
- 컴포넌트의 다양한 시각적 상태 식별하기
- 이러한 상태 변화를 유발하는 요소 결정하기
- useState를 사용해 메모리에서 상태 표현하기
- 필수적이지 않은 상태 변수를 제거합니다.
- 이벤트 핸들러를 연결해 상태를 설정합니다.
1단계: 컴포넌트의 다양한 시각적 상태 식별하기
컴퓨터 과학에서 "state machine"이 여러 "states" 중 하나에 있다는 말을 들어보셨을 것입니다. 디자이너와 함께 일한다면 다양한 "시각적 상태"에 대한 목업을 본 적이 있을 것입니다. React는 디자인과 컴퓨터 과학의 교차점에 서 있기 때문에 이 두 가지 아이디어 모두 영감의 원천이 됩니다.
먼저, 사용자에게 표시될 수 있는 UI의 다양한 '상태'를 모두 시각화해야 합니다:
- 비어 있음: 양식에 '제출' 버튼이 비활성화되어 있습니다.
- 입력 중: 양식에 '제출' 버튼이 활성화되어 있습니다.
- 제출 중: 양식이 완전히 비활성화되었습니다. 스피너가 표시됩니다.
- 성공: 양식 대신 "감사합니다" 메시지가 표시됩니다.
- 오류: 입력 상태와 동일하지만 추가 오류 메시지가 표시됩니다.
디자이너와 마찬가지로 로직을 추가하기 전에 다양한 상태에 대한 '목업'을 만들거나 '모의'를 만들어야 합니다. 예를 들어, 다음은 양식의 시각적 부분만을 위한 목업입니다. 이 모의는 기본값이 '비어 있음'인 상태라는 prop으로 제어됩니다:
export default function Form({
status = 'empty'
}) {
if (status === 'success') {
return <h1>That's right!</h1>
}
return (
<>
<h2>City quiz</h2>
<p>
In which city is there a billboard that turns air into drinkable water?
</p>
<form>
<textarea />
<br />
<button>
Submit
</button>
</form>
</>
)
}
prop에 원하는 이름을 붙일 수 있으며, 이름은 중요하지 않습니다. state = 'empty'을 state = 'success'으로 편집하여 성공 메시지가 표시되는지 확인해보세요. 모킹을 사용하면 로직을 연결하기 전에 UI를 빠르게 반복할 수 있습니다. 다음은 동일한 컴포넌트의 좀 더 구체화된 프로토타입으로, 여전히 status props에 의해 '제어'됩니다:
export default function Form({
// Try 'submitting', 'error', 'success':
status = 'empty'
}) {
if (status === 'success') {
return <h1>That's right!</h1>
}
return (
<>
<h2>City quiz</h2>
<p>
In which city is there a billboard that turns air into drinkable water?
</p>
<form>
<textarea disabled={
status === 'submitting'
} />
<br />
<button disabled={
status === 'empty' ||
status === 'submitting'
}>
Submit
</button>
{status === 'error' &&
<p className="Error">
Good guess but a wrong answer. Try again!
</p>
}
</form>
</>
);
}
Deep Dive - 한 번에 여러 시각적 상태 표시
컴포넌트에 시각적 상태가 많은 경우 한 페이지에 모두 표시하는 것이 편리할 수 있습니다:
import Form from './Form.js';
let statuses = [
'empty',
'typing',
'submitting',
'success',
'error',
];
export default function App() {
return (
<>
{statuses.map(status => (
<section key={status}>
<h4>Form ({status}):</h4>
<Form status={status} />
</section>
))}
</>
);
}
이러한 페이지를 흔히 '생활 스타일 가이드' 또는 '스토리북'이라고 합니다.
2단계: 상태 변경을 트리거하는 요소 결정하기
두 가지 종류의 입력에 대한 응답으로 상태 업데이트를 트리거할 수 있습니다:
버튼 클릭, 필드 입력, 링크 탐색과 같은 유저 입력.
네트워크 응답 도착, 시간 초과 완료, 이미지 로딩과 같은 컴퓨터 입력.
두 경우 모두 UI를 업데이트하려면 상태 변수를 설정해야 합니다. 개발 중인 양식의 경우 몇 가지 다른 입력에 대한 응답으로 상태를 변경해야 합니다:
- 텍스트 입력(유저)을 변경하면 텍스트 상자가 비어 있는지 여부에 따라 비어 있음 상태에서 입력 중 상태로 전환되거나 다시 전환되어야 합니다.
- 제출 버튼(유저)을 클릭하면 제출 중 상태로 전환됩니다.
- 네트워크 응답 성공(컴퓨터)은 성공 상태로 전환됩니다.
- 네트워크 응답 실패(컴퓨터)는 일치하는 오류 메시지와 함께 오류 상태로 전환되어야 합니다.
유저 입력에는 종종 이벤트 핸들러가 필요하다는 점에 주목하세요!
이 흐름을 시각화하는 데 도움이 되도록 종이에 각 상태를 레이블이 지정된 원으로 그리고 두 상태 사이의 각 변경 사항을 화살표로 그려 보세요. 이러한 방식으로 많은 플로우를 스케치하고 구현하기 훨씬 전에 버그를 분류할 수 있습니다.
3단계: useState로 메모리 상태 표현하기
다음으로 컴포넌트의 시각적 상태를 메모리에서 useState로 표현해야 합니다. 단순함이 핵심입니다. 각 상태들은 "움직이는 조각"이며, 가능한 한 적은 수의 "움직이는 조각"을 원합니다. 복잡하면 버그가 더 많이 발생합니다!
반드시 있어야 하는 상태부터 시작하세요. 예를 들어, 입력에 대한 답을 저장하고 마지막 오류를 저장하려면 오류(존재하는 경우)를 저장해야 합니다:
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
그런 다음 앞서 설명한 시각적 상태 중 어떤 상태를 표시할지 나타내는 상태 변수가 필요합니다. 일반적으로 메모리에 이를 표현하는 방법은 한 가지 이상이므로 실험해 볼 필요가 있습니다.
가장 좋은 방법을 즉시 생각하기 어렵다면 가능한 모든 시각적 상태를 확실히 다룰 수 있을 만큼 충분한 상태를 추가하는 것부터 시작하세요:
const [isEmpty, setIsEmpty] = useState(true);
const [isTyping, setIsTyping] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSuccess, setIsSuccess] = useState(false);
const [isError, setIsError] = useState(false);
첫 번째 아이디어가 최선이 아닐 수도 있지만, 상태 리팩토링은 프로세스의 일부이니 괜찮습니다!
4단계: 필수적이지 않은 상태 변수 제거하기
상태 콘텐츠의 중복을 방지하여 필수적인 것만 추적하고 싶을 것입니다. 상태 구조를 리팩토링하는 데 약간의 시간을 투자하면 컴포넌트를 더 쉽게 이해하고, 중복을 줄이며, 의도하지 않은 의미를 피할 수 있습니다. 목표는 메모리에 있는 상태가 사용자에게 표시되기를 원하는 유효한 UI를 나타내지 않는 경우를 방지하는 것입니다. (예를 들어 오류 메시지를 표시하면서 동시에 입력을 비활성화하면 사용자가 오류를 수정할 수 없게 됩니다.)
다음은 상태 변수에 대해 물어볼 수 있는 몇 가지 질문입니다:
- 이 상태가 역설을 일으키는가? 예를 들어, isTyping과 isSubmitting은 둘 다 참일 수 없습니다. 역설은 일반적으로 상태가 충분히 제약되지 않았음을 의미합니다. 두 부울의 조합은 네 가지가 가능하지만 유효한 상태에 해당하는 것은 세 가지뿐입니다. '불가능' 상태를 제거하려면 세 가지 값 중 하나여야 하는 상태로 결합하면 됩니다: '입력 중', '제출 중', '성공' 중 하나여야 합니다.
- 다른 상태 변수에서 이미 동일한 정보를 사용할 수 있나요? 또 다른 역설은 isEmpty와 isTyping이 동시에 참일 수 없다는 것입니다. 두 변수를 별도의 상태 변수로 만들면 동기화되지 않아 버그가 발생할 위험이 있습니다. 다행히도 isEmpty를 제거하고 대신 answer.length === 0을 확인할 수 있습니다.
- 다른 상태 변수의 역함수에서 동일한 정보를 얻을 수 있을까요? 오류 !== null을 대신 확인할 수 있으므로 isError는 필요하지 않습니다.
이렇게 정리하고 나면 필수 상태 변수가 7개에서 3개(7개에서 감소!)로 줄어듭니다:
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'
기능을 중단하지 않고는 제거할 수 없기 때문에 필수적인 요소라는 것을 알고 계실 것입니다.
Deep Dive - reducer로 "불가능한" 상태 제거하기
이 세 가지 변수는 이 양식의 상태를 충분히 잘 표현합니다. 하지만 여전히 완전히 이해되지 않는 중간 상태가 몇 가지 있습니다. 예를 들어, 상태가 '성공'일 때 null이 아닌 오류는 의미가 없습니다. 상태를 보다 정확하게 모델링하기 위해 상태를 감속기로 추출할 수 있습니다. 리듀서를 사용하면 여러 상태 변수를 하나의 객체로 통합하고 모든 관련 로직을 통합할 수 있습니다!
5단계: 이벤트 핸들러를 연결하여 상태 설정하기
마지막으로 상태 변수를 설정할 이벤트 핸들러를 생성합니다. 아래는 모든 이벤트 핸들러가 연결된 최종 양식입니다:
import { useState } from 'react';
export default function Form() {
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing');
if (status === 'success') {
return <h1>That's right!</h1>
}
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
try {
await submitForm(answer);
setStatus('success');
} catch (err) {
setStatus('typing');
setError(err);
}
}
function handleTextareaChange(e) {
setAnswer(e.target.value);
}
return (
<>
<h2>City quiz</h2>
<p>
In which city is there a billboard that turns air into drinkable water?
</p>
<form onSubmit={handleSubmit}>
<textarea
value={answer}
onChange={handleTextareaChange}
disabled={status === 'submitting'}
/>
<br />
<button disabled={
answer.length === 0 ||
status === 'submitting'
}>
Submit
</button>
{error !== null &&
<p className="Error">
{error.message}
</p>
}
</form>
</>
);
}
function submitForm(answer) {
// Pretend it's hitting the network.
return new Promise((resolve, reject) => {
setTimeout(() => {
let shouldError = answer.toLowerCase() !== 'lima'
if (shouldError) {
reject(new Error('Good guess but a wrong answer. Try again!'));
} else {
resolve();
}
}, 1500);
});
}
이 코드는 원래의 명령형 예제보다 길지만 훨씬 덜 취약합니다. 모든 상호작용을 상태 변화로 표현하면 나중에 기존 상태를 깨지 않고도 새로운 시각적 상태를 도입할 수 있습니다. 또한 인터랙션 자체의 로직을 변경하지 않고도 각 상태에 표시되어야 하는 내용을 변경할 수 있습니다.
요약
- 선언적 프로그래밍이란 UI를 세세하게 관리하지 않고 각 시각적 상태에 대한 UI를 설명하는 것을 의미합니다(명령형).
- 컴포넌트를 개발할 때:
- 모든 시각적 상태를 식별합니다.
- 상태 변경에 대한 인간 및 컴퓨터 트리거를 결정합니다.
- useState로 상태를 모델링합니다.
- 버그와 역설을 피하기 위해 필수적이지 않은 상태를 제거하세요.
- 이벤트 핸들러를 연결하여 상태를 설정합니다.
과제 1 / 3: CSS 클래스 추가 및 제거하기
그림을 클릭하면 외부 <div>에서 배경 활성 CSS 클래스가 제거되지만 <img>에 그림 활성 클래스가 추가되도록 합니다. 배경을 다시 클릭하면 원래 CSS 클래스가 복원됩니다.
시각적으로 보면 그림을 클릭하면 보라색 배경이 제거되고 그림 테두리가 강조 표시되는 것을 볼 수 있습니다. 그림 바깥쪽을 클릭하면 배경은 강조 표시되지만 그림 테두리 강조 표시가 제거됩니다.
과제 2 / 3: 프로필 편집기
다음은 일반 JavaScript와 DOM으로 구현된 작은 양식입니다. 직접 사용해보고 동작을 이해해 보세요:
이 양식은 편집 모드에서는 입력 내용을 볼 수 있고 보기 모드에서는 결과만 볼 수 있는 두 가지 모드로 전환할 수 있습니다. 버튼 레이블은 현재 모드에 따라 '편집'과 '저장' 사이에서 변경됩니다. 입력을 변경하면 하단의 환영 메시지가 실시간으로 업데이트됩니다.
여러분의 임무는 아래 샌드박스에서 React로 다시 구현하는 것입니다. 편의를 위해 마크업은 이미 JSX로 변환되었지만, 원본처럼 입력을 표시하고 숨기도록 만들어야 합니다.
하단의 텍스트도 업데이트되는지 확인하세요!
과제 3 / 3: React 없이 명령형 솔루션 리팩토링하기
다음은 이전 과제의 원래 샌드박스로, React 없이 명령형으로 작성되었습니다:
React가 존재하지 않는다고 상상해 보세요. 이 코드를 리팩터링하여 로직이 덜 취약하고 React 버전과 더 유사하게 만들 수 있을까요? React에서처럼 상태가 명시적이라면 어떤 모습일까요?
어디서부터 시작해야 할지 고민 중이라면, 아래 스텁에 이미 대부분의 구조가 갖춰져 있습니다. 여기서 시작한다면 updateDOM 함수에서 누락된 로직을 채우면 됩니다. (필요한 경우 원본 코드를 참조하세요.)
*이 문제는 solution만 참고하고 직접 풀지않았음
https://codesandbox.io/s/3juca-lwrk1m?file=/src/ReactingToInputWithState/ProfileEditor.js
발표요약)
1. 명령형 프로그래밍은 다 정확히 적어줘야하고, 선언형 프로그래밍은 직접 돔을 컨트롤하지않는다.
(상태 변화를 리액트에 알려주고 UI를 변경한다.)
2. 시각적 상태를 잘 판단하여 사용하고, 불필요한 상태를 잘 제거해줘야한다.
3. 유저입력을 받으면 대부분 이벤트핸들러가 필요하다.