✏️기록하는 즐거움
article thumbnail
반응형

🔍 기능 미리 보기

✨ 구현할 기능

  • +/- 버튼으로 수량 조절하기
  • 1개 보다 작거나, 재고 보다 큰 수량을 입력할 수 없게 예외처리 하기
  • <input>에 값을 입력하여 수량 조절하기

 

1️⃣ 버튼으로 수량 조절

자식 컴포넌트의 구성 요소인 버튼으로 수량을 조절하여 부모 컴포넌트가 가지고 있는 수량가격에 대한 상태 값을 변경하고 싶다면, 해당 상태들을 변경할 수 있는 함수를 자식 요소인 QuantityInput의 props로 전달하여 QuantityInput에 있는 button의 이벤트 핸들러로 추가해주면 된다.

// ProductInfo.jsx

export default function ProductInfo({ product }) {
  const [quantity, setQuantity] = useState(1);
  const [total, setTotal] = useState(product.price);

  const handleClickCounter = (num) => {
    setQuantity((prev) => prev + num);
    setTotal((prev) => prev + product.price * num);
  };

  return (
    <Wrapper>
        <!-- ... -->
        <!-- 상품의 수량을 조절할 수 있는 QuantityInput -->
        <!-- props로 총 수량, 총 가격의 상태를 변경할 수 있는 함수를 전달한다. -->
        <QuantityInput
          quantity={quantity}
          stock={product.stock}
          onClick={handleClickCounter}
        />
        <Price>
          <h3 className="a11y-hidden">총 상품 가격 정보</h3>
          <span>총 상품 금액</span>
          <div>
            <!-- QuantityInput에 의해 변경된 상태 값을 화면에 렌더링한다. -->
            <span>
              총 수량 <strong>{quantity}</strong>개
            </span>
            <span>
              <strong>{total.toLocaleString()}</strong>원
            </span>
          </div>
        </Price>

        <!-- ... -->
      </Wrapper>
  );
}
// QuantityInput.jsx

export default function QuantityInput({ stock, quantity, onClick }) {
  return (
    <Wrapper>
      <Counter>
        <button
          type="button"
          aria-label="수량 내리기"
          onClick={() => onClick(-1)}
        >
          <StyledMinusIcon />
        </button>
        <label>
          <span className="a11y-hidden">상품 주문 수량 입력란</span>
          <input
            type="number"
            min={1}
            value={quantity}
            max={stock}
            readOnly
          />
        </label>
        <button
          type="button"
          aria-label="수량 올리기"
          onClick={() => onClick(1)}
        >
          <StyledPlusIcon />
        </button>
      </Counter>
    </Wrapper>
  );
}

여기서 주의할 점은, 이벤트 핸들러에 전달되는 ProductInfo 컴포넌트의 handleClickCounter 함수의 setState()는 콜백 함수로 상태 값을 업데이트 해줘야 한다는 것이다.

state 값을 변경하는 setState()는 비동기로 동작하기 때문에 이벤트 핸들러 안에서 호출하게 되면 해당 상태가 즉각적으로 업데이트되지 않는다. setState는 이벤트 핸들러 안에서 현재 state의 값에 대한 변화만 요청하고, 해당 요청사항은 이벤트 핸들러가 종료되고 react에 의해서 효율적으로 상태가 갱신된다.

 

따라서 콜백 함수가 아닌 계산 값을 전달 후 콘솔로 확인해보면 다음과 같이 즉각적인 업데이트가 아님을 확인해볼 수 있다.

// ProductInfo.jsx

export default function ProductInfo({ product }) {
  const [quantity, setQuantity] = useState(1);
  const [total, setTotal] = useState(product.price);

  const handleClickCounter = (num) => {
    setQuantity((prev) => prev + num);

    // 콜백 함수 사용 X
       const totalPrice = product.price * quantity;
       setTotal(totalPrice);
       console.log(quantity, total);
  };

// ...

 

콜백 함수를 사용하여 업데이트하면 콜백 함수의 인자는 업데이트 이후의 값이 보장되어 업데이트 된 값을 가지고 작업을 할 수 있다. 즉, 아래와 같이 정상적으로 즉각 업데이트 함을 알 수 있다.

// ProductInfo.jsx

export default function ProductInfo({ product }) {
  const [quantity, setQuantity] = useState(1);
  const [total, setTotal] = useState(product.price);

  const handleClickCounter = (num) => {
    setQuantity((prev) => prev + num);

    // 콜백 함수 사용 O
    setTotal((prev) => prev + product.price * num);
    console.log(quantity, total);
  };

// ...

 

또한 현재 계속 변경되는 quantity 값을 input의 value 속성에서 사용하고 있는데, 이때 onChange 메서드를 사용하거나 readOnly 속성을 넣어주어야 리액트의 You provided a value prop to a form field without an onChange handler. This will render a read-only field. If the field should be mutable use defaultValue. Otherwise, set either onChange or readOnly. 경고를 해결할 수 있다.

 

만약 버튼으로만 수량을 조절하게 한다면 input의 값이 직접적으로 변경되서는 아니기 때문에 readOnly 속성을 추가하는 것이 더 알맞다고 생각했다.

 

2️⃣ 재고에 맞게 수량 조절 기능 제한하기

간단하게 button의 disabled 속성에 조건식에 따라 true / false로 동작하게끔 코드를 작성했다.

현재 QuantityInput에 해당 상품에 대한 stock(재고) 데이터를 props로 넘기고 있으므로, 아래와 같이 코드를 작성할 수 있다.

// QuantityInput.jsx
<Wrapper>
    <Counter>
        <button
            type="button"
            disabled={quantity === 1} // 선택한 수량에 따라 버튼 활성화 제어
            aria-label="수량 내리기"
            onClick={() => onClick(-1)}
            >
            <StyledMinusIcon />
        </button>
        <label>
            <span className="a11y-hidden">상품 주문 수량 입력란</span>
            <input
                type="number"
                min={1}
                value={quantity}
                max={stock}
                readOnly
                />
        </label>
        <button
            type="button"
            disabled={stock < 1 || stock === quantity // 재고에 따라 버튼 활성화 제어
            aria-label="수량 올리기"
            onClick={() => onClick(1)}
            >
            <StyledPlusIcon />
        </button>
    </Counter>
</Wrapper>

button의 disabled 속성에 간단한 표현식을 넣어주면 된다. - 버튼은 수량이 1일 경우 disabled 속성이 true가 되고, + 버튼의 경우 재고가 1보다 작거나 재고와 선택한 상품 수량이 같다면 true가 되어 더 이상 수량을 늘리지 못한다.

 

3️⃣ input에 직접 값을 입력하여 수량 조절하기

처음에는 버튼에 이벤트 핸들러를 전달한 것처럼 input의 onChange 이벤트에 핸들러를 전달하면 되겠다고 생각했다.

// ProductInfo.jsx
export default function ProductInfo({ product }) {
  const [quantity, setQuantity] = useState(1);
  const [total, setTotal] = useState(product.price);

  const handleClickCounter = (num) => {
    setQuantity((prev) => prev + num);
    setTotal((prev) => prev + product.price * num);
  };

  const handleChangeInput = (e) => {
    const newQuantity = parseInt(e.target.value);
    setQuantity(newQuantity);
    setTotal(product.price * newQuantity);
  };

// ...

    return (
        <Wrapper>
         <!-- ... -->
            <QuantityInput
                quantity={quantity}
                stock={product.stock}
                onClick={handleClickCounter}
                onChange={handleChangeInput}
             />
// QuantityInput.jsx
export default function QuantityInput({
  stock,
  quantity,
  onClick,
  onChange,
}) {
  return (
    <Wrapper>
      <Counter>
        <!-- ... -->
          <input
            type="number"
            min={1}
            value={quantity}
            max={stock}
            onChange={onChange}
          />
        <!-- ... -->
      </Counter>
    </Wrapper>
  );
}

하지만 React에서 input의 change 이벤트는 JavaScript에서의 change 이벤트 와는 다르게 동작한다.

JavaScript에서 change 이벤트의 경우 input의 타입에 따라 다르게 동작한다. checkbox나 radio 같은 사용자 상호작용이 값 선택인 것 말고 타이핑이 필요한 타입의 경우, 요소의 값이 바뀐 뒤 포커스를 상실했을 때 이벤트가 발생한다.

 

공식 문서에서 React의 change 이벤트의 경우 입력 값이 변경되면 즉시 실행된다고 설명되어 있다.

 

따라서 위와 같이 onChange로 변경된 값을 그대로 화면에 보여주게 되면 값이 비어있을 때 화면에 NaN이 보이거나, 재고를 체크하여 버튼을 disabled로 변경해줄 때 생각치 못한 오류가 발생하게 된다.

 

React에서 값이 입력되었을 때 이벤트가 발생하는 것이 아닌 focusout 되었을 때 이벤트가 발생하게 하려면 onBlur 이벤트를 사용하면 된다.

현재 원하는 동작은 input이 포커스를 상실할 때 input에 있는 값이 총 수량과 총 금액에 반영되게 하는 것이다.

따라서 input에서 입력 값이 변경되었을 경우 해당 값을 상태로 저장하고, 포커스를 상실할 때 부모 컴포넌트(ProductInfo)의 상태 값인 quantity(총 수량), total(총 금액)이 변경되게 하면 된다.

코드를 수정해보면 아래와 같다.

// ProductInfo.jsx

export default function ProductInfo({ product }) {
  const [quantity, setQuantity] = useState(1);
  const [total, setTotal] = useState(product.price);

  const handleClickCounter = (num) => {
    setQuantity((prev) => prev + num);
    setTotal((prev) => prev + product.price * num);
  };

  // input이 포커스를 상실했을 때 상태값을 업데이트하기 위한 이벤트 핸들러 함수
  const handleBlurInput = (quantity) => {
    const newQuantity = quantity;
    setQuantity(newQuantity);
    setTotal(product.price * newQuantity);
  };

  return (
    <Wrapper>
      <!-- ... -->
      <!-- onBlur에 이벤트 핸들러 함수 전달 -->    
        <QuantityInput
          quantity={quantity}
          stock={product.stock}
          onClick={handleClickCounter}
          onBlur={handleBlurInput}
        />
        <!-- ... -->
    </Wrapper>
  );
}
// QuantityInput.jsx

export default function QuantityInput({ stock, quantity, onClick, onBlur }) {
  // input에 입력되는 값을 상태로 관리한다.
  const [value, setValue] = useState(quantity);

  // input에 입력 값이 변경되면 상태 값을 업데이트 한다.
  const handleChangeInput = (e) => {
    const newValue = parseInt(e.target.value);

    // NaN이 입력되거나 1보다 작은 값이 입력될 경우 기본 값인 1로 업데이트
    if (isNaN(newValue) || newValue < 1) {
      setValue(1);
    } // 정상적인 값이 입력될 경우 해당 값으로 업데이트
      else {
      setValue(newValue);
    }
  };

  // input이 포커스를 상실했을 때 부모 컴포넌트의 상태 값을 변경하는 핸들러 호출
  const handleBlurInput = (e) => {
    let newValue = parseInt(e.target.value);

    // 재고보다 많은 값이 입력되어 저장되었을 경우 실행 
    if (stock < newValue) {
      alert(`${stock}개 이하로 구매하실 수 있습니다.`);
      newValue = stock;
    }

    setValue(newValue);
    onBlur(newValue);
  };

  // 기존에 quantity를 props로 전달받아 사용했던 부분을 QuantityInput에서 관리하는 상태 값인 value로 수정한다.
  return (
    <Wrapper>
      <Counter>
        <button
          type="button"
          disabled={value === 1}
          aria-label="수량 내리기"
          onClick={() => onClick(-1)}
        >
          <StyledMinusIcon />
        </button>
        <label>
          <span className="a11y-hidden">상품 주문 수량 입력란</span>
          <input
            type="number"
            min={1}
            value={value}
            max={stock}
            onChange={handleChangeInput}
            onBlur={handleBlurInput}
          />
        </label>
        <button
          type="button"
          disabled={stock < 1 || stock === value}
          aria-label="수량 올리기"
          onClick={() => onClick(1)}
        >
          <StyledPlusIcon />
        </button>
      </Counter>
    </Wrapper>
  );
}

 

이 때 handleBlurInput에서도 setValue()가 비동기 함수로 동작하기 때문에 if문 안에서 setValue()로 value를 재고 값으로 변경하고 콘솔로 확인해보면 재고 값(63)으로 업데이트된 값이 아닌 이전 입력 값(99999)이 출력되는 것을 볼 수 있다.

const handleBlurInput = (e) => {
    const newValue = parseInt(e.target.value);

    // 재고보다 많은 값이 입력되어 저장되었을 경우 실행 
    if (stock < newValue) {
        alert(`${stock}개 이하로 구매하실 수 있습니다.`);
        setValue(stock);
    }

    console.log(value);
    onBlur(newValue);
};

 

따라서 아래와 같이 비동기에 영향을 받지 않는 변수를 직접적으로 전달하는 방식으로 해결할 수 있다.

const handleBlurInput = (e) => {
    let newValue = parseInt(e.target.value);

    // 재고보다 많은 값이 입력되어 저장되었을 경우 실행 
    if (stock < newValue) {
        alert(`${stock}개 이하로 구매하실 수 있습니다.`);
        newValue = stock;
    }

    setValue(newValue);
    onBlur(newValue);
};

 

💡 완성 코드

// ProductInfo.jsx
import React, { useState } from "react";
import QuantityInput from "../../Input/QuantityInput/QuantityInput";
import {
  ActionButtons,
  Delivery,
  ImgWrapper,
  Info,
  Price,
  ProductMain,
  Wrapper,
} from "./ProductInfoStyle";
import Button from "../../Button/Button/Button";

export default function ProductInfo({ product }) {
  const [quantity, setQuantity] = useState(1);
  const [total, setTotal] = useState(product.price);

  const handleClickCounter = (num) => {
    setQuantity((prev) => prev + num);
    setTotal((prev) => prev + product.price * num);
  };

  const handleBlurInput = (quantity) => {
    const newQuantity = quantity;
    setQuantity(newQuantity);
    setTotal(product.price * newQuantity);
  };

  return (
    <Wrapper>
      <h2 className="a11y-hidden">상품 상세 페이지</h2>
      <ImgWrapper>
        <img src={product.image} alt={`${product.product_name} 상품 이미지`} />
      </ImgWrapper>
      <form>
        <ProductMain>
          <h3 className="a11y-hidden">상품 기본 정보</h3>
          <Info>
            <span>{product.store_name}</span>
            <h4>{product.product_name}</h4>
            <span>
              <strong>{product.price.toLocaleString()}</strong>원
            </span>
          </Info>
          <Delivery>
            <span>택배배송</span>
            <span>
              {product.shipping_fee ? `${product.shipping_fee}원` : "무료배송"}
            </span>
          </Delivery>
        </ProductMain>
        <QuantityInput
          quantity={quantity}
          stock={product.stock}
          onClick={handleClickCounter}
          onBlur={handleBlurInput}
        />
        <Price>
          <h3 className="a11y-hidden">총 상품 가격 정보</h3>
          <span>총 상품 금액</span>
          <div>
            <span>
              총 수량 <strong>{quantity}</strong>개
            </span>
            <span>
              <strong>{total.toLocaleString()}</strong>원
            </span>
          </div>
        </Price>

        <ActionButtons>
          <Button size={"md"}>바로 구매</Button>
          <Button size={"md"} variant={"dark"}>
            장바구니
          </Button>
        </ActionButtons>
      </form>
    </Wrapper>
  );
}
// QuantityInput.jsx
import React, { useEffect, useState } from "react";
import {
  Counter,
  StyledMinusIcon,
  StyledPlusIcon,
  Wrapper,
} from "./QuntityInputStyle";

export default function QuantityInput({ stock, quantity, onClick, onBlur }) {
  const [value, setValue] = useState(quantity);

  const handleChangeInput = (e) => {
    const newValue = parseInt(e.target.value);

    if (isNaN(newValue) || newValue < 1) {
      setValue(1);
    } else {
      setValue(newValue);
    }
  };

  const handleBlurInput = (e) => {
    let newValue = parseInt(e.target.value);

    if (stock < newValue) {
      alert(`${stock}개 이하로 구매하실 수 있습니다.`);
      newValue = stock;
    }

    setValue(newValue);
    onBlur(newValue);
  };

  return (
    <Wrapper>
      <Counter>
        <button
          type="button"
          disabled={value === 1}
          aria-label="수량 내리기"
          onClick={() => onClick(-1)}
        >
          <StyledMinusIcon />
        </button>
        <label>
          <span className="a11y-hidden">상품 주문 수량 입력란</span>
          <input
            type="number"
            min={1}
            value={value}
            max={stock}
            onChange={handleChangeInput}
            onBlur={handleBlurInput}
          />
        </label>
        <button
          type="button"
          disabled={stock < 1 || stock === value}
          aria-label="수량 올리기"
          onClick={() => onClick(1)}
        >
          <StyledPlusIcon />
        </button>
      </Counter>
    </Wrapper>
  );
}

 

Reference

https://velog.io/@kym123123/%EB%B9%84%EB%8F%99%EA%B8%B0%EB%A1%9C-%EB%8F%99%EC%9E%91%ED%95%98%EB%8A%94-react%EC%9D%98-setState%EC%97%90-%EB%8C%80%ED%95%98%EC%97%AC

https://developer.mozilla.org/ko/docs/Web/API/HTMLElement/change_event

https://react.dev/reference/react-dom/components/input#props

반응형
profile

✏️기록하는 즐거움

@nor_coding

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!