ET네 만물상 - GitHub Repository / 배포 링크
import { useEffect, useState } from "react";
const DEFAULT_DELAY = 500;
export default <T>(value: T, delay: number = DEFAULT_DELAY) => {
const [debouncedValue, setDebounceValue] = useState(value);
useEffect(() => {
const handler: NodeJS.Timeout = setTimeout(() => {
setDebounceValue(value);
}, delay);
return () => {
clearTimeout(handler);
};
}, [value, delay]);
return debouncedValue;
};
디바운스는 같은 요청이 반복적으로 발생할 때, 그 간격이 지정한 시간보다 길 때 까지 요청을 유예하는 것"** 이라고 요약할 수 있다.
이 useDebounce 외부에서 빠르게 변하는 값, 가령 클릭시 변경되는 값을 인자로 받는다. 함수 내부의 useEffect의 의존성 배열에 추가함으로써 해당 값이 변경되면 useEffect를 실행하도록 하며, useEffect가 실행되면 새로운 timer로 debounce값을 set하고, 기존 timer를 clear한다
외부에서는 이 debouncedValue가 변경되면 감지하는 useEffect를 사용해서 debounce값이 변경되었을 때에만 의도한 동작을 수행하도록 하면 됨!
이 경우 useEffect로 debouncedValue 값이 변경되었을 때 동작한다는 원리인데, 가장 큰 문제는 내가 의도한 동작(예를 들어 클릭)이 아닌데 지정값을 변경시키는 경우가 있다면 의도치 않게 useEffect가 동작하곤 했다.
특히 react-query에서 받아오는 값을 default 값으로 지정하려 할 때 문제가 생겼다.
//1
const {
status,
data: product,
error,
refetch,
} = useProduct(parseInt(productId));
const [isMyWish, setIsMyWish] = useState(product?.isWish);
const debounceIsMyWish = useDebounce<boolean>(isMyWish, 300);
//2
const handleClickWish = async (e: Event) => {
e.stopPropagation();
if (!isLoggedin) {
return;
}
setIsMyWish((isMyWish) => !isMyWish);
};
//3
useEffect(() => {
if (product) {
setIsMyWish(product?.isWish);
}
}, [product]);
//4
useDidMountEffect(async () => {
if (debounceIsMyWish !== product.isWish) {
debounceIsMyWish
? await postWishProduct(product.id)
: await deleteWishProduct(product.id);
}
}, [debounceIsMyWish]);
react-query는 초기에 loading status, 데이터를 받아오면 success status가 되어 자동으로 컴포넌트를 최소 1번 리렌더시킨다. useProduct로 가져온 product 값이 초기엔 undefined이기 때문에 product안에 있는 isWish 값을 default로하는 값을 상태로 관리하고 싶다면, //3 에 있는 useEffect로 초기화를 해줘야한다.
이 때, debounceIsMyWish도 초기 undefined에서 product.isWish 값으로 변경됨으로 인지되기 때문에 //4 의 useDidMountEffect가 실행되어버린다
참고로 useDidMountEffect는 첫 렌더링을 무시하는 useEffect 커스텀 hooks이다.
실제 프로젝트에서는 조건문을 추가해서 클릭이 아닌 페이지 렌더시에 API 요청이 수행되는 걸 막았으나, 좀 더 개선이 필요해보인다.
import { useEffect, useState } from "react";
const DEFAULT_DELAY = 500;
export default <T>(value: T, delay: number = DEFAULT_DELAY) => {
const [throttledValue, setThrottleValue] = useState(value);
const [isWaiting, setIsWaiting] = useState(false);
useEffect(() => {
if (!isWaiting) {
const handler: NodeJS.Timeout = setTimeout(() => {
setIsWaiting(false);
clearTimeout(handler);
}, delay);
setThrottleValue(value);
setIsWaiting(true);
}
}, [value]);
return throttledValue;
};
쓰로틀링은 디바운스와 비슷하면서도 다른데, 빠르게 반복되는 요청 중 지정한 시간에 최소 1번은 실행하는 것이다 .
useDebounce와 마찬가지로 외부에서 변경되는 값을 받아서 자체 state인 throttledValue를 생성하고, useEffect로 관리한다.
차이점은 isWaiting이라는 boolean 상태가 추가되어 지정한 시간이 될 때 마다 isWaiting이 true일 때만 throttledValue를 변경한다.
const AutoList = ({ keyword, handleSearch }) => {
const handleClick = (v) => {
handleSearch(v);
};
const throttledSearchInput = useThrottle<string>(keyword, 200);
const { data: autoList, status } = useKeywords(throttledSearchInput);
...
return
}
useDebounce 설명에서는 react-query와 충돌이 발생할 수 있다 설명했지만, 반대로 hooks의 값을 useQuery의 인자로 사용할 때에는 아주 찰떡인 거 같다
useQuery가 해당파라미터를 key로 가지고 있기 때문에 캐싱처리가 되기 때문이다! throttledValue가 변하지 않는다면 useQuery는 캐싱된 값을 반환할 것이며 불필요한 요청을 하지 않는다.
export type InputType = {
value: string;
onChange: ({
target,
}: {
target: HTMLInputElement | HTMLTextAreaElement;
}) => void;
setValue: React.Dispatch<React.SetStateAction<string>>;
};
export default (
defaultValue: string,
filter?: (text: string) => string
): InputType => {
const [value, setValue] = useState(
filter ? filter(defaultValue) : defaultValue
);
const onChange = ({
target,
}: {
target: HTMLInputElement | HTMLTextAreaElement;
}) => {
const { value } = target;
setValue(filter ? filter(target.value) : value);
};
return { value, onChange, setValue };
};
default값을 기반으로 상태로 관리하며 이벤트 객체에서 target.value를 추출, setState한다.
input tag의 value값과 onChange 속성에 넣어두어 사용할 수 있다.
const addressDetail = useInput(defaultAddress?.detailAddress ?? "");
...
<input
placeholder="상세주소 입력"
defaultValue={addressDetail.value}
disabled={!address.address}
onChange={addressDetail.onChange}
/>
import { useState } from "react";
export type ValidationType = {
isValid: boolean;
onCheck: (input: string) => void;
setIsValid: (boolean) => void;
};
export default (
checkValidation: (input: string) => boolean,
defaultValue: boolean = null
): ValidationType => {
const [isValid, setIsValid] = useState<boolean>(defaultValue);
const onCheck = (input: string) => {
setIsValid(checkValidation(input));
};
return { isValid, onCheck, setIsValid };
};
유효성 검사 대상 값과, 유효성 검사 함수를 전달 받은 후 유효성 boolean / 체크 함수 등을 반환한다.
어디서든 원하는 시점에 check를 하고, isValid로 대상이 유효한지 판단할 수 있다
강제로 유효성 값을 변경시킬 수도 있다!
import { useEffect, useRef } from "react";
const THRESHOLD = 0.05;
export default (action: () => void, threshold: number = THRESHOLD) => {
const ref = useRef(null);
useEffect(() => {
if (ref.current) {
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && ref.current) {
observer.unobserve(ref.current);
action();
}
});
},
{ threshold }
);
observer.observe(ref.current);
return () => observer.disconnect();
}
}, [ref.current]);
return { ref };
};
intersectionObserver를 사용한다.
viewport에 나타나는 걸 감지할 dom이 필요한데, useRef를 사용한다.
intersection이 감지되면 실행할 함수를 전달받고, 감지 대상인 Dom에 지정할 ref를 반환해서 외부에서 해당 반환값을 대상 dom의 ref속성에 넣으면 된다!
import { useEffect, useRef } from "react";
export default (func, deps) => {
const didMount = useRef(false);
useEffect(() => {
if (didMount.current) func();
else didMount.current = true;
}, deps);
};
ref를 사용해서 변경되어도 리렌더를 일으키지 않는 값으로 boolean을 설정한다
첫 렌더 때만 default 값인 false로 useEffect 동작을 무시하면서 ref값을 true로 바꾼다
두 번째 렌더부터는 useEffect가 계속 동작한다.