React use 라이브러리는 react에서 유용한 hook들을 사용 할 수 있게 해주는 라이브러리이다.
공식문서 링크는 다음을 참고한다 : https://www.reactuse.com/
많이 사용하는 것 정리와 더불어 구현원리를 알아보기 위해 코드를 공부해보자.
공식문서에서는 4가지 카테고리 (State, Effect, Element, Browser)로 나눠서 hook들을 나눠놨다.
const [cookieValue, updateCookie, refreshCookie] = useCookie(
cookieName,
defaultOption,
"default-value"
);
내부코드는 다음과 같다
로직은 어려운건 없고, 특이한점은 쿠키관련 값을 다룰 때 js-cookie 라이브러리를 사용하고, string 값인지 check한다는 점
import { useCallback, useEffect, useState } from "react";
import Cookies from "js-cookie";
import { isBrowser, isFunction, isString } from "../utils/is";
import { defaultOptions } from "../utils/defaults";
import type { UseCookie, UseCookieState } from "./interface";
const getInitialState = (key: string, defaultValue?: string) => {
// Prevent a React hydration mismatch when a default value is provided.
if (defaultValue !== undefined) {
return defaultValue;
}
if (isBrowser) {
return Cookies.get(key);
}
if (process.env.NODE_ENV !== "production") {
console.warn(
"`useCookie` When server side rendering, defaultValue should be defined to prevent a hydration mismatches.",
);
}
return "";
};
export const useCookie: UseCookie = (
key: string,
options: Cookies.CookieAttributes = defaultOptions,
defaultValue?: string,
) => {
const [cookieValue, setCookieValue] = useState<UseCookieState>(
getInitialState(key, defaultValue),
);
useEffect(() => {
const getStoredValue = () => {
const raw = Cookies.get(key);
if (raw !== undefined && raw !== null) {
return raw;
}
else {
if (defaultValue === undefined) {
Cookies.remove(key);
}
else {
Cookies.set(key, defaultValue, options);
}
return defaultValue;
}
};
setCookieValue(getStoredValue());
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [defaultValue, key, JSON.stringify(options)]);
const updateCookie = useCallback(
(
newValue: UseCookieState | ((prevState: UseCookieState) => UseCookieState),
) => {
const value = isFunction(newValue) ? newValue(cookieValue) : newValue;
if (value === undefined) {
Cookies.remove(key);
}
else {
Cookies.set(key, value, options);
}
setCookieValue(value);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[key, cookieValue, JSON.stringify(options)],
);
const refreshCookie = useCallback(() => {
const cookieValue = Cookies.get(key);
if (isString(cookieValue)) {
setCookieValue(cookieValue);
}
}, [key]);
return [cookieValue, updateCookie, refreshCookie] as const;
};
js 에서 cookie값을 가져오려면 document.cookie 로 접근해서 직접 값을 파싱해야한다.
document.cookie에는 현재 문서와 관련된 모든 쿠키가 세미콜론으로 구분된 목록이 포함되어 있고,
각 쿠키를 반복하여 제공된 이름과 일치하는 지 확인해서 해당값을 찾는 과정이 필요함.
function getCookie(cookieName) {
// Split all cookies into an array
const cookies = document.cookie.split(';');
// Loop through each cookie to find the one with the specified name
for (let i = 0; i < cookies.length; i++) {
let cookie = cookies[i];
// Remove leading spaces if any
while (cookie.charAt(0) == ' ') {
cookie = cookie.substring(1);
}
// Check if this cookie has the name we're looking for
if (cookie.indexOf(cookieName + '=') == 0) {
// Return the value of the cookie
return cookie.substring(cookieName.length + 1, cookie.length);
}
}
// Return null if cookie not found
return null;
}
useCountDown은 특정시간의 차이만큼 countDown 할 수 있도록, hour, minute, second를 반환해주는 hook이고,
useCounter은 counter를 손쉽게 만들어 줄 수 있게 도와주는 hook이다.
//useCountDown
import { useEffect, useState } from "react";
import { useInterval } from "../useInterval";
import type { UseCountDown } from "./interface";
const padZero = (time: number): string => {
return `${time}`.length < 2 ? `0${time}` : `${time}`;
};
export const getHMSTime = (timeDiff: number): [string, string, string] => {
if (timeDiff <= 0) {
return ["00", "00", "00"];
}
if (timeDiff > 100 * 3600) {
return ["99", "59", "59"];
}
const hour = Math.floor(timeDiff / 3600);
const minute = Math.floor((timeDiff - hour * 3600) / 60);
const second = timeDiff - hour * 3600 - minute * 60;
return [padZero(hour), padZero(minute), padZero(second)];
};
export const useCountDown: UseCountDown = (
time: number,
format: (num: number) => [string, string, string] = getHMSTime, //형식에 맞춰서
callback?: () => void,
) => {
const [remainTime, setRemainTime] = useState(time);
const [delay, setDelay] = useState<number | null>(1000);
useInterval(() => {
if (remainTime <= 0) {
setDelay(null);
return;
}
setRemainTime(remainTime - 1);
}, delay); // interval로 state 감소
useEffect(() => {
if (time > 0 && remainTime <= 0) {
callback && callback();
}
}, [callback, remainTime, time]);
const [hour, minute, secoud] = format(remainTime);
// state가 변하면 => format에 맞춰 hour, minute, second변함
return [hour, minute, secoud] as const;
};
useCounter
초기화 지연 기능을 사용함 (initValue값을 함수로 받을 수도 있게 함) => 이를 위해 typeof === function으로 타입가드 후 각각에 맞춰서 로직 진행
import { useState } from "react";
import { useEvent } from "../useEvent";
import type { UseCounter } from "./interface";
export const useCounter: UseCounter = (
initialValue: number | (() => number) = 0,
max: number | null = null,
min: number | null = null,
) => {
// avoid exec init code every render
const initFunc = () => {
let init
= typeof initialValue === "function" ? initialValue() : initialValue;
typeof init !== "number"
&& console.error(
`initialValue has to be a number, got ${typeof initialValue}`,
);
if (typeof min === "number") {
init = Math.max(init, min);
}
else if (min !== null) {
console.error(`min has to be a number, got ${typeof min}`);
}
if (typeof max === "number") {
init = Math.min(init, max);
}
else if (max !== null) {
console.error(`max has to be a number, got ${typeof max}`);
}
return init;
};
const [value, setValue] = useState(initFunc);
const set = useEvent(
(newState: number | ((prev: number) => number) | (() => number)) => {
setValue((v) => {
let nextValue = typeof newState === "function" ? newState(v) : newState;
if (typeof min === "number") {
nextValue = Math.max(nextValue, min);
}
if (typeof max === "number") {
nextValue = Math.min(nextValue, max);
}
return nextValue;
});
},
);
const inc = (delta = 1) => {
set(value => value + delta);
};
const dec = (delta = 1) => {
set(value => value - delta);
};
const reset = () => {
set(initFunc);
};
return [value, set, inc, dec, reset] as const;
};
Throttle 와 Debounce 는 자주 사용 되는 이벤트나 함수 들의 실행되는 빈도를 줄여서, 성능 상의 유리함을 가져오기 위한 개념
input에 사용자가 입력한 값을 바탕으로 => api 호출이 필요하다고 가정할 때
차이점
Throttle 는 입력 주기를 방해하지 않고, 일정 시간 동안의 입력을 모와서, 한번씩 출력을 제한한다.
Debounce 는 입력 주기가 끝나면, 출력한다.
ex)
throttle 에서는 키보드입력 발생하면, 500ms 후에, 가장 최신 value 를 출력하고, 초기화 하여, 키보드 입력이 끝날때까지 반복한다.
debounce 에서는 키보드 입력이 발생하면, 500ms 동안 기다리다, 그 안에 키보드 입력이 발생하면, 시간을 초기화 하고 다시 기다리다, 가장 최신 value 를 출력한다.
useDebounce에서는 useDebounceFn이라는 hook을 사용
useDebounceFn에 => 1. value를 setting 할 수 있는 setter와 wait값, DebounceSettings option값을 넘겨준다.
https://www.reactuse.com/docs/state/useDebounce
https://www.reactuse.com/docs/effect/useDebounceFn
//이런식으로 사용하고
const debouncedValue = useDebounce(value, 500);
const { run } = useDebounceFn(() => {
setValue(value + 1);
}, 500);
import { useEffect, useState } from "react";
import type { DebounceSettings } from "lodash-es";
import { useDebounceFn } from "../useDebounceFn";
import type { UseDebounce } from "./interface";
export const useDebounce: UseDebounce = <T>(
value: T,
wait?: number,
options?: DebounceSettings,
) => {
const [debounced, setDebounced] = useState(value);
const { run } = useDebounceFn(
() => {
setDebounced(value);
},
wait,
options,
);
useEffect(() => {
run();
}, [run, value]);
return debounced;
};
useDebounceFn은 lodash에 debounce를 사용한다.
여기서 useLatest는 가장 최근에 state의 ref값에 직접 접근하는 hook이다. 밑에서 알아보자.
useLatest를 이용해 fn에 ref에 직접 접근한뒤, 다시 lodash의 debounce로 값들을 넘긴다.
import { useMemo } from "react";
import type { DebounceSettings } from "lodash-es";
import { debounce } from "lodash-es";
import { isDev, isFunction } from "../utils/is";
import { useLatest } from "../useLatest";
import { useUnmount } from "../useUnmount";
import type { UseDebounceFn } from "./interface";
export const useDebounceFn: UseDebounceFn = <T extends (...args: any) => any>(
fn: T,
wait?: number,
options?: DebounceSettings,
) => {
if (isDev) {
if (!isFunction(fn)) {
console.error(
`useDebounceFn expected parameter is a function, got ${typeof fn}`,
);
}
}
const fnRef = useLatest(fn);
const debounced = useMemo(
() =>
debounce(
(...args: [...Parameters<T>]): ReturnType<T> => {
return fnRef.current(...args);
},
wait,
options,
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(options), wait],
);
useUnmount(() => {
debounced.cancel();
});
return {
run: debounced,
cancel: debounced.cancel,
flush: debounced.flush,
};
};
https://github.com/lodash/lodash/blob/main/src/debounce.ts
function debounced(...args) {
const time = Date.now();
const isInvoking = shouldInvoke(time);
lastArgs = args;
lastThis = this;
lastCallTime = time;
if (isInvoking) {
if (timerId === undefined) {
return leadingEdge(lastCallTime);
}
if (maxing) {
// Handle invocations in a tight loop.
timerId = startTimer(timerExpired, wait);
return invokeFunc(lastCallTime);
}
}
if (timerId === undefined) {
timerId = startTimer(timerExpired, wait);
}
return result;
}
상태 관리: debounced 함수는 lastArgs, lastThis, lastCallTime 및 timerId와 같은 특정 상태 변수를 유지한다.
timerId => setTimeout timerId담는 변수, lastCallTime : 지난번에 호출된 시간
=> isInvoking값이 true라면 즉시 호출해야 하는경우 => 하지만 어떤 delay내에 함수가 여러번 호출되었을 경우가 있어 예외처리를 해줘야함.
활성 타이머가 없으면 즉시 함수를 호출합니다(leadingEdge) => function bind해서 값을 return하는 함수
'maxing'도 true인 경우 새 타이머를 설정하고 함수를 다시 호출하여 디바운스 기간 동안 여러 호출이 이루어진 경우를 처리함.
활성 타이머가 없는 경우(timerId가 정의되지 않음) 함수는 startTimer를 사용하여 새 타이머를 설정합니다. 이렇게 하면 마지막 호출 이후 디바운스 기간이 경과한 후에 디바운스된 함수가 호출됨.
function Demo() {
const isFirstMount = useFirstMountState();
const [render, reRender] = useState(0);
return (
<div>
<span>This component is just mounted: {isFirstMount ? "YES" : "NO"}</span>
<br />
<button onClick={() => reRender(1)}>{render}</button>
</div>
);
};
// useMountState.index.ts
import { useRef } from "react";
import type { UseFirstMountState } from "./interface";
export const useFirstMountState: UseFirstMountState = (): boolean => {
const isFirst = useRef(true);
if (isFirst.current) {
isFirst.current = false;
return true;
}
return isFirst.current;
};
function Demo() {
const isMounted = useMountedState();
const [, update] = useState(0);
useEffect(() => {
update(1);
}, []);
return <div>This component is {isMounted() ? "MOUNTED" : "NOT MOUNTED"}</div>;
};
import { useCallback, useEffect, useRef } from "react";
export const useMountedState = (): () => boolean => {
const mountedRef = useRef<boolean>(false);
const get = useCallback(() => mountedRef.current, []);
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
};
}, []);
return get;
};