리액트에서 button 요소를 type에 따라 조건부 렌더링할 때 벌어지는 일

이나리·2022년 7월 3일
4

문제의 상황

form 요소 안에서 입력 단계가 1에서 3까지 있다고 가정할 때, 1단계에서 다음 단계로 넘어가려면 다음 버튼을 클릭해야 하고, 마지막 3단계에서는 넘어갈 단계가 없으니 폼을 제출해야 합니다.

위에 설명한 것을, 리액트 코드로 간단하게 표현해보면 다음과 같습니다.

function App() {
  const [step, setStep] = useState(1);
  const [result, setResult] = useState('');
  
  const onClick = () => {
    setStep((prev) => prev + 1);
  };

  const onSubmit = (e) => {
    e.preventDefault();
    setResult('폼 제출 완료');
  };

  const renderButton =
    step === 3 ? (
      <button>제출</button>
    ) : (
      <button type="button" onClick={onClick}>
        다음
      </button>
    );

  return (
    <form onSubmit={onSubmit}>
      <h2>문제상황</h2>
      {result || <div>현재 {step}단계</div>}
      {!result && renderButton}
    </form>
  );
}

step 이라는 단계를 나타내는 상태값을 만들고, 마지막 단계가 아닐 경우에는 이 상태값을 1씩 증가시키는 클릭 이벤트를 실행하고, 마지막 단계인 3단계에서는 폼을 제출하고 결과 메세지를 출력합니다.

그런데 이 코드는 제가 의도한 것과 다르게 동작합니다.
2단계에서 다음 버튼을 누르면 3단계로 이동하는 것이 아니라, 결과 메세지를 출력한 후 3단계는 표시되지 않습니다.

왜 기대한 것과 다르게 동작했을까요? 그 원인을 하나씩 알아보겠습니다.

button 요소의 기본 동작

버튼 요소의 기본 typebutton 이 아닙니다. submit 입니다.
type 을 지정하지 않을 경우, 버튼의 타입은 submit 이 되어 서버로 폼과 같은 양식을 제출하는 동작을 기본 동작으로 갖게 됩니다.

MDN 문서에 더 정확한 설명이 나와있습니다.
따라서, 폼과 같은 양식을 제출하는 경우가 아니라면 버튼의 타입을 button 으로 지정하여 버튼의 기본 동작이 실행되지 않도록 해야 합니다.

button type 과 이벤트의 상관관계

코드가 왜 의도한 대로 동작하지 않는지 설명하기 위해서는, 먼저 아래 코드에 대한 이해가 선행되어야 합니다.

react github issue 에서 가져온 예제 코드를 단계별로 세분화 해봤습니다. 코드 링크
html과 자바스크립트를 이용한 간단한 버튼 클릭 이벤트입니다.

1. button type = submit, 타입을 특정하지 않은 경우

버튼의 타입을 특정하지 않으면, 타입은 기본적으로 submit 이 됩니다.

<form>
  <button>버튼</button>
</form>
document.querySelector('button').addEventlistener('click', () => {
  console.log('버튼 클릭');
});

document.querySelector('form').addEventListener('submit', (e) => {
  e.preventDefault(); // 페이지 새로고침 방지
  console.log('폼 제출 완료');
});

이 상태에서 버튼을 클릭하면, 버튼 클릭, 폼 제출 완료 가 모두 콘솔에 출력됩니다.
버튼을 클릭했으니 버튼에 전달된 click 이벤트가 실행되고, 폼과 같은 양식을 제출하는 기본 동작 때문에 폼 이벤트도 실행됩니다.

2. button type = button

<form>
  <button type="button">버튼</button>
</form>
document.querySelector('button').addEventlistener('click', () => {
  console.log('버튼 클릭');
});

document.querySelector('form').addEventListener('submit', (e) => {
  e.preventDefault(); // 페이지 새로고침 방지
  console.log('폼 제출 완료');
});

이번에는 타입을 button 으로 변경하고 버튼을 클릭해보겠습니다. 결과는 버튼 클릭 만 콘솔에 출력됩니다.
타입이 button 이기 때문에, 버튼의 기본 동작이 무시되고 폼 이벤트는 실행되지 않습니다.

이때, 버튼의 타입 속성값을 바꾸는 코드를 추가하면 어떻게 될까요?

document.querySelector('button').addEventlistener('click', (e) => {
  console.log('버튼 클릭');
  e.target.setAttributes('type', 'submit');
});

이제 버튼을 클릭하면, 버튼 클릭, 폼 제출 완료 가 모두 콘솔에 출력됩니다.

버튼의 타입만 클릭 이벤트 안에서 변경해줬을 뿐인데, 폼 이벤트까지 실행됐습니다.
뭔가 이상합니다. 이벤트 전파가 발생한 걸까요? 이벤트 전파를 막는 코드를 추가하면 폼 이벤트 실행을 막을 수 있을까요?

document.querySelector('button').addEventlistener('click', (e) => {
  e.stopPropagation(); // 이벤트 전파 방지
  console.log('버튼 클릭');
  e.target.setAttribute('type', 'submit');
});

이벤트 전파가 발생하지 않도록 했음에도 불구하고, 콘솔에는 버튼 클릭, 폼 제출 완료 가 모두 출력됩니다.

여기서 한가지 중요한 사실을 발견할 수 있습니다.
이벤트가 완료되기 전까지는, 버튼에 전달된 타입이 중요하지 않다는 것이죠.

버튼에 전달된 click 이벤트가 모두 실행된 후에야 버튼의 타입이 감지되고, 이 타입을 통해 버튼의 기본 동작을 실행할지를 결정한다는 것입니다.

리액트의 비교 알고리즘 (Reconciliation)

버튼 요소의 동작 방식은 리액트가 렌더 트리를 비교하는 과정에도 영향을 미칩니다.

리액트는 이전 렌더링 트리와 현재 렌더링 트리를 비교할 때, 같은 트리에 위치한 요소가 다른 타입이라면 기존 트리를 제거하고 새로운 트리로 교체해버립니다.
하지만, 같은 타입의 요소라면 attributes, 즉 속성 값을 비교해 달라진 속성 부분만 변경합니다.
즉, 리렌더링 되더라도 요소를 새로 교체하지 않고, 이전에 사용한 요소의 속성값만 바꿔 그대로 사용합니다.
리액트 공식문서 참고

우리는 step 이라는 상태값이 바뀔 때마다 렌더링할 버튼 요소를 결정하고 있습니다.
이 요소는 같은 트리에 위치하고, 같은 타입이기 때문에 렌더링마다 재사용된다는 것을 알 수 있습니다.

그런데 버튼의 type 은 항상 클릭 이벤트가 실행된 이후 감지되니, 모든 렌더링에서 이 버튼 요소는 기본값인 submit 타입을 갖게 됩니다.

이벤트가 실행된 이후, 타입이 감지되어 button 타입으로 변경된다고 하더라도 렌더링과는 무관합니다. 이 타입은 버튼의 기본 동작을 실행할지를 결정할 뿐입니다.

문제의 상황 되짚어보기

이제 코드를 다시 살펴볼까요?

  • step = 1 일 때, onClick 이벤트를 실행한 후 버튼의 타입은 button 입니다.
    button 은 버튼의 기본 동작을 무시하기 때문에 폼 이벤트가 실행되지 않습니다.

  • step = 2 일 때, onClick 이벤트가 실행되고 step 값이 3 이 되면서 버튼의 타입은 submit 이 됩니다.
    submit 은 버튼의 기본 동작을 실행하므로 폼 이벤트가 실행됩니다.

결국 마지막 3단계는 렌더링되지 않고 폼이 제출되어 버립니다.

해결방법

사실 해결방법은 생각보다는 간단합니다.

같은 버튼을 같은 트리에서 재사용하다보니 발생한 문제이기 때문에, 리액트에게 서로 다른 버튼이라는 것을 인지하도록 하거나, 렌더링될 트리의 위치를 바꿔주면 리액트가 더이상 요소를 재사용하지 않을 겁니다.

1. 다른 요소로 래핑하기

리액트는 렌더 트리를 비교할 때, 같은 트리에 위치한 요소의 타입이 다르다면 이를 재사용하지 않고 새로운 트리로 교체합니다.
버튼 요소를 다른 요소로 래핑하면, 래핑한 요소와 이전 버튼 요소의 타입이 다르기 때문에 이전에 사용한 버튼 요소를 재사용하지 않게 됩니다.

저는 fragment 를 사용했지만, 다른 요소를 사용해도 상관 없습니다.

<>
  <button>제출</button>
</>

2. key 전달

버튼에 별도의 key 를 전달해도 됩니다.
key 를 전달하게 되면, 요소마다 고유의 식별자를 얻게 되기 때문에 리액트가 렌더링을 할 때 이전에 사용한 버튼을 재사용하지 않게 할 수 있습니다.

step === 3 ? (
  <button key="submit">제출</button>
) : (
  <button key="notSubmit" onClick={onClick}>다음</button>
);

3. 다른 트리 위치에서 각각의 버튼을 렌더링하기

같은 트리 위치에서 같은 타입의 요소를 렌더링해서 발생한 문제이기 때문에, 각각의 버튼을 다른 위치에서 렌더링한다면 요소를 재사용하지 않습니다.

const renderNextButton = step !== 3 && <button onClick={onClick}>다음</button>;
const renderSubmitButton = step === 3 && <button>제출</button>;

<form>
  // 폼 컨텐츠
  {renderNextButton}
  {renderSubmitButton}	
</form>

전체 코드 링크

0개의 댓글