반응형

React Hook 에 대해 공부하다보면, 아래와 같은 문구를 만나게 된다.

 

 

막연하게 Hook 의 실행 순서와 선언 범위에 대한 규칙만 서술되어 있고, 왜 하지 말아야 하는지에 대한 내용은 서술되어 있지 않다. Hook 은 클로저와 아주 밀접한 관련이 있는데 클로저의 특징 때문에 생긴 제약사항이다.

 

외부에서 변수 접근을 하지 못 하게 만들고, 함수 자체를 리턴하게 하고 싶을 때, 아래와 같이 생각해볼 수 있다.

 

function getAdd() {
  let foo = 1
  return function () {
    foo += 1
    return foo
  }
}

 

함수 자체를 리턴하고 함수 내에서 사용하는 변수를 내부에 선언한다. 이렇게 구현하면 getAdd 를 통해 생성된 함수 객체를 호출할 때마다 내부에 선언되어 있는 변수에 계산을 할 수 있게 된다. 클로저다.

 

이 함수를 모듈 패턴을 통해 아래와 같이 구성할 수도 있다.

 

const add = (function getAdd() {
  let foo = 1;
  return function () {
    foo += 1;
    return foo;
  }
})()

 

사실 함수를 반환하면서 내부에 있는 변수들을 외부에서 제어하고자 한다면 고차함수들 대부분이 클로저일 수 밖에 없다.

React Hook 들도 모두 클로저이다.

 

const React = (function () {
  function useState(initVal) {
    let _val = initVal
    const state = () => _val
    const setState = (newVal) => {
      _val = newVal
    }
    return [state, setState]
  }
  return { useState }
})()

const [count, setCount] = React.useState(1)
console.log(count()) // 1
setCount(2)
console.log(count()) // 2

 

useState 를 구현해보면 위와 같다. 초기값을 인자로 받아 내부 변수에 세팅하고, 그 변수를 활용한 함수들을 리턴하는 것을 볼 수 있다. 내부 상태 값을 반환하는 함수와 갱신하는 함수를 배열 형태로 리턴하면 React 에서 흔히 사용하는 useState 와 유사하다.

 

하지만 hook 을 여러 번 사용한다면 위와 같은 구조는 정상적으로 동작하지 않게 된다. 위 React 모듈에 있는 값은 _val 하나이기 때문이다. 갱신하는 함수를 호출할 때마다 _val 변수가 덮어씌워져 호출이 되지 않는 것이다.

 

이를 방지하기 위해 배열에 hook 함수들을 배치하고 인덱스를 활용하여 사용해 접근하도록 개선했다.

 

const React = (function () {
  let hooks = []
  let idx = 0
  function useState(initVal) {
    const state = hooks[idx] || initVal
    const setState = (newVal) => {
      hooks[idx] = newVal
    }
    idx += 1
    return [state, setState]
  }
  function render(Component) {
    const C = Component()
    C.render()
    console.log(idx) // 2, 4, 6
    return C
  }
  return { useState, render }
})()

function Component() {
  const [count, setCount] = React.useState(1)
  const [text, setText] = React.useState('apple')

  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (word) => setText(word),
  }
}

var App = React.render(Component) // { count: 1, text: 'apple' }
App.click()
var App = React.render(Component) // { count: 2, text: 'apple' }
App.type('pear')
var App = React.render(Component) // { count: 'pear', text: 'apple' }

 

갱신이 정상적으로 되는 것 같지만, React.render 를 하는 순간 계속해서 idx 를 추가하게 되어서 이상하게 값이 수정되는 것을 볼 수 있다. 이미 증가된 idx 를 통해 갱신을 하는 것이 문제점이 되어 버렸다.

 

문제점을 해결하기 위해 아래와 같이 수정한다.

render 함수 내부에 idx 값을 초기화하고, 각 모듈 내의 함수에서 사용할 index 를 고정해서 공급해주면 여러 hook 들이 각자 고유한 index 를 가지게 되어 그에 맞는 상태를 변경할 수 있게 된다.

 

const React = (function () {
  let hooks = []
  let idx = 0
  function useState(initVal) {
  	const state = hooks[idx] || initVal
  	const _idx = idx // freeze
  	const setState = (newVal) => {
    	hooks[_idx] = newVal
  	}
    idx += 1
    return [state, setState]
  }

  function render(Component) {
  	idx = 0
    const C = Component()
    C.render()
    return C
  }
  return { useState, render }
})()

function Component() {
  const [count, setCount] = React.useState(1)
  const [text, setText] = React.useState('apple')

  return {
    render: () => console.log({ count, text }),
    click: () => setCount(count + 1),
    type: (word) => setText(word),
  }
}

var App = React.render(Component) // { count: 1, text: 'apple' }
App.click()
var App = React.render(Component) // { count: 2, text: 'apple' }
App.type('pear')
var App = React.render(Component) // { count: 2, text: 'peer' }

 

[출처]

본 포스팅은 (2019년 JS 컨퍼런스에서 발표한) React Hooks 와 Closure 의 관계를 잘 설명한 동영상을 재해석하였다.

 

반응형
반응형

모든 리소스를 한 번에 로드하는 것이 아니라 사용자가 보는 뷰포트 영역에 접근했을 때 지연로딩하고 싶을 때가 있다.

그 때 사용할 수 있는 API 가 IntersectionObserver API 이다. 뷰포트 영역의 특정 교차점 부분을 진입했을 때 새로운 액션을 할 수 있다.

 

IntersectionObserver() - Web API | MDN (mozilla.org)

 

IntersectionObserver() - Web API | MDN

IntersectionObserver() 생성자는 새로운 IntersectionObserver 객체를 생성하고 반환합니다.

developer.mozilla.org

 

InsersectionObserver 생성자를 살펴보면 첫 번째 인자에는 두 번째 인자(대상 요소의 가시성 비율) 값보다 역치 값이 넘어갈 경우 감시할 옵저버 함수를 만들 수 있다.

 

특정 비율이 넘어갈 때 사진을 로드하는 함수를 만든다고 생각해보자.

 

감시할 imageRef 대상이 있는데 imageSrc 이미지 경로가 없다면, 그 때 생성자를 생성하여 감시를 시작한다.

감시하다가 교차지점에 도달하면 imageSrc 이미지 경로를 세팅한다.

 

import { useState, useRef, useEffect } from "react";

export function useLazyImageObserver({ src }) {
    const [imageSrc, setImageSrc] = useState(null);
    const imageRef = useRef(null);

    useEffect(()=>{
        let observer;
        if (imageRef && !imageSrc) {
            observer = new IntersectionObserver(([entry]) => {
                if (entry.isIntersecting) {
                    setImageSrc(src);
                    observer.unobserve(imageRef.current);
                }
            }, { threshold: [0.25] });
            observer.observe(imageRef.current);
        }
        return () => {
            observer && observer.disconnect(imageRef);
        };
    }, [imageRef, imageSrc, src]);

    return { imageSrc, imageRef };
}

 

[사용부]

import { memo } from "react";
import { useLazyImageObserver } from "../hooks/useLazyImageObserver";

export const LazyImage = memo(({src, alt})=>{
    const { imageSrc, imageRef } = useLazyImageObserver({ src });
    return (
        <img ref={imageRef} src={imageSrc} alt={alt} width='200px' height='300px'/>
    );
});

 

const urlList = [
  'https://picsum.photos/200/300?random=1',
  'https://picsum.photos/200/300?random=2',
  'https://picsum.photos/200/300?random=3',
  'https://picsum.photos/200/300?random=4',
  'https://picsum.photos/200/300?random=5',
  'https://picsum.photos/200/300?random=6',
  'https://picsum.photos/200/300?random=7',
  'https://picsum.photos/200/300?random=8',
  'https://picsum.photos/200/300?random=9',
  'https://picsum.photos/200/300?random=10',
  'https://picsum.photos/200/300?random=11',
  'https://picsum.photos/200/300?random=12',
  'https://picsum.photos/200/300?random=13',
  'https://picsum.photos/200/300?random=14',
  'https://picsum.photos/200/300?random=15',
  'https://picsum.photos/200/300?random=16',
  'https://picsum.photos/200/300?random=17',
  'https://picsum.photos/200/300?random=18',
  'https://picsum.photos/200/300?random=19',
  'https://picsum.photos/200/300?random=20',
  'https://picsum.photos/200/300?random=21',
  'https://picsum.photos/200/300?random=22',
  'https://picsum.photos/200/300?random=23',
  'https://picsum.photos/200/300?random=24',
  'https://picsum.photos/200/300?random=25',
  'https://picsum.photos/200/300?random=26',
  'https://picsum.photos/200/300?random=27',
  'https://picsum.photos/200/300?random=28',
  'https://picsum.photos/200/300?random=29',
  'https://picsum.photos/200/300?random=30',
  'https://picsum.photos/200/300?random=31',
  'https://picsum.photos/200/300?random=32',
  'https://picsum.photos/200/300?random=33',
  'https://picsum.photos/200/300?random=34',
  'https://picsum.photos/200/300?random=35',
  'https://picsum.photos/200/300?random=36',
];

function App() {
  
  return (
    <div style={{ width: '600px' }}>
      {urlList && urlList.map((url, index) => (
          <LazyImage key={index} src={url} alt=""/>
      ))}
    </div>
  );
}

 

반응형
반응형

특정 기간동안 함수가 너무 많이 호출될 경우, 함수 실행을 건너뛸 수 있는 debounce 나 throttle 기법을 고려할 수 있다.

그 중 debounce 는 이벤트를 그룹화하여 많은 이벤트가 발생해도 모두 무시하고, 하나의 이벤트만 실행시키도록 하는 기법이다. lodash 라이브러리에서 지원한다.

 

input 태그에서 사용자가 입력을 수시로 변할 때, 백엔드에 input 데이터를 보내는 예제를 생각해보자.

 

const Input = () => {
  const [value, setValue] = useState();

  const sendRequest = (value) => {
    // 백엔드에 input 데이터를 보냄
  };

  const debouncedSendRequest = debounce(sendRequest, 500);

  const onChange = (e) => {
    const value = e.target.value;
    setValue(value);
    debouncedSendRequest(value);
  }

  return <input onChange={onChange} value={value} />
}

 

위와 같이 코드를 생각해볼 수 있다. onChange 함수에서 value 를 세팅하고 debounced 된 함수를 호출하는 형태이다.

하지만 위 코드는  value 값이 바뀔 때마다 리렌더링이 되면서 아래와 같은 문제점이 생긴다.

 

  • sendRequest, debouncedSendRequest 함수가 계속 파괴되고 생성된다.
  • timer 로 인해 바로 파괴되는 것이 아니라 timer 시간 동안 유지되었다가 참조하는 곳이 없어서 가비지 컬렉션에 의해 정리된다.

 

const Input = () => {
  const [value, setValue] = useState("initial");

  const sendRequest = useCallback((value) => {
    // 백엔드에 input 데이터를 보냄
  }, []);

  const debouncedSendRequest = useMemo(() => {
  	return debounce(sendRequest, 500);
  }, [sendRequest]);

  const onChange = (e) => {
    const value = e.target.value;
    setValue(value);
    debouncedSendRequest(value);
  }

  return <input onChange={onChange} value={value} />
}

 

리렌더링이 되어서 함수가 파괴가 되어 참조를 못 하는 문제를 해결하기 위해 sendRequest 함수 자체에는 useCallback 훅으로 감싸고, debouncedSendRequest 함수는 useMemo 훅으로 감싸서 코드를 수정해보았다. 정상적으로 동작하는 것처럼 보인다.

 

debouncedSendRequest(value) 구문보면 계속해서 value 인자를 주고 있는데 이 부분을 useCallback 의 종속성 인자로 바꾸어 보면, 결국 처음 코드와 같게 된다.

 

리바운싱할 때마다 디바운스 함수를 생성하지 않게 하는 것이 관건인데 보통 useRef 훅으로 함수를 감싸는 것을 추천한다. 아래와 같은 형태가 되는데 만약 useRef 내부 함수에서 state 값을 참조하면 클로저가 되어버리기 때문에 주의해야 한다.

 

const ref = useRef(debounce(() => {
    // value 가 scope 를 벗어난 외부 변수이기 때문에 초기값으로 세팅된다 (클로저)
    console.log(value);
}, 500));

 

value 상태 값을 항상 최신으로 유지하기 위해 함수를 다시 호출하고 ref 에 다시 할당해야 한다.

(즉, 클로저라는 문제 때문에 value 값이 변경될 때마다, ref.current 값을 교체해주어야 된다.)

 

useEffect(() => {
    ref.current = debounce(() => {
    }, 500);
}, [value]);

 

또 처음 코드와 같게 된다. 클로저로 변형된 함수들을 Ref 로 묶고, debouncedCallback 함수만 useMemo  로 결과 값만 재 사용한다면 위 useMemo 와 useCallback 의 이점을 동시에 사용할 수 있게 된다.

 

[최종 코드]

  const [value, setValue] = useState("initial");
  const ref = useRef();

  const onChange = () => {
  };

  useEffect(() => {
    ref.current = onChange;
  }, [onChange]);

  const debouncedCallback = useMemo(() => {
    const func = () => {
      ref.current?.();
    };
    return debounce(func, 1000);
  }, []);

 

[출처]

How to debounce and throttle in React without losing your mind (developerway.com)

반응형
반응형

useQuery 옵션 중에 onSuccess, onError, onSettled 같은 상태 변화가 일어날 때 트리거해주는 옵션이 있다.

 

 

현재 조회하고 있는 데이터의 상태에 따라 동작을 수행할 수 있어 onSuccess 에서 state 를 변경하곤 한다.

심지어 구글 첫 번째 검색 결과인 스택오버플로우에서도 권장하고 있는 방법이었다.

 

javascript - Store data from useQuery with useState - Stack Overflow

 

Store data from useQuery with useState

I'm using React hooks both to fetch GraphQL data with react-apollo and to store local state: const [userData, setUserData] = useState({}) const { loading, error, data } = useQuery(USER_QUERY) How...

stackoverflow.com

 

하지만 이는 안티패턴이라고 한다. Suspense 같은 React 에서 지원하는 컴포넌트를 같이 사용하게 될 때 렌더링 순서가 어떻게 바뀔지 모르기 때문이다. 

 

setState in onSuccess is not working first time with suspense · Issue #3784 · TanStack/query (github.com)

 

setState in onSuccess is not working first time with suspense · Issue #3784 · TanStack/query

Describe the bug With Suspense, calling setState in useQuery.onSuccess is not changing state. I found that this issue would be fix turning off refetchOnWindowFocus. but it still no good UX (require...

github.com

 

실제로 Suspense 와 useQuery 의 onSuccess (setState 동작) 를 같이 사용할 때 state 값이 업데이트가 되지 않는 이유를 물어보는 Tanstack/Query Issue 이다. 정리해보면 다음과 같다.

 

Suspense 와 useQuery, useEffect, onSuccess 가 호출되는 순서

1. <Suspense> 컴포넌트가 마운트 된다.
2. 자식 컴포넌트가 마운트 된다.
3. useQuery 비동기 함수가 호출이 된다. (이 때 Promise 를 던진다)
4. (<Suspense> 컴포넌트가 자식의 비동기 함수를 관찰하기 시작한다.)

Promise 의 상태가 Pending 상태이므로 <Suspense> 컴포넌트의 자식 컴포넌트가 언마운트된다.

그리고 Fallback 컴포넌트를 마운트한다.

5. 해당 시점에 useEffect 는 호출되지 않는다. 이미 언마운트 되었기 때문이다.

그러나 onSuccess 는 이 시점에 호출되고 언마운트 된 컴포넌트의 state 를 변경하려고 시도하기 때문에 아무 일도 일어나지 않는다.

6. Promise 의 상태가 Complete 되면서 특정 결과를 반환할 때 캐싱 처리된다.

7. Fallback 은 언마운트 되고 다시 자식 컴포넌트를 마운트한다.

8. 자식 컴포넌트가 마운트 되는 시점에 useEffect 가 호출되고, useQuery 캐시에 저장되어 있던 결과를 로드한다.

 

Suspense 가 결과가 회신되기 전까지 자식 컴포넌트를 언마운트하기 때문에 onSuccess 의 호출시점이 언마운트 중에 호출 되었다면 상태가 업데이트 되지 않는다는 것이다.

 

 

그래서 useQuery 로 도출한 결과 값을 useEffect 로 모니터링하고 해당 결과 값이 바뀌면 상태를 바꾸어야 한다.

반응형
반응형

Rules of Hooks – React (reactjs.org)

 

Rules of Hooks – React

A JavaScript library for building user interfaces

reactjs.org

Only Call Hooks at the Top Level
Don’t call Hooks inside loops, conditions, or nested functions. Instead, always use Hooks at the top level of your React function, before any early returns. By following this rule, you ensure that Hooks are called in the same order each time a component renders. That’s what allows React to correctly preserve the state of Hooks between multiple useState and useEffect calls. (If you’re curious, we’ll explain this in depth below.)

 

위 React 공식문서를 살펴보면, 반복문 조건문 안이나 nested function 안에 hook 을 호출하면 안 된다고 명시하고 있다.

무조건 React Function Top level 에서 호출해야 한다. 이 규칙을 정해야 hook 의 호출 시점이 일관됨을 보장할 수 있기 때문이다.

 

따라서 Hook 을

 

- 조건문에서 사용하고 싶다면 결과 값을 useState 와 useEffect 에서 핸들링한다.

- 반복문에서 사용하고 싶다면 Hook 에서 반복을 지원하는지 살펴보거나, 반복할 리스트를 map 으로 맵핑하여 인자로 전달한다.

반응형

+ Recent posts