들어가며
오늘은 여러 가지 사용자 정의 훅을 만들어 보았다. 강의 앞부분에 해당 사용자 정의 훅이 어떤 동작을 하는지에 대한 설명을 듣고 먼저 만들어 보고, 강의에서는 어떤 방식으로 구현했는지 비교해보면서 공부했다. 반복해서 만들다 보니 어떤 식으로 구성해야 할 지 조금씩 감이 잡히는 것 같다.
useResize
지정한 대상 요소의 크기가 변경되었을 때 이벤트를 실행하는 훅이다. 처음에는 resize 이벤트를 사용해 구현하려고 했다. 그런데 동작을 안하길래 찾아보니 resize 이벤트는 window 객체에서만 발생한다고 한다. 모든 요소의 resize 이벤트를 감지하기 위해 Resize Observer를 사용할 수 있다.
1 const observer = new ResizeObserver(callback);
ResizeObserver 생성자 함수를 사용해 observer 객체를 생성할 수 있다. 전체적인 사용법은 Intersection Observer와 같다. 콜백 함수는 entries, observer 를 매개변수로 받는다. entries는 ResizeObserverEntry 인스턴스의 배열이다. 해당 인스턴스는 아래의 속성들을 가지고 있다.
- contentRect: 관찰 대상의 사각형 정보
- target: 관찰 대상 요소
- contentBoxSize: 관찰 대상의 content-box 크기
- borderBoxSize: 관찰 대상의 border-box 크기
observer는 자기 자신(ResizeObserver)을 참조하고 있다.
1 import { useCallback, useEffect, useRef, useState } from "react";23 const useResize = (handler) => {4 const savedHandler = useRef(handler);5 const targetRef = useRef(null);67 useEffect(() => {8 const element = targetRef.current;9 if (!element) return;1011 const observer = new ResizeObserver(entries => {12 savedHandler.current(entries[0].contentRect)13 })14 observer.observe(element);1516 return () => {17 observer.disconnect();18 }19 }, [targetRef])2021 return targetRef;22 }2324 export default useResize;
useRef를 사용해 관찰하고자 하는 요소의 값을 가져온다. 관찰할 요소가 하나이기 때문에 ResizeObserver 콜백 함수에서 entries의 0번째 요소를 가져온다.
useLocalStorage / useSessionStorage
브라우저의 저장소를 사용할 수 있게 해주는 훅이다. 강의를 안보고 혼자서 한 번 만들어 봤는데 전체적인 구조가 같았다.
1 import { useState } from "react";23 const useStorage = (type, key, initialValue) => {4 // type: local, session5 const storage = type === 'local' ? localStorage : sessionStorage;6 const [state, setState] = useState(() => {7 try {8 const item = storage.getItem(key);9 return item ? JSON.parse(item) : initialValue;10 } catch(e) {11 console.error(e);12 return initialValue;13 }14 });1516 const setStorage = (value) => {17 try {18 storage.setItem(key, JSON.stringify(value));19 setState(value);20 } catch(e) {21 console.error(e);22 }23 }2425 return [state, setStorage];26 }2728 export default useStorage;
강의에서는 localStorage와 sessionStorage를 사용하는 훅을 따로 만들었는데, 이름만 다르고 사용법은 같기 때문에 하나의 훅으로 만들고 type을 전달하도록 만들어 보았다. useState를 사용해 해당 훅 내부에서 사용할 상태 변수를 설정해준다. 이 값은 로컬 저장소에 저장된 값이 있으면 그 값을 초기값으로 설정하고, 만약 값이 없다면 전달 받은 매개변수를 초기값으로 설정한다.
신기한 점이 있는데 useState 내에 초기값을 함수로 전달하고 있다. 이를 Lazy Initial State 라고 한다. 만약 초기값을 설정할 때 복잡한 연산의 결과로 얻은 값을 전달한다고 가정하자.
1 const [state, setState] = useState(someExpensiveComputation())
이렇게 계산된 결과를 전달하게 되면 값은 초기화 과정에서 한 번만 적용되고 리렌더링시에는 적용되지 않지만, 함수는 계속해서 호출되기 때문에 성능에 낭비가 생긴다. 이를 해결하기 위해서 함수를 전달한다. 함수를 전달하면 처음 렌더링 할 때만 반환값을 초기값으로 설정하고, 리렌더링시에는 어떠한 계산도 하지 않는다.
1 const [state, setState] = useState(() => {2 return someExpensiveComputation();3 })
useForm
Form을 관리하기 위한 훅이다. Form은 데이터를 전송하기 위해 사용한다. Form에는 입력 받는 데이터가 있고, 해당 데이터의 유효성을 검사하고, 에러를 출력하고, 데이터를 전송하는 기능이 있다. 이런 기능들은 다양한 Form에서 공통적으로 사용하기 때문에 훅으로 만들어 관리하면 편하다. 이를 위한 Formik 이라는 라이브러리도 사용할 수 있다고 한다.
1 import { useState } from "react";23 const useForm = ({ initialValues, onSubmit, validate }) => {4 const [values, setValues] = useState(initialValues);5 const [errors, setErrors] = useState({});6 const [isLoading, setIsLoading] = useState(false);78 const handleChange = (e) => {9 const { name, value } = e.target;10 setValues({11 ...values,12 [name]: value13 })14 }1516 const handleSubmit = async (e) => {17 setIsLoading(true);18 e.preventDefault();19 const newErrors = validate ? validate(values) : {};20 if (Object.keys(newErrors).length === 0) {21 await onSubmit(values);22 }23 setErrors(newErrors);24 setIsLoading(false);25 }2627 return {28 values,29 errors,30 isLoading,31 handleChange,32 handleSubmit33 }34 }3536 export default useForm;
useTimeout
setTimeout을 시작하고 중단할 수 있는 훅이다. 구조는 간단하다.
1 import { useCallback, useEffect, useRef } from "react";23 const useTimeoutFn = (fn, ms) => {4 const timeoutId = useRef();5 const callback = useRef(fn);67 useEffect(() => {8 callback.current = fn;9 }, [fn]);1011 const run = useCallback(() => {12 timeoutId.current && clearTimeout(timeoutId.current);1314 timeoutId.current = setTimeout(() => {15 callback.current();16 }, ms);17 }, [ms])1819 const clear = useCallback(() => {20 timeoutId.current && clearTimeout(timeoutId.current);21 }, [])2223 useEffect(() => {24 clear()25 }, [clear])2627 return [run, clear]28 }2930 export default useTimeoutFn;
useRef를 사용해 setTimeout 함수의 id를 관리한다. run 함수에서는 setTimeout 함수를 실행하고, clear 함수에서는 clearTimeout 함수를 실행한다.
useInterval
useInterval 훅도 useTimeout 훅과 같다. setTimeout, clearTimeout 대신 setInterval, clearInterval 함수를 사용한다.
1 import { useCallback, useEffect, useRef } from "react";23 const useIntervalFn = (fn, ms) => {4 const intervalId = useRef();5 const callback = useRef(fn);67 useEffect(() => {8 callback.current = fn;9 }, [fn]);1011 const run = useCallback(() => {12 intervalId.current && clearInterval(intervalId.current);1314 intervalId.current = setInterval(() => {15 callback.current();16 }, ms);17 }, [ms])1819 const clear = useCallback(() => {20 intervalId.current && clearInterval(intervalId.current);21 }, [])2223 useEffect(() => clear, [clear]);2425 return [run, clear];26 }2728 export default useIntervalFn;
useDebounce
디바운싱을 위해 사용하는 훅이다.
1 import { useEffect } from "react";2 import useTimeoutFn from "./useTimeoutFn"34 const useDebounce = (fn, ms, deps) => {5 const [run, clear] = useTimeoutFn(fn, ms);67 //eslint-disable-next-line8 useEffect(run, deps);910 return clear;11 }1213 export default useDebounce;
기존에 만들었던 useTimeout 훅을 사용한다. useDebounce 훅 내의 useTimeout 훅의 run 함수가 실행되면 기존의 setTimeout 함수는 사라지기 때문에 자연스럽게 디바운스 효과를 낼 수 있다.
정리
훅을 만들어 재사용하면 확실히 엄청 편할 것 같다. 지금은 사용자 정의 훅을 만드는 강의를 듣기 때문에 쉽다고 느껴지는 것 같은데, 실제 프로젝트를 진행하면서 중복되는 부분을 훅으로 만들어 사용할 수 있을지는 의문이다.
TMI
TIL은 12시 조금 넘어서 작성을 했다. mdx 파일을 생성하고 블로그 레포지토리에 변경사항을 반영했다. netlify로 배포하고 있어서 자동으로 빌드가 되는데 아무리 기다려도 빌드가 안되는 거다. 뭐가 문제인가 싶어서 netlify 오류 로그를 확인해 봤는데 다음과 같은 오류 때문에 빌드가 실패하고 있었다.
1 Failed during stage 'building site': Build script returned non-zero exit code: 1
관련해서 구글링 해봤는데 다양한 원인이 있었다. 일단은 이전에 빌드가 제대로 됐던 커밋으로 다시 되돌리고 빌드를 해 보았다. 분명 이전에는 빌드가 잘 됐던 파일인데, 다시 빌드해보니 똑같은 오류와 함께 빌드가 안된다. 미치겠다. 제대로 배포가 됐을 때의 빌드 로그와 오류가 났던 빌드 로그를 비교해 보았다. 제대로 빌드가 됐을 때는 16.15.0 버전의 node를 사용하고 있었고, 실패 했을 때에는 16.15.1 버전을 사용하고 있었다. 그래서 netlify 내부적으로 사용하는 node 버전을 변경하는 법에 대해 찾아보았다. netlify 문서 에서 잘 설명하고 있는데 환경 변수로 NODE_VERSION 이라는 변수를 설정하고 값으로는 원하는 node 버전을 명시해주면 된다고 한다. 버전 명시하니까 제대로 빌드가 된다... 버전이 갑자기 왜 바뀐걸까..? 버전의 중요성을 깨닫는 하루였다. 오늘 일찍 잘 생각에 싱글벙글 하고 있었는데...
참고자료
프로그래머스 데브코스
https://heropy.blog/2019/11/30/resize-observer/
https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver
https://reactjs.org/docs/hooks-reference.html#lazy-initial-state
https://stackoverflow.com/questions/58539813/lazy-initial-state-what-it-is-and-how-to-use-it