카테고리 없음

[공식문서 읽기]함수형 업데이트로 멀티 폼 유효성 검사를 한 번에 해보자.

아보카도 있었어! 2023. 9. 23. 12:53

🔗 https://react.dev/learn/queueing-a-series-of-state-updates 문서를 읽고 문제를 해결한 경험을 정리한다.

 

Queueing a Series of State Updates – React

The library for web and native user interfaces

react.dev

 

반복문 안에서 setState를 여러 번 호출할 경우 함수형 업데이트를 해야 원하는 결과를 얻을 수 있다.

 

문제 상황

submit 이벤트가 발생했을 때 유효성을 검사하는 회원가입 폼 구현하다가 state로 관리하고 있던 에러 메시지가 제대로 출력되지 않는 상황이 발생했다.

제출을 하면, 나머지 폼도 공백이라 에러메시지가 출력되어야 하는데 가장 마지막 유효성 폼인 github 주소 입력 란에만 에러메시지가 출력된다.

 

마지막 필수 입력 폼에만 에러메시지 출력되는 현상

 

원인 분석

각 폼의 에러 메시지를 관리하는 상태는 객체로 구현된다.

 

 
  const [errors, setErrors] = useState({
    email: "",
    password: "",
    password_confirm: "",
    private: "",
    phone: "",
    position: "",
    github: "",
  });
 

 

에러 메시지 상태를 관리하는 함수는 swith문으로 구현된다.

인수로 들어온 value 값의 공백 유무로 에러 메시지를 지정한다.

 

 
  const onValidate = (word, value) => {
    value = value.trim();
    switch (word) {
      case "email":
        setErrors({
          ...errors,
          email: value ? "" : "아이디는 필수 입력 값입니다.",
        });
        break;

      case "password":
        setErrors({
          ...errors,
          password: value ? "" : "비밀번호 설정은 필수 입력 값입니다.",
        });
        break;
      /* 외에 다른 유효성 검사 */
     /* ... */
      case "github":
        {
          setErrors({
            ...errors,
            github: value ? "" : "github 주소는 필수 입력 값입니다.",
          });
        }
        break;
    }
  };

 

폼 제출 이벤트가 발생하면 onValidate 함수가 폼 전체를 순회하여 실행된다.

 

 
  const handleSubmit = async (e) => {
    e.preventDefault();

    const formData = new FormData(e.currentTarget);

    for (let [name, value] of formData) {
        onValidate(name, value);
    }
 

 

onValidate 함수는 state 변경 함수를 실행하고, 해당 함수는 반복문 안에서 실행되고 있는 상황이 된다.

반복문 안에서 state update는 어떻게 이뤄질까?

🔗 https://react.dev/learn/queueing-a-series-of-state-updates

 

 

React batches state updates

| 리액트는 상태를 한 번에 모아서 업데이트한다.(일괄처리, batching)

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 1);
        setNumber(number + 1);
        setNumber(number + 1);
      }}>+3</button>
    </>
  )
}

 위와 같은 코드에서 우리는 다음 렌더링에서 counter가 3번 증가해 3이 될 것이라 예상하지만, 1이 렌더링된다.

이는 이벤트 핸들러 내부에서 값이 고정되는 것과 관련 있다. 👉 참고: https://react.dev/learn/state-as-a-snapshot

 

 React는 상태 업데이트를 처리하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 기다린다. 즉, onClick 이벤트 핸들러의 setNumber() 함수가 모두 호출된 이후에만 리렌더링이 일어나는 이유다.

(문서는 이를 식당에서 주문을 받는 웨이터로 비유한다.)

 

 이렇게 하면 여러 컴포넌트에서 여러 상태 변수를 업데이트해도 너무 많은 리렌더링을 트리거하지 않고도 업데이트할 수 있다는 장점이 있다. 하지만 이는 곧 이벤트 핸들러와 그 안에 있는 코드가 완료될 때까지는 UI가 업데이트되지 않는다는 의미이기도 하다. 이러한 React의 동작을 일괄처리, batching이라고 하는데, React 앱을 훨씬 빠르게 실행할 수 있게 해 준다. 일부 변수만 업데이트하는 '반조리된' 렌더링을 처리하지 않아도 된다는 이점도 있다.

 

 회원가입 폼에서 가장 마지막 폼의 에러메시지만 출력된 이유는 handleSubmit 핸들러 안에서 동일한 상태변수 Errors를 여러 번 변경하는 함수를 호출해도, 이벤트 핸들러가 호출되기 전 Errors로 값이 고정되어 있었기 때문이다.

 

그러면 동일한 상태 변수를 한 핸들러 안에서 여러 번 업데이트하고 싶다면 어떻게 해야 할까?

 

 

Updating the same state multiple times before the next render

|  함수형 업데이트는 일부 상태 변수를 여러 번 업데이트할 수 있다.

 

 다음 렌더링 전에 동일한 상태 변수를 여러 번 업데이트하고 싶을 때, 대기열의 이전 상태를 기반으로 다음 상태를 계산하는 함수를 state 변경 함수에 전달할 수 있다. 단순히 값을 바꾸는 것이 아니라 React에게 '상태'값으로 무언가를 하라고 지시하는 방법이다.

setNumber(n => n + 1)
import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(n => n + 1);
        setNumber(n => n + 1);
        setNumber(n => n + 1);
      }}>+3</button>
    </>
  )
}

 위와 같은 방식을 함수형 업데이트라고 하며, n => n + 1을 업데이터 함수라고 한다.

 

 업데이터 함수를 state 변경 함수의 인자로 전달할 때, 다음과 같은 일이 순차적으로 진행된다.

1. React는 이벤트 핸들러 내의 다른 모든 코드가 실행된 후에 업데이터 함수가 처리되도록 대기열에 추가한다.

2. 다음 렌더링 중에 대기열을 모두 실행하고, 최종 업데이트된 상태를 제공한다.

 

 이벤트 핸들러가 실행되는 동안 업데이터 함수를 만나면 React가 이를 대기열에 추가하는 방식은 다음과 같다.

setNumber(n => n + 1); // n => n + 1은 함수입니다. React는 이를 대기열에 추가합니다.
setNumber(n => n + 1); // n => n + 1은 함수입니다. React는 이를 대기열에 추가합니다.
setNumber(n => n + 1); // n => n + 1은 함수입니다. React는 이를 대기열에 추가합니다.

 이렇게 대기열에 추가됐으면, 다음 렌더링 중에 useState 호출 때 대기열이 실행된다. 이전 숫자 상태는 0이었으므로 React는 첫 번째 업데이터 함수에 인수 n으로 전달한 다음 이전 업데이터 함수의 반환 값을 가져와서 다음 업데이터 함수에 n으로 전달하는 식으로 진행된다. 이전 값을 활용해서 무언가를 하는 식이다.

 

 위 상태 변경 함수 호출 대기열의 실행을 표로 나타내면 다음과 같다.

 

queued update n returns
n => n + 1 0 0 + 1 = 1
n => n + 1 1 1 + 1 = 2
n => n + 1 2 2 + 1 = 3

 React는 3을 최종 결과로 저장하고, useState에서 반환한다. 이러한 과정으로 counter가 3으로 렌더링된다.

상태 변경 함수의 인자에 값을 전달하는 것과 업데이터 함수를 전달하는 것에는 어떤 차이가 있을까?

 

 

What happens if you replace state after updating it

| 상태 변경 함수에 값을 전달하는 방식으로 업데이트하면, 이전 반환 값을 활용하지 않는다

import { useState } from 'react';

export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    <>
      <h1>{number}</h1>
      <button onClick={() => {
        setNumber(number + 5);
        setNumber(n => n + 1);
        setNumber(42);
      }}>Increase the number</button>
    </>
  )
}

 위 코드의 실행 결과 number는 6으로, 상태 변경 함수에 값을 전달하는 것과 업데이터 함수를 전달하는 것의 차이를 보여준다.

setNumber(number + 5); // number는 0이므로 setNumber(0 + 5)가 됩니다. React는 대기열에 "5로 대체"를 추가합니다.
setNumber(n => n + 1); // n => n + 1은 업데이터 함수입니다. React는 해당 함수를 대기열에 추가합니다.
setNumber(42) // React는 대기열에 "42로 대체"를 추가합니다.

 

 위 상태 변경 함수 호출 대기열의 실행을 표로 나타내면 다음과 같다.

queued update n returns
“replace with 5” 0 (unused) 5
n => n + 1 5 5 + 1 = 6
“replace with 42” 6 (unused) 42

 React는 42를 최종 결과로 저장하고, useState에서 반환한다.

 

 값을 전달하여 업데이트하는 것과, 업데이터 함수를 전달하여 업데이트하는 것 모두 반환 값을 가진다.

하지만, 함수형 업데이트가 이전 업데이터 함수에서 받아온 인자 n의 값을 활용하는 것과 달리, 값을 대체하는 방법으로 상태를 업데이트하면, 이전 업데이터 함수에서 반환한 값과 상관없이 값이 대체되는 것을 확인할 수 있다. 이벤트 핸들러가 완료되면 React는 다시 렌더링은 트리거하여, 렌더링 중에 React는 대기열을 처리한다.

 

 업데이터 함수는 렌더링 중에 실행되므로 순수해야 하며, 결과만 반환해야 한다. 내부에서 상태를 설정하거나 다른 side effect을 실행하지 않도록 주의해야 한다. 엄격 모드에서 React는 각 업데이터 함수를 두 번 실행하여 실수를 찾을 수 있도록 도와준다.

 

 회원가입 폼에서 Errors의 상태 변경 함수를 실행하는 onValidate 함수는 상태 변경 함수에 값을 전달하는 방식으로 상태를 업데이트하고 있다. 지금까지 문서를 살펴본 결과 에러 메시지를 관리하는 Errors 상태에 가장 마지막 입력 폼의 메시지만 전달되어서 해당 폼에서만 에러메시지를 출력하고 있음을 도출할 수 있다.

 

문제 해결

 원인은 handleSubmit 이벤트 핸들러가 실행되기 전 Errors 상태 값이 고정된 상태에서 함수형이 아니라 에러 메시지의 값을 단순 전달했기 때문임을 알게 되었다. onVlidate의 상태 변경 함수마다 인자로 업데이터 함수를 전달해주면 예상처럼 동작하는 것을 확인할 수 있다.

 

이전 상태 변경 함수(값 전달)

setErrors({
              ...errors,
              email: value ? "" : "아이디는 필수 입력 값입니다.",
            });

 

수정 후 상태 변경 함수(업데이터 함수 전달)

setErrors((prev) => {
            return {
              ...prev,
              email: value ? "" : "아이디는 필수 입력 값입니다.",
            };
          });

 

문제 해결 코드

 


  const onValidate = (word, value) => {
    value = value.trim();
    switch (word) {
      case "email":
        {
          setErrors((prev) => {
            return {
              ...prev,
              email: value ? "" : "아이디는 필수 입력 값입니다.",
            };
          });
        }
        break;
      case "password":
        {
          setErrors((prev) => {
            return {
              ...prev,
              password: value ? "" : "비밀번호 설정은 필수 입력 값입니다.",
            };
          });
        }
        break;
      /* 외에 다른 유효성 검사 */
     /* ... */ 
      case "github":
        {
          setErrors((prev) => {
            return {
              ...prev,
              github: value ? "" : "github 주소는 필수 입력 값입니다.",
            };
          });
        }
        break;
    }
  };

 

 

구현 결과

공백 폼 모두 에러 메시지 출력

 

아직 구현되지 않은 비밀번호 재확인 유효성 검사 폼 외에는 정상적으로 에러 메시지를 출력하는 것을 확인할 수 있다.

 

배운 점 및 후기

 함수형 업데이트에 대해서는 원래 알고 있었던 개념이지만, 이번 기회에 공식문서를 한 번 더 정리하면서 이해에 깊이를 더할 수 있었다. 문서만 볼 때는 몰랐는데, 구현을 하면서 마주하게 되니 개념 숙지의 필요성을 알게 되었다. 또한, 자꾸 잊어버리기 쉬운 개념인 것 같기도 하다.

 이제 무언가 원하는대로 화면이 구현되지 않으면, batching에 관련된 디버깅을 시도해볼 수 있게 되었다. 여담으로, 이번에는 입력 input 값들을 모두 state로 관리하지 않고, 비제어 컴포넌트로 관리했는데, 확실히 state로 관리하는 것보다 DOM에 접근하기가 어려운 것 같다고 느꼈다. onChange, onBlur와 formData로 들어오는 DOM만 제어할 수 있다 보니, 바닐라 JS 처럼 querySelector 메서드 사용이 React의 핵심 아이디어인 가상 DOM과 충돌하는 것 등 생각해 봐야 할 부분이 많았다. 하지만, formData로 멀티 폼을 입력 받는 새로운 시도를 해볼 수 있어 좋았다.

 또, 예전에 처음 문서를 읽을 당시엔, 함수형 업데이트를 잘 이해하지 못했기 때문에 그냥 지나쳤던 업데이터 함수의 Naming 컨벤션도 알게 되었다. 이번 포스팅에서는 이전 업데이터 함수의 반환 값을 prev라는 이름의 동일한 인자로 받았지만, 다음 함수형 업데이트 상태 변경 함수를 작성할 때는 네이밍 컨벤션을 지켜서 작성해봐야겠다.

 한 가지 더, 아쉬운 점은 유효성 검사 switch문 코드의 중복이 너무 많고 길다는 것이다. 이 부분은 어떻게 작성해야 할지 아직 감이 오지 않지만, 이 또한 계속 개발을 하다보면 해결 방법을 알게 될 날이 올 것이라고 생각한다. 아쉽지만, 아직은 더 우선으로 공부해야할 것이 남아 있다.