현재 암호화폐 선물 거래 서비스를 런칭하고 나서 어느정도 안정화가 되고 신규 기능을 추가하고 있었습니다. 코드를 어느정도 마무리하고, 리뷰를 받던 와중에 팀원분께서 useCallbackRef라는 훅을 소개시켜줬었다. 코드 적용후에 이것이 어떻게 내부적으로 어떻게 동작하는지 궁금해서 좀 살펴보게 되었습니다.
기존에 작성되었던 코드는 다음과 같은 형태였습니다. 업데이트된 count 상태를 기반으로 API를 실행할 수 있도록 하는 것을 의도했었습니다.
import { useCallback, useEffect, useRef, useState } from "react";
export default function CallbackRefTest() {
const [count, setCount] = useState(0);
const updateCount = useCallback(() => {
// API 실행
console.log(count);
}, [count]);
const countControllerRef = useRef<CountController>(null);
useEffect(() => {
countControllerRef.current = new CountController(updateCount);
}, []);
return (
<div>
<button
onClick={() => {
setCount((prev) => prev + 1);
}}
>
+
</button>
<button
onClick={() => {
countControllerRef.current?.handleClick();
}}
>
API 실행
</button>
<span>{count}</span>
</div>
);
}
class CountController {
constructor(private onClick: () => void) {
this.onClick = onClick;
}
handleClick() {
this.onClick?.();
}
}
이렇게 구현하니 count가 업데이트 되더라도 CountController의 handleClick을 실행하더라도 첫 상태를 클로저로 유지가 되게 된다.
클로저가 계속 유지된다..!
이걸 해결하기 위해서 2가지 방식을 떠올렸었습니다.
첫번째는 count가 변경될 때마다 새로운 CountController 인스턴스를 만들어준다는 것과 두번째는 count가 변경될 때마다 인스턴스 내부의 핸들러 함수를 업데이트 해주는 것이었습니다.
저는 두번째 방식을 선택했는데 계속해서 인스턴스를 생성하는 것은 리소스 낭비가 발생할 것이라고 생각했기 때문입니다. 물론 두번째 방식의 경우는 코드가 지저분해진다는 단점이 있었지만 계속 인스턴스를 생성하는 것보다 개인적으로 더 낫다고 판단했습니다
// 1. count를 deps에 넣어서 최신 함수를 넣어둔다
useEffect(() => {
countControllerRef.current = new CountController(updateCount);
}, [count]);
// 2. countController의 핸들러 함수 속성을 업데이트 한다.
useEffect(() => {
countControllerRef.current = new CountController(updateCount);
}, []);
useEffect(() => {
countControllerRef.current?.updateHandleClick(updateCount);
}, [count]);
class CountController {
constructor(private onClick: () => void) {
this.onClick = onClick;
}
handleClick() {
this.onClick?.();
}
updateHandleClick(onClick: () => void) {
this.onClick = onClick
}
}
이 코드를 보시더니 팀원분께서 useCallbackRef라는 훅을 알려주셨습니다. radix-ui에서도 사용되며, 이는 useEffect 내에서 함수의 최신성을 보장할 때 사용되는 훅입니다.
export function useCallbackRef<Callback extends (...args: any[]) => any>(
callback: Callback
) {
const callbackRef = useRef<Callback>(callback);
useEffect(() => {
callbackRef.current = callback;
}, [callback]);
return useCallback((...args: any[]) => {
return callbackRef.current(...args);
}, []) as Callback;
}
훅의 callbackRef에 변경되는 callback을 저장하고, 리턴하는 함수에 저장한 callback을 실행하는 코드입니다. 이렇게 되면 클로저로 인해 반환하는 함수 내부의 callbackRef는 계속 최신성을 보장받을 수 있게 됩니다.
import { useEffect, useRef, useState } from "react";
import { useCallbackRef } from "../hooks/useCallbackRef";
export default function CallbackRefTest() {
const [count, setCount] = useState(0);
const updateCount = useCallbackRef(() => {
// API 실행
console.log(count);
});
const countControllerRef = useRef<CountController>(null);
useEffect(() => {
countControllerRef.current = new CountController(updateCount);
}, []);
return (
<div>
<button
onClick={() => {
setCount((prev) => prev + 1);
}}
>
+
</button>
<button
onClick={() => {
countControllerRef.current?.handleClick();
}}
>
API 실행
</button>
<span>{count}</span>
</div>
);
}
class CountController {
constructor(private onClick: () => void) {
this.onClick = onClick;
}
handleClick() {
this.onClick?.();
}
}
이렇게 변경하고 나서 코드를 실행해보도록 합시다.
이렇게 변경하면 함수 내의 상태에 대한 최신성을 유지하면서도 useEffect deps에 함수 내부의 상태를 추가할 필요가 없어집니다. 참조에 대한 새로운 방식을 또 배울 수 있었어서 성장을 한 느낌이네요.