카테고리 없음

[React] state를 사용해 입력에 대응하기

불곰자리 2024. 8. 31. 01:05

React는 UI를 조작하기 위해 선언적인 방법을 제공한다. UI의 각 부분을 직접 조작하는 것이 아니라, 컴포넌트가 얻을 수 있는 서로 다른 상태를 서술하여, 사용자의 입력에 대해 그에 대한 상태를 교체한다. 이건, 디자이너가 UI에 대해 사고하는 방식과 비슷하다.

이 글에서 배울 수 있는 것
1. 선언형 UI 프로그래밍과 명령형 UI 프로그래밍의 차이
2. 컴포넌트가 얻을 수 있는 여러 시각적 상태를 열거하는 방법
3. 서로 다른 시각적 상태 간의 변화를 코드에서 발생시키는 방법

 

선언형 UI와 명령형 UI의 비교

상호작용적인 UI를 설계할 때, 사용자의 행동에 대해 UI가 어떻게 변화할지 생각하는 경우가 많다. 예를 들어, 사용자가 대답을 전송할 수 있는 폼을 생각해보자.

  • 폼에 무언가 입력하면, "Submit" 버튼이 활성화된다.
  • "Submit" 버튼을 누르면, 폼과 버튼이 비활성화되고, 스피너가 표시된다.
  • 네트워크 요청이 성공하면, 폼은 사라지고, 축하 메시지가 표시된다.
  • 네트워크 요청이 실패할 경우, 에러 메시지가 표시되고, 폼이 다시 활성화된다.

명령형 프로그래밍에선, 위의 변화가 그대로 UI 간의 상호작용 구현법에 대응한다. 일어난 것에 대해 UI를 조작하기 위해 명령 그 자체를 작성하지 않으면 안된다. 다르게 생각해보자: 차의 조수석에 타고있는 사람에게, 방향을 전환할 때마다 갈 곳을 지시해주는 것을 생각해보자.

 

운전자는 당신이 어디로 가고싶은지 알지 못한 채로, 그저 당신의 지시에 따를 뿐이다. (그리고, 당신의 방향 지시가 잘못되었다면, 잘못된 곳으로 도착하고 만다.) 이것을 명령형이라고 부른다. 왜냐하면, 스피너에서부터 버튼까지 도달하기 위해선, 각각의 요소에 대해 직접 명령하여서, 컴퓨터가 어떻게 UI를 변경할지 지시하기 때문이다.

 

아래의 명령형 UI 프로그래밍의 예시에선, 폼은 React를 사용하지 않고 브라우저의 DOM만을 이용하고 있다.

// index.js
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;
<form id="form">
  <h2>City quiz</h2>
  <p>
    What city is located on two continents?
  </p>
  <textarea id="textarea"></textarea>
  <br />
  <button id="button" disabled>Submit</button>
  <p id="loading" style="display: none">Loading...</p>
  <p id="error" style="display: none; color: red;"></p>
</form>
<h1 id="success" style="display: none">That's right!</h1>

<style>
* { box-sizing: border-box; }
body { font-family: sans-serif; margin: 20px; padding: 0; }
</style>

위 코드의 실행 결과

UI를 명령적으로 조작한다는 것은 작은 샘플에선 잘 실행될 수도 있지만, 더욱 복잡한 시스템에선 어려움이 지수적으로 증가한다. 예를 들어 이런 여러 폼으로 여러 페이지를 변경하는 걸 생각해보자. 새로운 UI 요소나 새로운 조작방법을 추가할 경우, 기존의 모든 코드를 주의 깊에 확인해, 버그(예를 들면, 무언가 표시하거나 표시하지 않는 것을 잊어버리지 않았는지) 확인할 필요가 있다.

 

React는 이 문제를 해결하기 위해 만들어졌다.

 

React에선, 당신이 UI를 직접적으로 조작하는 것은 불가능하다. 즉, 컴포넌트의 활성화, 비활성화, 표시, 비표시를 직접 행하는 것은 불가능하다. 대신, 보여주고 싶은 것을 선언하는 것으로, React가 UI를 변경하는 방법을 생각할 수 있게 한다. 택시에 탔을 때, 어디에서 핸들을 꺾을지 정확하게 전달하는 것이 아닌, 어디에 가고싶은지 운전자에게 전달하는 걸 생각해보자. 운전자는 당신을 그곳에 데려다주는 것이 일이고, 당신이 생각하지 않았던 지름길도 알 수 있다. 

 

UI를 선언적으로 생각하기

위에선, 폼을 명령적으로 활용하여 삽입하는 방법을 보았다. React적인 사고방식을 보다 잘 이해하기 위해서 아래의 이 UI를 React로 재삽입하는 방법을 확인해보겠다.

  1. 컴포넌트의 여러 시각적 상태를 특정한다.
  2. 그것들의 상태변화를 일으키는 계기를 결정한다.
  3. useState를 사용해 메모리 상에 state를 표현한다.
  4. 꼭 필요한 state변수를 모두 삭제한다.
  5. 이벤트 핸들러를 부착해 state를 설정한다.

Step 1: 컴포넌트의 여러 시각적 상태를 특정한다.

CS용어로, 복수의 상태 사이를 왔다갔다하는 구조인 '스테이트먼트' 라는 용어를 들어봤을지도 모른다. 혹은 디자이너와 함께 일을하며, 여러가지 시각 상태의 목업을 본 적이 있을지도 모른다. React는 디자인과 CS 그 사이에 위치해있기 때문에, 양쪽의 아이디어가 영감의 원천이 된다.

 

우선, 사용자가 보고 있는 가능성있는 UI의 여러 상태를 모두 가시화할 필요가 있다.

  • Empty: 폼에는 비활성화된 "Submit" 버튼이 있다.
  • Typing: 폼에는 활성화된 "Submit" 버튼이 있다.
  • Submitting: 폼은 완전히 비활성화된다. 스피너가 표시된다.
  • Success: 폼 대신 축하 메시지가 표시된다.
  • Error: Typing 상태와 같지만, 에러 메시지도 표시된다.
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>
    </>
  )
}

위 코드 실행 결과물

이 props의 이름은 아무 관계도 없다. 이름을 붙이는 게 중요하진 않다. status = 'empty'status = 'success'로 편집하여, 정답이라는 메시지가 표시되는 것을 확인해보자. 목업을 사용하여, 로직을 짜기 전, 각 UI의 상태를 빠르게 확인할 수 있다. 위의 컴포넌트에 조금 더 보퉁한 프로토타입을 아래에 본보기로 보여주지만, 여전히 status 프로퍼티에 대해 제어된다. 

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>
      </>
  );
}

위 코드 실행 결과물

 

Step 2: 각자의 상태 변화를 일으키는 계기를 결정한다

아래 두 가지의 입력에 대응해, 상태의 변화를 일으킬 수 있다.

  • 사람으로부터의 입력: 예를 들어 버튼을 클릭하는 것, 필드에 입력하는 것, 링크를 탐색하는 것 등
  • 컴퓨터로부터의 입력: 예를 들어 네트워크 응답이 도착하는 것, 타임아웃이 일어나는 것, 영상 데이터가 읽히는 것 

어느 상황에서도, UI를 변화시키기 위해선 state 변수를 설정할 필요가 있다. 이번에 개발하는 폼에선, 몇 개의 다른 입력에 대응하여 상태를 변화할 필요가 있다.

  • 텍스트 입력 필드의 편집(사람)에 의해, 텍스트 박스의 공백여부로 Empty 상태와 Typing 상태로 서로 교체한다.
  • 전송 버튼 클릭(사람)에 의해, Submitting 상태로 변화한다.
  • 네트워크 응답의 성공(컴퓨터)에 의해, Success 상태로 변화한다.
  • 네트워크 응답의 실패(컴퓨터)에 의해, 대응하는 에러 메시지와 같이 Error 상태로 변화한다.

이 흐름을 시각화하기 위해, 각 상태를 원으로 둘러싸 그린다음, 두 가지 상태 사이의 변화를 화살표로 그려보자. 이렇게 해서 많은 흐름을 그려내는 것으로, 기능이 나중에 추가되어도 버그를 줄일 수 있다.

시스템 흐름

Step 3: useState를 사용해 메모리 상에 state를 표현한다

다음으로, useState를 사용해 컴포넌트의 시각적 상태를 메모리 내에 표현할 필요가 있다. 간단함이 열쇠다. 각 state는 '움직이는 파츠'이기도 하며, 가능한 이 '움직이는 파츠'를 적게하려고 한다. 복잡함이 증가하면 버그도 증가하니까.

 

우선 절대로 필요한 state부터 시작한다. 예를 들어, 입력 중의 회답에 해당하는 answer를 보존할 필요가 있고, 마지막에 일어난 에러(있다면)를 보존하기 위해 error가 필요하다.

const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);

 

그리고, 이 시각적 상태를 표시할지 보여주는 state 변수가 요구된다. 보통은 메모리 상에서 이걸 표현하는 방법은 한 가지가 아니기 때문에, 실제로 체험할 필요가 있다.

 

만약, 바로 최적의 방법이 떠오르지 않는 경우는, 우선, 생각한 모든 것을 시각적 상태로 확실하게 커버할 수 있는 충분한 수의 state를 추가하는 것부터 시작하자.

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);

 

처음 아이디어가 최적의 아이디어가 아닐 가능성도 있지만, 그건 그거대로 괜찮다. state의 리팩토링은 프로세스의 일부이니까.

 

Step 4: 필수불가결이 아닌 state 변수를 모두 삭제한다

state의 내용의 중복이 없도록 하기 위해, 정말 필요한 것만 관리하려고 한다. state의 구조를 리팩터링하는 것에 조금 시간을 할애하기만해도, 컴포넌트의 이해가 쉬워지고, 중복이 줄어들고, 예상 외의 의미를 찾을 수 있다. 목표는 메모리 상의 state가 사용자에게 보여주고싶은 유효한 UI를 표시하지 않는 상태를 막는 것이다. (예를 들어, 에러 메시지를 표시함과 동시에 입력을 무효화하면 안된다. 사용자가 에러를 수정할 수 없게 되니까.)

 

자신의 state 변수에 대해 아래와 같이 자문자답해보자.

  • 이 state로 모순이 생기지는 않나? 예를 들어, isTypingisSubmitting 두 변수가 동시에 true가 되는 경우가 생기면 안된다. 모순이 있는 state라는 건 보통, state의 제약이 충분하지 않다는 걸 의미한다. 두 가지 boolean값의 조합은 네 가지가 있지만, 유효한 상태에 대응하는 경우는 세 가지뿐이다. 이와 같이 있을 수 없는 state를 삭제하기 위해선, 이것들을 정리해서, typing, submitting, 또는 success 세 가지의 값 중 하나여야하는 status라는 하나의 state만을 선언하면 된다.
  • 같은 정보가 다른 state 변수에서부터 구해지지 않나? 또 하나의 모순의 원인은, isEmptyisTyping이 동시에 true가 되기 때문이다. 이것들을 각각 state 변수로 선언하면, 동기화가 안되어서, 버그가 발생할 위험이 있다. 어렵지만, isEmpty를 삭제하고, 대신 answer.length === 0을 확인할 수 있다.
  • 다른 state 변수의 반대 값을 가지고 같은 정보를 얻고있지 않나? isError는 불필요하다. 왜냐하면 대신 error !== null을 체크할 수 있기 때문이다. 
const [answer, setAnswer] = useState('');
const [error, setError] = useState(null);
const [status, setStatus] = useState('typing'); // 'typing', 'submitting', or 'success'

 

기능을 부수지 않고 어떤 것을 분리하는 것은 불가능해서, 필수불가결한 것임을 알 수 있다.

 

Step 5: 이벤트 핸들러를 붙여서 state를 설정한다

마지막으로, state를 변경하는 이벤트 핸들러를 작성한다. 아래에, 모든 이벤트 핸들러가 붙여진 최종적인 폼을 보여줄 것이다.

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) {
  // 네트워크 요청을 하고 응답을 받은 것처럼 반환
  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);
  });
}

 

이 코드에선, 원래 명령형의 예시보다 길어지지만, 개발을 계속하다보면 코드가 비교적 덜 무너진다. 모든 상호작용을 state 변화로써 표현한느 것으로, 기존의 state를 파괴하지 않고, 뒤에서 새로운 시각적 상태를 도입할 수 있다. 또, 상호 작용 그 자체의 로직을 변경하지 않고, 각 state가 보여주는 것들을 변경할 수 있다.

 

정리

  • 선언형 프로그래밍은, UI를 세밀하게 관리하는(명령형)이 아닌, 시각적 상태 자체에 UI를 기술하는 것을 의미한다.
  • 컴포넌트를 개발할 때
    1. 컴포넌트의 시각적 상태를 모두 특정한다.
    2. 상태를 변화하기 위해 사용자 및 컴퓨터의 트리거(발생 계기)를 결정한다.
    3. useState로 state를 모델화한다.
    4. 버그나 모순을 피하기 위해, 불필요한 state를 삭제한다.
    5. state를 설정하기 위해 이벤트 핸들러를 붙인다.

참고글

state를 사용해 입력에 대응하기 - React 공식 문서