공식문서를 크게 크게 한 챕터씩 정리하다가
3번째 챕터인 Managing State
부분부터는 한 강의 내용이 슬슬 깊어지는 듯 하여
한 강씩 정리하려고 한다.
How declarative UI compares to imperative
리액트는 명령형 UI 가 아닌 선언적 UI를 이용한다.
Imperative UI
웹 페이지에서 클라이언트와 인터렉티브하게
클라이언트의 행동에 따라서 다른 페이지를 보여줘야 한다면
명령형 UI의 경우에는 클라이언트 행동에 따라 웹 브라우저의 행동 로직을 모두 구현해두도록 한다.
form
에 어떤 입력값을 넣고 있다면 submit
버튼을 enable
하게 하기submit
버튼을 누르면 버튼은 disable
이 되고 loading
이라는 글자가 하단에 뜨도록 함form
버튼은 비활성화가 된다.form
은 사라지고 Thank you
라는 메시지가 뜨도록 한다.form
버튼이 활성화 된다.다음처럼 인터렉티브하게 클라이언트 요청에 따라 인터렉티브한 화면을 구성하고 싶다면 명령형 UI의 코드는 다음과 같을 것이다.
import './App.css';
export default function App() {
// 기본적인 컴포넌트 구성
return (
<form onSubmit={checkAnswer}>
<header>
<h1>Math Quiz</h1>
<h3>What is 1 + 1 ?</h3>
</header>
<main>
<textarea onInput={handleChange}></textarea>
<br />
<button disabled={true}>submit</button>
</main>
<footer>
<p id='loading' style={{ display: 'none' }}>
Loading ..
</p>
<p id='success' style={{ display: 'none' }}>
you are correct
</p>
<p id='fail' style={{ display: 'none' }}>
try agin !{' '}
</p>
</footer>
</form>
);
}
// 행위에 따른 메소드 정의
function disable(el) {
el.disabled = true;
}
function enable(el) {
el.disabled = false;
}
function hide(el) {
el.style.display = 'none';
}
function show(el) {
el.style.display = '';
}
function handleChange(e) {
const $button = document.querySelector('button');
if (e.target.value.length === 0) disable($button);
else enable($button);
}
function handleSubmit(answer) {
// 네트워크 상태를 가장한 Promise 객체
// 1.5 초 이후 정답일 경우엔 fullfiled , 오답일 경우엔 Error 생성
return new Promise((res, rej) => {
setTimeout(() => {
if (answer === '2') res();
else rej();
}, 1500);
});
}
async function checkAnswer(e) {
const $textarea = document.querySelector('textarea');
const $button = document.querySelector('button');
const $loading = document.querySelector('#loading');
const $success = document.querySelector('#success');
const $fail = document.querySelector('#fail');
// 무수한 행위 별 액션
e.preventDefault();
// 제출 시 기본 액션
hide($fail); // 이전에 발생한 fail 메시지 제거
// 비활성화
disable($textarea);
disable($button);
try {
show($loading); // await 하는 동안엔 loading 메시지 보이게
await handleSubmit($textarea.value); // 정답이라 fullfiled 됐을 때의 액션들
hide($loading);
hide($textarea);
hide($button);
show($success);
} catch {
// 오류일 때의 액션들
enable($textarea);
enable($button);
show($fail);
} finally {
// 기본적으로 loading 메시지는 제거하기
hide($loading);
}
}
이렇게 명령형 UI 를 이용해 구성해보았다.
작동에는 문제가 없지만 이런 명령형 UI 를 이용 할 때의 단점들이 몇 가지 존재한다.
- 이해하기가 복잡하다.
명령형 UI 를 이용하게 되면 위 코드를 보아도 전체적인 흐름을 이해하는 것이 어렵다.
그 이유는 각 상태마다 단계별로 액션들을 정의해두었기 때문에 개발자는 모든 액션들을 하나하나 지정해줘야 하며 각 액션들이 충돌하지 않도록
코드가 복잡해질 수 밖에 없다.
- 유지 보수성 및 확장성이 낮다.
명령형 UI 는 액션 하나 하나가 서로 충돌하지 않도록 코드가 복잡하다는 단점이 있다고 하였다.
이 때 새로운 액션을 추가 하고 싶다면, 이전에 설정해 둔 액션들과 충돌하지 않도록 수정해야 할 것이다.
경험상 확장성이 낮은 코드를 확장하기 위해서 이것 저것 고치며 디버깅 하는 것보다 새로 다시 짜는게 편할 때가 있다.
- 재사용이 힘들다.
2번과 유사한 내용으로, 각 행위 별 액션을 하나 하나씩 엄밀하게 정의해둔 코드는
다른 곳에서 재사용 할 때 다른 것들과 충돌 될 가능성이 높다.
- 인터렉션이 복잡해졌을 때 예상치 못한 에러와 마주 할 가능성이 높다.
엄밀하게 행동이 하나씩 맞물려져 정의된 코드들에서
인터렉션이 복잡해질 때 액션 간 충돌이 일어나 버그가 일어날 가능성이 높다.
- 디버깅이 어렵다.
각 인터렉션 별 액션을 직접적으로 정의해두었기 때문에 인터렉션과 액션의 흐름을 이해하기 힘들다.
이는 디버깅을 더욱 어렵게 한다.
위의 코드를 리액트에서 보고 직접 구성해볼 때 디버깅 하느라 혼났다.
- 퍼포먼스 오버헤드가 크다.
인터렉션 로직이 복잡해질 수록 액션들도 그만큼 자주 호출되며 퍼포먼스 오버헤드가 높아진다.
Declarative UI
리액트는 이런 복잡한 문제를 선언형 UI 를 통해 해결법을 제시했다.
사실 나는 선언형 UI 라는 단어만 보고 , 아 리액트에선 함수를 선언하니까 선언형 UI 인가 .. 했다.
선언형 UI 는 인터렉션 별 행위(명령)등을 구현해두는 것이 아니라
컴포넌트의 상태 별 렌더링 될 모습을 정의하고 , 인터렉션 별로 컴포넌트의 상태가 정의되도록 한다.
리액트에서 제시하는 선언형 UI 를 구성하기 이해 생각 할 5가지 단계가 있다.
1. Identify your component's diffrenect visual states
컴포넌트가 상태 별로 렌더링 될 모습을 정의하는 것이다.
상태에 따른 컴포넌트의 렌더링 양상을 정의하는 단계에서 컴포넌트가 가질 수 있는 상태들 또한 엄밀하게 정의한다.
위 예시에서 가질 수 있는 컴포넌트의 상태 별 렌더링 양상은 무엇이 있었을까 생각해보면 다음과 같다.
state
) => button 을 disabled 해두자 (rendering
)state
) => button 을 enabled 되게 해두자 (rendering
)state
) => textarea , button 을 disabled 하고 loading 이란 글자가 나타나게 하자 (render
)state
) => textarea ,button 을 보이지 않게 하고 correct 라는 문구가 뜨도록 하자 (render
)state
) => textarea, button 을 enable 시키고 try agin 이라는 문구가 뜨도록 하자 상태와 렌더링을 두 가지로 구분해둬 구분되는 계층을 만들어둔다 .
아키텍쳐들은 다음처럼 각자의 역할이 단순 할 수록 유지보수가 쉽고 이해하기가 훨씬 쉬워진다.
export default function App() {
// 상태값과 상태 변화를 일으킬 trigger 들을 useState 를 이용해 정의
const [status, setStatus] = useState('empty'); // textarea 의 상태 (typing , submitting , empty )
const [correct, setIsCorrect] = useState(null); // 정답 유무의 상태
const [answer, setAnswer] = useState(''); // 입력값의 상태
...
}
다음처럼 컴포넌트가 가질 수 있는 상태등을 정의해두자
2. Determine what triggers those state changes
상태와 렌더링을 구분해 정의해뒀다면
그렇다면 상태를 변경시킬 트리거들을 정의해야 한다.
그런 트리거에는 클라이언트의 액션에 따른 트리거가 존재하기도 하며
네트워크 상태나 컴퓨터 인풋에 따른 트리거가 존재하기도 한다. (네트워크 요청이나 파일 업로드 등이 그렇다.)
이전 챕터들에서 배웠지만
상태와 상태를 변경시키는 트리거들은 useState
를 이용하여 생성해주도록 한다.
useState
를 이용하면 state
변화에 따른 렌더링을 자동적으로 해주기 때문이다.
import './App.css';
import { useState } from 'react';
export default function App() {
// 상태값과 상태 변화를 일으킬 trigger 들을 useState 를 이용해 정의
const [status, setStatus] = useState('empty'); // textarea 의 상태 (typing , submitting , empty )
const [correct, setIsCorrect] = useState(null); // 정답 유무의 상태
const [answer, setAnswer] = useState(''); // 입력값의 상태
return (
<form>
<header>
<h1>Math Quiz</h1>
<h3>What is 1 + 1 ?</h3>
</header>
<main style={{ display: correct === true ? 'none' : '' }}>
<textarea disabled={status === 'submitting'}></textarea>
<br />
<button disabled={status !== 'typing'}>submit</button>
</main>
<footer>
<p
id='loading'
style={{ display: status === 'submitting' ? '' : 'none' }}
>
Loading ..
</p>
<p
id='success'
style={{ display: status !== 'submitting' && correct ? '' : 'none' }}
>
you are correct
</p>
<p
id='fail'
style={{ display: status !== 'submitting' && !correct ? '' : 'none' }}
>
try agin !{' '}
</p>
</footer>
</form>
}
다음처럼 state
를 규정해주고 state
별로 렌더링 될 모습을 구현해둔다.
main
영역은 제출한 정답이 correct
가 true
일 경우 사라지도록 한다.textarea , button
은 상태가 submitting
이 아닐 때에만 활성화 되도록 해둔다.footer
의 문구들은 status
가 submitting
이 아니면서 correct
상태 값에 따라 다르게 변하도록 해준다.3,4 단계가 있지만 여기서는 스킵하도록 하겠다.
3,4 단계는 문서에 가서 읽어보면 좋을 것 같다.
useState
를 이용하는 것과 불필요한state
를 지우는 것에 대한 이야기이다.
3. Connect the event handlers to set state
그럼 이제 인터렉션 별로 state
를 변경 할 수 있도록 이벤트 핸들러들을 선언해주도록 하자
import './App.css';
import { useState } from 'react';
export default function App() {
// 상태값과 상태 변화를 일으킬 trigger 들을 useState 를 이용해 정의
const [status, setStatus] = useState('empty'); // textarea 의 상태 (typing , submitting , empty )
const [correct, setCorrect] = useState(null); // 정답 유무의 상태
const [answer, setAnswer] = useState(''); // 입력값의 상태
function handleInput(e) {
setAnswer(e.target.value);
if (e.target.value.length === 0) setStatus('empty');
else setStatus('typing');
}
function checkAnswer() {
return new Promise((res, rej) => {
setTimeout(() => {
if (answer === '2') res();
else rej();
}, 1500);
});
}
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
try {
await checkAnswer();
setCorrect(true);
} catch {
setCorrect(false);
} finally {
setStatus('typing');
}
}
return (
<form>
<header>
<h1>Math Quiz</h1>
<h3>What is 1 + 1 ?</h3>
</header>
<main style={{ display: correct === true ? 'none' : '' }}>
<textarea
disabled={status === 'submitting'}
onInput={handleInput}
></textarea>
<br />
<button disabled={status !== 'typing'} onClick={handleSubmit}>
submit
</button>
</main>
<footer>
<p
id='loading'
style={{ display: status === 'submitting' ? '' : 'none' }}
>
Loading ..
</p>
<p
id='success'
style={{
display: status !== 'submitting' && correct === true ? '' : 'none',
}}
>
you are correct
</p>
<p
id='fail'
style={{
display: status !== 'submitting' && correct === false ? '' : 'none',
}}
>
try agin !{' '}
</p>
</footer>
</form>
);
}
상태를 변경시키는 메소드들을 이벤트 핸들러로 담아주었다.
export default function App() {
// 상태값과 상태 변화를 일으킬 trigger 들을 useState 를 이용해 정의
const [status, setStatus] = useState('empty'); // textarea 의 상태 (typing , submitting , empty )
const [correct, setCorrect] = useState(null); // 정답 유무의 상태
const [answer, setAnswer] = useState(''); // 입력값의 상태
function handleInput(e) {
setAnswer(e.target.value);
if (e.target.value.length === 0) setStatus('empty');
else setStatus('typing');
}
function checkAnswer() {
return new Promise((res, rej) => {
setTimeout(() => {
if (answer === '2') res();
else rej();
}, 1500);
});
}
async function handleSubmit(e) {
e.preventDefault();
setStatus('submitting');
try {
await checkAnswer();
setCorrect(true);
} catch {
setCorrect(false);
} finally {
setStatus('typing');
}
}
...
}
명령형 UI
의 경우에는 메소드들이 상태만을 변경시키기 때문에
각 메소드들을 통해 일어나는 일을 직관적으로 이해하는 것이 간단하다.
return (
<form>
<header>
<h1>Math Quiz</h1>
<h3>What is 1 + 1 ?</h3>
</header>
<main style={{ display: correct === true ? 'none' : '' }}>
<textarea
disabled={status === 'submitting'}
onInput={handleInput}
></textarea>
<br />
<button disabled={status !== 'typing'} onClick={handleSubmit}>
submit
</button>
</main>
<footer>
<p
id='loading'
style={{ display: status === 'submitting' ? '' : 'none' }}
>
Loading ..
</p>
<p
id='success'
style={{
display: status !== 'submitting' && correct === true ? '' : 'none',
}}
>
you are correct
</p>
<p
id='fail'
style={{
display: status !== 'submitting' && correct === false ? '' : 'none',
}}
>
try agin !{' '}
</p>
</footer>
</form>
);
상태에 따라 렌더링 로직을 정의해두었기 때문에 상태에 따른 렌더링 로직만 이해 한다면
액션 별 발생하는 상태 변화만을 추적하면 되기 때문에 디버깅이 훨씬 쉬워지고 전체적인 흐름을 이해하는 것이 쉬워졌다.
만약 추가적인 기능을 넣어주고 싶다면 상태와 상태 별 렌더링 로직만 추가해주면 되기 때문에 확장성 또한 늘어났다.
인터렉션 별 돔을 직접적으로 조작하는 것이 아닌
상태 별 렌더링 로직을 정의하고, 인터렉션은 상태를 정의하는 것이
리액트의 기본적인 철학이다.
처음 상태 관리에 대해서 이해하는데 어려움을 겪었으나 이제는 상태를 통한 렌더링 로직이 더 익숙해지니
오히려 명령형으로 코드를 작성하는게 더 어려웠던 것 같다.
키킥