- #Project
- #Burgerput
react-hook-form에 rc-slider 곁들이기..
Prolip
2024-06-16
react-hook-form에 rc-slider 곁들이기.. 짱 멋진 슬라이더
시작..
이전 포스팅에서 react-hook-form을 활용해 제 끔찍한 코드를 어떻게 개선했는지 기록했습니다..
이번 포스팅에선 react-hook-form과 rc-slider를 어떻게 함께 사용했는지 기록해보려고 합니다..
rc-slider
우선! 이 라이브러리를 왜 사용했는가! 입니다.
버거풋엔 숨겨진 기능이 존재합니다.. 바로 사용자가 지정한 최소, 최대 온도 범위 내에서 랜덤으로 온도 값을 생성해 제출하는 기능입니다.
버거풋을 이용하는 정석적인 방법은 이전 포스팅에서 설명한 입력 폼을 사용해 온도를 하나씩 입력하는 방법입니다.
아니 랜덤 생성이라니요?? 탄생 배경은.. 우리 페이지를 배포하고 매장에 확인차 방문했을 때로 돌아갑니다..
prolip: 안녕하세요 매니저님 버거풋 잘 이용하고 계신가요?
익명의 매니저: 잘 쓰고 있어요 그런데 혹시 기능 하나만 추가해주시면 안돼요?
prolip: 어떤 기능이요?? (아싸 뭐 만들지?)
익명의 매니저: 버튼 누르면 온도가 알아서 입력 됐으면 좋겠어요.
prolip: (???)
네.. 딸깍을 만들어달라고 하셨습니다.
각 product별로 min, max 값이 존재하니 어쨌든 이 min, max 사이에서 랜덤한 값을 생성하면 돼서 사실 별 문제가 되진 않았습니다.
하지만 여기서 고려해야될 사항이 하나 존재했는데 예를 하나 들어보겠습니다.
{ "customMachine": [ { "id": "5", "name": "무언가1", "min": "46", "max": "66" }, ... ], }
저 무언가1의 온도는 46 이상 66 이하의 값이어야만 합니다.
그런데.. 매장 특성상 이 온도가 절대 55 이상으로는 측정될 수 없는 상황이 발생하게 됩니다.
이게 무슨 뜻이냐면 기기와 매장 특성상 온도가 55 이상으로 절대 나올 수 없으나 이 min, max에 의거해 랜덤 값을 생성하면 55 이상의 값이 생성될 수 있다는 것입니다.
아! 그럼 그냥 max 값에서 다 10 빼버리면 되잖아요? 하실 수 있는데 그 20개 정도의 항목 중에 3개, 4개 정도만 해당되어 그렇게 구현하기도 어려웠습니다.
그래서 각 항목별로 사용자가 직접 최소, 최대 온도 범위를 커스터마이징하고 이 범위를 저장해 랜덤 생성에 사용하면 되겠다 싶었습니다.
그래서 처음엔 최소, 최대 온도를 직접 기입하는 방법을 생각했으나.. 멋이 없었습니다.
네! 멋있는 거 만들고 싶었습니다!
그리고 편의성면에서 좋지 않았습니다. 각 항목별로 최소, 최대 온도를 기입하려면 사용자는 최소 40개의 항목에 온도를 기입해야됩니다.
하지만 이 슬라이더 형식은 간단하게 잡아다 끌면 끝이기에 슬라이더 방식을 사용하게 됩니다.
이 라이브러리의 주요 특징은 다음과 같습니다.
- 범위 설정: 슬라이더의 최소값과 최대값을 설정할 수 있고, 사용자는 이 범위 내에서 값을 선택할 수 있습니다.
- 마크 기능: 슬라이더 트랙에 특정 지점들을 표시할 수 있습니다. 예를 들어 최소값이 0, 최대값이 100인 슬라이더가 있을 때, 25, 50, 75 등 중간 중간에 마커 표시가 가능합니다.
- Draggable: 슬라이더라는 이름에 맞게 사용자는 슬라이더 핸들을 마우스로 드래그해 값을 변경할 수 있습니다 .이 때 이벤트 핸들러를 통해 값을 동적으로 관리할 수 있습니다.
- onChange: 사용자가 슬라이더를 움직일 때 발생하는 이벤트로 슬라이더의 현재 값을 반환합니다.
- onAfterChange: 사용자가 슬라이더를 움직이고 최종적으로 손을 뗄 때 발생하는 이벤트입니다.
- 커스터마이징: 라이브러리 없이 양방향 슬라이더를 구현하려면 CSS 설정하기 여간 귀찮은게 아닙니다. 하지만 해당 라이브러리는 간편하게 커스텀 가능합니다.
- 다중 핸들 슬라이더: 하나의 슬라이더에 여러 개의 핸들 추가가 가능합니다.
기본적인 사용법이라고 할 거 없이 사실 사용법 자체는 단순해서 여기까지만 하겠습니다..
useFieldArray
useFieldArray는 react-hook-form에서 제공하는 훅으로 배열 형태의 폼 필드를 동적으로 관리하는 기능을 제공합니다.
특히 폼 필드에 요소를 추가하거나 제거할 수 있는 리스트 형태의 필드를 다룰 때 매우 유용한데 사실 전 그런 기능은 안 쓰고 각 배열 항목을 독립적인 폼 필드로 동작하게 해주는 기능 때문에 사용했습니다.
주요 기능은 다음과 같은데
- 동적 배열 필드 관리
- 배열 형태의 폼 필드를 동적으로 추가하거나 제거하는 경우 useFieldArray는 해당 필드의 상태를 react-hook-form과 자동으로 동기화합니다.
- 배열 항목의 추가(append), 삭제(remove) 등 동적으로 폼 필드를 관리할 수 있는 메서드를 제공합니다.
- 성능 최적화
- 위에서 설명했듯 배열을 동적으로 관리할 수 있는 메서드를 제공하는데 이를 통해 배열 항목의 상태가 변경되면 폼의 전체를 렌더링하는 것이 아닌 변경된 부분만 다시 렌더링하도록 최적화되어 있습니다.
- 배열 항목의 상태를 보존합니다.
- 배열 항목을 추가하거나 삭제할 때 기존 항목의 폼 상태가 유지됩니다. 리액트의 key와 연동되어 항목이 추가되거나 삭제될 때 각 항목의 상태가 유지됩니다.
일단 보기..
어떻게 구현했는지 코드를 먼저 보여드리고 정리해볼까 합니다..
어떤 형식의 데이터를 받아올까요??
[ { "id": "2", "name": "무언가1", "min": "31", "max": "33", "initMin": "31", "initMax": "33" }, { "id": "54", "name": "무언가2", "min": "31", "max": "33", "initMin": "31", "initMax": "33" }, ... ]
기존의 product 객체와 약간 다르죠?
제가 생각한 방법은 다음과 같았습니다.
- 각 제품은 고유의 최소, 최대 온도 값을 가집니다 ⇒ initMin, initMax로 관리.
- 각 제품별로 사용자가 커스터마이징한 최소, 최대 온도 값을 가집니다 ⇒ min, max
그래서 데이터 객체는 위와 같이 정의되었습니다.
RandomTempForm
export default function RandomTempForm({ products, onSaveRandomRange, onSubmitRandomRange, }) { const { handleSubmit, control, setValue } = useForm({ mode: "onSubmit", values: { products: products.map((product) => ({ ...product, initMin: Number(product.initMin), initMax: Number(product.initMax), min: Number(product.min), max: Number(product.max), })), }, }); const { generateRandomTemp } = useGenerateRandomTemp(); const onSaveTemp = (formData) => { const products = formData.products; onSaveRandomRange(products); }; const onSubmitTemp = (formData, state) => { const products = generateRandomTemp(formData.products); onSubmitRandomRange(products, state); }; const { fields } = useFieldArray({ control, name: "products", }); return ( <form> <ul> {fields.map((product, idx) => ( <li key={idx}> <RandomTemp idx={idx} product={product} control={control} setValue={setValue} /> </li> ))} </ul> <section> <button type="button" onClick={handleSubmit(onSaveTemp)}> 범위 저장 </button> <button type="button" onClick={handleSubmit((formData) => onSubmitTemp(formData, "AM"))} > 오전 제출 </button> <button type="button" onClick={handleSubmit((formData) => onSubmitTemp(formData, "PM"))} > 오후 제출 </button> </section> </form> ); }
일단 이 코드는 폼을 관리하는 최상위 컴포넌트로 랜덤 제출 페이지의 폼 컴포넌트입니다.
해당 페이지에서 사용자가 슬라이더를 통해 변경한 최소, 최대 온도 값을 저장하고 제출하게 됩니다.
전체적인 동작 흐름을 살펴보면 다음과 같습니다.
- RandomTempFrom 컴포넌트가 렌더링되면서 useForm에서 정의한 defaultValues 옵션을 통해 products 배열이 폼의 초기값으로 설정됩니다.
- useForm 훅에서 생성된 control 객체와 배열 필드 이름(products)을 전달해 초기화 합니다. 이 때 fields 배열은products 배열을 기반으로 생성되며 useFieldArray는 products 배열 항목들을 폼 필드로 변환해 관리하게 되는데 각 배열 항목이 독립적인 폼 필드로 동작합니다.
- 이 부분이 제가 코드를 작성하는데 가장 중요한 부분이었습니다.. 처음엔 이 useFieldArray를 사용하지 않고 단순히 받아온 products 배열을 순회했는데 폼 필드의 변화가 react-hook-form의 내부 상태와 동기화되지 않아 아주 속이 타들어갔습니다.
- useFieldArray는 각 배열 항목을 고유한 id로 관리하게 되는데 이는 리액트의 key 시스템과 연동됩니다. 그렇기에 배열 항목을 추가하거나 삭제할 때 리액트가 해당 항목을 정확히 추적하고 상태를 유지하게 됩니다.
- fields 배열을 순회해 각 제품의 온도 범위를 설정할 수 있는 RandomTemp 컴포넌트를 렌더링합니다.
RandomTemp
export function useRandomTemp({ idx, control, setValue }) { const [isDisabled, setIsDisabled] = useState(false); const prevTemp = useRef([]); const minTempKey = `products[${idx}].min`; const maxTempKey = `products[${idx}].max`; const currentMin = useWatch({ control, name: minTempKey }); const currentMax = useWatch({ control, name: maxTempKey }); const handleDisabled = () => { setIsDisabled((prev) => { const newDisabledState = !prev; if (newDisabledState) { prevTemp.current = { currentMin, currentMax }; setValue(minTempKey, 999); setValue(maxTempKey, 999); } else { setValue(minTempKey, prevTemp.current.currentMin); setValue(maxTempKey, prevTemp.current.currentMax); } return newDisabledState; }); }; const handleChange = (value) => { setValue(minTempKey, value[0]); setValue(maxTempKey, value[1]); }; return { isDisabled, currentMin, currentMax, handleDisabled, handleChange, }; } export default function RandomTemp({ idx, product, control, setValue }) { const { initMax, initMin, name } = product; const { isDisabled, currentMin, currentMax, handleDisabled, handleChange } = useRandomTemp({ idx, setValue, control }); return ( <article> <section> <p>{name}</p> <p> ({initMin} ~ {initMax} ºF) <button type="button" onClick={handleDisabled} > 결품 </button> </p> </section> <section> <p>{currentMin} ºF</p> <Slider range disabled={isDisabled} value={[currentMin, currentMax]} onChange={handleChange} min={initMin} max={initMax} allowCross={false} /> <p>{currentMax} ºF</p> </section> </article> ); }
해당 컴포넌트는 개별 제품에 대한 슬라이더와 상태를 관리하는 컴포넌트입니다.
각 제품의 최소 온도(min)와 최대 온도(max) 값을 슬라이더로 조작할 수 있습니다.
- 슬라이더의 onChange 함수에서 값이 변경되면 각 배열의 idx에 해당하는 객체의 최소, 최대 온도 값을 setValue로 동기화합니다.
- 변경된 최소, 최대 값은 useWatch를 사용해 추적합니다. 해당 값은 결품 버튼을 클릭했을 때 ref 값에 저장해두고 이후 결품 상태를 해제할 때 다시 사용됩니다.
랜덤한 온도값 생성하기
export function useGenerateRandomTemp() { const generateRandomTemp = (products) => { const randProducts = []; products.forEach(({ id, name, min, max }) => { const temp = rand(Number(min), Number(max)); randProducts.push({ id, name, temp }); }); return randProducts; }; const rand = (min, max) => { const random = Math.floor(Math.random() * (max - min + 1)) + min; return random; }; return { generateRandomTemp }; }
이제 해당 커스텀 훅을 사용해 랜덤한 온도 값이 담긴 배열을 리턴해 제출하게 됩니다.
짜잔
이제 슬라이더를 잡아다 끌면 드르륵 드르륵 멋있게 바뀝니다.
범위를 저장하면 해당 범위를 저장해두고 제출에 계속 쓸 수 있게 됩니다.
실제로 해당 기능을 배포한 뒤 매장에 방문했을 때 만족도가 2000% 증가한 매니저님을 만나뵐 수 있었습니다..
마치며
사실 이 기능은 꼼수..입니다.. 그래서 이 기능이 평상시엔 보이지 않고 특정 동작을 수행하면 나타나도록 숨겨놓았습니다..
뭐 저는 어차피 그만두기도 했고 사용자의 요구사항에 맞게 기능을 만들어주면 그게 좋은 거 아니겠습니까?
ㅋㅋ
.
.
.
뿅..