thumbnail
기술아티클
React-Query Deep Dive 3. useQuery
useQuery를 분석해봅니다.
March 22, 2025

이번 장에서는 useQuery의 동작을 살펴볼것입니다.

QueryClientProvider

우리가 react-query를 사용할때 가장 먼저 실행되는 코드는 QueryClientProvider입니다.

export const QueryClientContext = React.createContext<QueryClient | undefined>(
  undefined,
)

export const useQueryClient = (queryClient?: QueryClient) => {
  const client = React.useContext(QueryClientContext)

  if (queryClient) {
    return queryClient
  }

  if (!client) {
    throw new Error('No QueryClient set, use QueryClientProvider to set one')
  }

  return client
}

export const QueryClientProvider = ({
  client,
  children,
}: QueryClientProviderProps): React.JSX.Element => {
  React.useEffect(() => {
    client.mount()
    return () => {
      client.unmount()
    }
  }, [client])

  return (
    <QueryClientContext.Provider value={client}>
      {children}
    </QueryClientContext.Provider>
  )
}

아주 간단한 코드로 queryclientmountunmountuseEffect에 넣어 라이프사이클에 맞게 실행되도록 처리해주는 액션 이외에, contextAPI를 통해 queryClient를 내부적으로 사용할수 있도록 해주는 역할을 가지고 있음을 알 수 있습니다.

간단하지만 이러한 로직이 없으면 내부적으로 queryClient를 사용할수 없고 queryClient가 마운트되지않아 react-query를 사용할 수 없게 됩니다.

useQuery

QueryClientProvider도 실행되었으므로 이제 컴포넌트 내의 useQuery가 실행될 시점입니다.

import { QueryObserver } from '@tanstack/query-core'

export function useQuery(options: UseQueryOptions, queryClient?: QueryClient) {
  return useBaseQuery(options, QueryObserver, queryClient)
}

타입 오버로딩을 모두 제거하면 이처럼 간단한 코드가 됩니다. 즉 핵심로직은 useBaseQuery에 존재하는것을 알 수 있습니다.

useBaseQuery

useBaseQuery에서 일부 타입을 제거하고, suspense,errorboundary 코드를 제거하면 다음과 같습니다.

export function useBaseQuery(
  options: UseBaseQueryOptions,
  Observer: typeof QueryObserver,
  queryClient?: QueryClient,
): QueryObserverResult<TData, TError> {
  const client = useQueryClient(queryClient)
  const defaultedOptions = client.defaultQueryOptions(options)

  
  // 1. observer 생성
  const [observer] = React.useState(
    () =>
      new Observer<TQueryFnData, TError, TData, TQueryData, TQueryKey>(
        client,
        defaultedOptions,
      ),
  )

  // 2. 결과값 추출
  const result = observer.getOptimisticResult(defaultedOptions)

  // 3. observer 구독 
  React.useSyncExternalStore(
    React.useCallback(
      (onStoreChange) => {
        const unsubscribe = observer.subscribe(notifyManager.batchCalls(onStoreChange))
        // 옵저버를 생성하고 이를 구독하는 순간에 놓친 업데이트를 전파받기 위한 로직
        observer.updateResult()

        return unsubscribe
      },
      [observer, shouldSubscribe],
    ),
    () => observer.getCurrentResult(),
    () => observer.getCurrentResult(),
  )

  // 4. result 프로퍼티 조회 트래킹
  return !defaultedOptions.notifyOnChangeProps
    ? observer.trackResult(result)
    : result
}

코드를 1 ~ 4번으로 나누어 실행 흐름을 따라가보겠습니다. 생각보다 깊이있게 들어가는 코드가 많기 때문에 여러번 반복해서 살펴보는것을 추천드립니다.

observer 생성

가장 먼저 실행되는 작업은 observer를 생성하는것입니다. 이 observer는 한번 생성되면 변경될 이유가 없으므로, setState가 존재하지 않습니다. observer의 생성자 함수인 constructor부터 살펴보겠습니다.

  constructor(
    client: QueryClient,
    public options: QueryObserverOptions,
  ) {
    super()
    this.#client = client
    this.#selectError = null
    this.#currentThenable = pendingThenable()
    this.bindMethods()
    this.setOptions(options)
  }

변수 초기화 및 두가지 함수를 실행하고 있습니다. 여기서 가장 중요한것은 마지막 라인의 this.setOptions(options)입니다. 해당 함수는 옵션을 할당하는 간단한 함수처럼 보이지만 실제로는 그렇지 않습니다.

  setOptions(
    options: QueryObserverOptions,
    notifyOptions?: NotifyOptions,
  ): void {

    const prevOptions = this.options
    const prevQuery = this.#currentQuery

    this.options = this.#client.defaultQueryOptions(options)
    this.#currentQuery.setOptions(this.options)
    
    // 1. 쿼리 업데이트
    this.#updateQuery()


    const mounted = this.hasListeners()

    // 2. 데이터 패치
    if (
      mounted &&
      shouldFetchOptionally(
        this.#currentQuery,
        prevQuery,
        this.options,
        prevOptions,
      )
    ) {
      this.#executeFetch()
    }

    // 3. 결과 업데이트
    this.updateResult(notifyOptions)

    // Update stale interval if needed
    if (
      mounted &&
      (this.#currentQuery !== prevQuery ||
        resolveEnabled(this.options.enabled, this.#currentQuery) !==
          resolveEnabled(prevOptions.enabled, this.#currentQuery) ||
        resolveStaleTime(this.options.staleTime, this.#currentQuery) !==
          resolveStaleTime(prevOptions.staleTime, this.#currentQuery))
    ) {
      this.#updateStaleTimeout()
    }

    const nextRefetchInterval = this.#computeRefetchInterval()

    // Update refetch interval if needed
    if (
      mounted &&
      (this.#currentQuery !== prevQuery ||
        resolveEnabled(this.options.enabled, this.#currentQuery) !==
          resolveEnabled(prevOptions.enabled, this.#currentQuery) ||
        nextRefetchInterval !== this.#currentRefetchInterval)
    ) {
      this.#updateRefetchInterval(nextRefetchInterval)
    }
  }

옵션값을 업데이트하는 로직은 중요하지만 오히려 간단하기에 넘어가겠습니다. 여기서 중요한 로직은 this.#updateQuery(), this.#executeFetch(), this.updateResult(notifyOptions)입니다. 먼저 첫번째 로직을 살펴보겠습니다.

updateQuery

#updateQuery() {
  // 현재 쿼리 가져오기
  const query = this.#client.getQueryCache().build(this.#client, this.options);

  // 쿼리가 같으면 조기 반환
  if (query === this.#currentQuery) {
    return;
  }

  // 현재 선택된 쿼리로 변경
  const prevQuery = this.#currentQuery as
    | Query<TQueryFnData, TError, TQueryData, TQueryKey>
    | undefined;
  this.#currentQuery = query;
  this.#currentQueryInitialState = query.state;

  // 이전 쿼리에 등록된 옵저버를 제거하고, 현재 쿼리에 옵저버 다시 등록 처음 옵저버 생성시는 구독리스너 없으므로 실행되지 않음
  if (this.hasListeners()) {
    prevQuery?.removeObserver(this);
    query.addObserver(this);
  }
}

생성자 함수에서 query를 설정한적이 없으므로 해당함수는 query를 초기화하는 과정이 되겠습니다. queryCachebuild함수를 이용해 쿼리를 가져오고, 이 쿼리를 현재 쿼리로 업데이트 해줍니다. build함수 먼저 살펴봅시다.

build(
    client: QueryClient,
    options: WithRequired<
      QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
      'queryKey'
    >,
    state?: QueryState<TData, TError>,
  ): Query<TQueryFnData, TError, TData, TQueryKey> {
    const queryKey = options.queryKey
    const queryHash =
      options.queryHash ?? hashQueryKeyByOptions(queryKey, options)
    let query = this.get<TQueryFnData, TError, TData, TQueryKey>(queryHash)

    // 쿼리가 없는 경우에만, 새로운 쿼리를 만들어 넣음
    if (!query) {
      query = new Query({
        client,
        queryKey,
        queryHash,
        options: client.defaultQueryOptions(options),
        state,
        defaultOptions: client.getQueryDefaults(queryKey),
      })
      this.add(query)
    }

    return query
}

add(query: Query<any, any, any, any>): void {
  // 동일한 쿼리가 없을 경우에만 queries에 쿼리를 추가함
  if (!this.#queries.has(query.queryHash)) {
    this.#queries.set(query.queryHash, query);

    // 쿼리가 추가되었음을 구독자에게 알림
    this.notify({
      type: "added",
      query,
    });
  }
}

queryKey를 이용해 queryHash를 만든 다음, 이를 기반으로 기존의 queries 세트에서 존재하는 쿼리를 가져옵니다. 만약 쿼리가 있다면 해당 쿼리를 그대로 반환하고, 그렇지 않은 경우에만 this.add를 이용하여 생성된 queries 세트에 추가하게됩니다. 즉 정리하자면, query가 있으면 그대로 반환하고, 그렇지 않으면 새롭게 query를 만들고 queries 세트에 추가한뒤 반환합니다.

추가적으로 queryHash를 생성하는 과정을 살펴보겠습니다. queryHash를 만드는 방법은 queryKey를 hashKey함수에 넣어 실행하여 만들어지는데, 이때 JSON.stringify를 실행하되, 두번째 인자로 콜백함수를 넘기고 있는것을 확인할수 있습니다. 이러한 로직을 넣게 되면, 객체에대해서 모두 이러한 콜백을 실행시킵니다. 콜백함수의 동작은 간단한데, 객체의 프로퍼티 순서가 다를 경우, 이를 동일하게 해주는 것입니다. 예를들어 {name:"foo",age:24}{age:"foo",name:24}은 그대로 JSON.stringify하면 다른 string이 되는데, 이를 정렬하여 키로 만들면 동일한 string이 되는것입니다. 객체의 프로퍼티 순서가 다른 쿼리를 가질 이유가 없기 때문에 이러한 로직이 포함되었습니다.

/**
 * 기본 해시 함수
 * planeObject의 경우 키 순서에 따라 해시 값이 달라지므로 정렬 후 해시 값을 반환
 */
export function hashKey(queryKey: QueryKey | MutationKey): string {
  return JSON.stringify(queryKey, (_, val) =>
    isPlainObject(val)
      ? Object.keys(val)
          .sort()
          .reduce((result, key) => {
            result[key] = val[key]
            return result
          }, {} as any)
      : val,
  )
}

executeFetch

#executeFetch(
  fetchOptions?: Omit<ObserverFetchOptions, "initialPromise">
): Promise<TQueryData | undefined> {
  return this.#currentQuery.fetch(
    this.options as QueryOptions<TQueryFnData, TError, TQueryData, TQueryKey>,
    fetchOptions
  );
}

몇가지 코드를 제거하였지만, 핵심적인 로직은 observer가 가지고 있는 queryfetch함수를 호출하는것입니다. 간단한 함수가 아니기 최대한 중요한 로직만 설명하겠습니다.

fetch(
    options?: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
    fetchOptions?: FetchOptions<TQueryFnData>,
  ): Promise<TData> {
    // 1. 현재 해당 쿼리를 패치중이거나 패치중에 잠시 멈춘경우
    if (this.state.fetchStatus !== 'idle') {
      if (this.state.data !== undefined && fetchOptions?.cancelRefetch) {
        this.cancel({ silent: true })
      } else if (this.#retryer) {
        this.#retryer.continueRetry()
        return this.#retryer.promise
      }
    }

    // 만약 옵션이 전달된 경우 옵션을 업데이트하고, 그렇지 않으면 기존 옵션을 그대로 사용합니다.
    if (options) {
      this.setOptions(options)
    }

    // 이 경우는 쿼리가 수화되거나, setQueryData로 인해 데이터가 생성되어 쿼리함수가 없는 경우 첫번째 옵저버의 쿼리내의 옵션을 사용합니다.
    if (!this.options.queryFn) {
      const observer = this.observers.find((x) => x.options.queryFn)
      if (observer) {
        this.setOptions(observer.options)
      }
    }

    // abortController 생성 및 네트워크 요청 취소가능 객체인 signal를 반환하는 프로퍼티 추가함수
    const abortController = new AbortController()
    const addSignalProperty = (object: unknown) => {
      Object.defineProperty(object, 'signal', {
        enumerable: true,
        get: () => {
          this.#abortSignalConsumed = true
          return abortController.signal
        },
      })
    }

    // 패치 함수. queryFn을 실행한 프로미스를 반환합니다.
    const fetchFn = () => {
      // 쿼리 함수를 반드시 보장함. 만약 없으면 내부적으로 에러 발생
      const queryFn = ensureQueryFn(this.options, fetchOptions)

      // 쿼리함수 컨텍스트 생성후 signal 프로퍼티 추가
      const queryFnContext: OmitKeyof<
        QueryFunctionContext<TQueryKey>,
        'signal'
      > = {
        client: this.#client,
        queryKey: this.queryKey,
        meta: this.meta,
      }
      addSignalProperty(queryFnContext)
      this.#abortSignalConsumed = false

      // persister 옵션을 명시한 경우 해당 함수를 실행한 결과를 반환함
      if (this.options.persister) {
        return this.options.persister(
          queryFn as QueryFunction<any>,
          queryFnContext as QueryFunctionContext<TQueryKey>,
          this as unknown as Query,
        )
      }

      // 쿼리 함수를 실행한 결과(프로미스)를 반환
      return queryFn(queryFnContext as QueryFunctionContext<TQueryKey>)
    }

    // Trigger behavior hook
    const context: OmitKeyof<
      FetchContext<TQueryFnData, TError, TData, TQueryKey>,
      'signal'
    > = {
      fetchOptions,
      options: this.options,
      queryKey: this.queryKey,
      client: this.#client,
      state: this.state,
      fetchFn,
    }

    addSignalProperty(context)

    this.options.behavior?.onFetch(
      context as FetchContext<TQueryFnData, TError, TData, TQueryKey>,
      this as unknown as Query,
    )

    this.#revertState = this.state

    // 2. 현재 상태를 fetch로 변경해줍니다.
    if (this.state.fetchStatus === 'idle') {
      this.#dispatch({ type: 'fetch' })
    }

    // 에러시 실행할 함수. state 디스패치 하고, 에러 발생 핸들러 호출합니다.
    const onError = (error: TError | { silent?: boolean }) => {
      // 에러 업데이트가 필요한 상황에만 에러상태를 업데이트합니다.
      if (!(isCancelledError(error) && error.silent)) {
        this.#dispatch({
          type: 'error',
          error: error as TError,
        })
      }

      // 캐시의 에러 발생 이벤트 핸들러 호출
      if (!isCancelledError(error)) {
        this.#cache.config.onError?.(
          error as any,
          this as Query<any, any, any, any>,
        )
        this.#cache.config.onSettled?.(
          this.state.data,
          error as any,
          this as Query<any, any, any, any>,
        )
      }

      // Schedule query gc after fetching
      this.scheduleGc()
    }

    // 3. 데이터 fetch를 위한 createRetryer 실행
    this.#retryer = createRetryer({
      initialPromise: fetchOptions?.initialPromise as
        | Promise<TData>
        | undefined,
      fn: context.fetchFn as () => Promise<TData>,
      abort: abortController.abort.bind(abortController),
      onSuccess: (data) => {
        if (data === undefined) {
          onError(new Error(`${this.queryHash} data is undefined`) as any)
          return
        }

        try {
          this.setData(data)
        } catch (error) {
          onError(error as TError)
          return
        }

        // Notify cache callback
        this.#cache.config.onSuccess?.(data, this as Query<any, any, any, any>)
        this.#cache.config.onSettled?.(
          data,
          this.state.error as any,
          this as Query<any, any, any, any>,
        )

        // Schedule query gc after fetching
        this.scheduleGc()
      },
      onError,
      onFail: (failureCount, error) => {
        this.#dispatch({ type: 'failed', failureCount, error })
      },
      onPause: () => {
        this.#dispatch({ type: 'pause' })
      },
      onContinue: () => {
        this.#dispatch({ type: 'continue' })
      },
      retry: context.options.retry,
      retryDelay: context.options.retryDelay,
      networkMode: context.options.networkMode,
      canRun: () => true,
    })

    return this.#retryer.start()
  }

첫번째 섹션은 현재 해당 쿼리의 네트워크 요청이 현재 대기상태가 아닐때에 대한 조건입니다. 만약 cancelRefetch옵션이 활성화되어있고 데이터도 존재한다면 그대로 현재 진행중인 쿼리 요청을 취소해버립니다. 그렇지 않다면 계속해서 쿼리의 네트워크 요청 절차를 진행합니다. 이로인해 동시에 들어온 API요청을 한번의 요청으로 처리할수 있습니다.

두번째 섹션은 fetch 상태로 업데이트하는 로직입니다. dispatch를 사용하였으므로 데이터 업데이트가 끝나기 전에 컴포넌트로 상태 변화가 전파됩니다.

세번째 섹션은 fetch를 위해 retryer를 생성하고 start함수를 실행하는 로직입니다. retryer는 비동기 로직을 실행하고, 실패시 이를 재시도 할수있도록 해주는 객체입니다. onSuccess콜백함수 내부에서 setData 함수를 호출해 dispatch시킨다는 사실과 canRun함수가 true를 반환한다는 사실을 기억하면서 retryer를 살펴봅시다.

export function createRetryer<TData = unknown, TError = DefaultError>(
  config: RetryerConfig<TData, TError>,
): Retryer<TData> {
  let isRetryCancelled = false
  let failureCount = 0
  let isResolved = false
  let continueFn: ((value?: unknown) => void) | undefined

  const thenable = pendingThenable<TData>()

  // retryer의 실행을 취소하고 에러 발생시키는 함수
  const cancel = (cancelOptions?: CancelOptions): void => {
    if (!isResolved) {
      reject(new CancelledError(cancelOptions))

      config.abort?.()
    }
  }

  // 재시도를 막는 함수
  const cancelRetry = () => {
    isRetryCancelled = true
  }

   // 재시도를 할수 있도록 하는 함수
  const continueRetry = () => {
    isRetryCancelled = false
  }

  // 포커스 중이고, 네트워크 모드가 always이면서 isOnline이고 canRun조건을 실행하는 경우 계속 호출
  const canContinue = () =>
    focusManager.isFocused() &&
    (config.networkMode === 'always' || onlineManager.isOnline()) &&
    config.canRun()

  // 네트워크 모드가 패치 가능한상태이고 canRun함수가 true이면 start가능
  const canStart = () => canFetch(config.networkMode) && config.canRun()

  // config.fn 함수 성공시 호출하는 함수로 onSuccess 함수 호출하고 continueFn 함수 호출함
  const resolve = (value: any) => {
    if (!isResolved) {
      isResolved = true
      config.onSuccess?.(value)
      continueFn?.()
      thenable.resolve(value)
    }
  }

  // config.fn 함수 실패시 호출하는 함수로 onError 함수 호출하고 continueFn 함수 호출함
  const reject = (value: any) => {
    if (!isResolved) {
      isResolved = true
      config.onError?.(value)
      continueFn?.()
      thenable.reject(value)
    }
  }

  // 실행중인 재시도 함수를 정지시키는 함수. 정지를 풀려면 continueFn을 실행해주어야합니다.
  const pause = () => {
    return new Promise((continueResolve) => {
      continueFn = (value) => {
        if (isResolved || canContinue()) {
          continueResolve(value)
        }
      }
      config.onPause?.()
    }).then(() => {
      continueFn = undefined
      if (!isResolved) {
        config.onContinue?.()
      }
    })
  }

  // 루프 도는 함수를 실행합니다.
  const run = () => {
    // 이미 resolve 되었다면 아무것도 하지 않습니다.
    if (isResolved) {
      return
    }

    Promise.resolve(config.fn())
      .then(resolve)
      .catch((error) => {
        // 이미 리졸브 되었다면 조기 리턴
        if (isResolved) {
          return
        }

        // 재시도 횟수 와 딜레이  및 재시도 해당 여부 체크
        const retry = config.retry ?? (isServer ? 0 : 3)
        const retryDelay = config.retryDelay ?? defaultRetryDelay
        const delay =
          typeof retryDelay === 'function'
            ? retryDelay(failureCount, error)
            : retryDelay
        const shouldRetry =
          retry === true ||
          (typeof retry === 'number' && failureCount < retry) ||
          (typeof retry === 'function' && retry(failureCount, error))

        // 재시도가 캔슬되었고, 재시도 필요없는경우 리젝트 해버림(에러 반환)
        if (isRetryCancelled || !shouldRetry) {
          // We are done if the query does not need to be retried
          reject(error)
          return
        }

        failureCount++

        // 실패 여부 전파
        config.onFail?.(failureCount, error)

        // 지연시간 걸고, 다시 재시도함
        sleep(delay)
          // Pause if the document is not visible or when the device is offline
          .then(() => {
            return canContinue() ? undefined : pause()
          })
          .then(() => {
            if (isRetryCancelled) {
              reject(error)
            } else {
              run()
            }
          })
      })
  }

  return {
    promise: thenable,
    cancel,
    continue: () => {
      continueFn?.()
      return thenable
    },
    cancelRetry,
    continueRetry,
    canStart,
    start: () => {
      // Start loop
      if (canStart()) {
        run()
      } else {
        pause().then(run)
      }
      return thenable
    },
  }
}

start함수를 실행하게되면 조건에 따라 run또는 pause함수를 실행합니다. canStart함수가 항상 true를 반환하므로 여기서 실행되는 함수는 run 함수입니다.

config.fn함수의 실행결과 resolve하게되면 resolve함수가 실행되면서 onSuccess가 실행되므로 데이터가 반영되지만, 만약 그렇지 않으면 재시도를 하게됩니다.

재시도 남은횟수, 시간등을 체크한뒤 재시도해야한다면 sleep 함수를 통해 지연시간을 걸고 다시 run 함수를 실행함으로써 에러를 전파하지 않고 네트워크 요청을 재시도하게됩니다.

updateResult

앞선 로직을 이해하셨다면 아직 비동기 호출의 결과를 받을수 없다는것을 알고 있을것입니다. 왜냐하면 이시점에 프로미스가 해결될수 없기 때문입니다. 그럼에도 호출하는것은 데이터가 아닌 상태(로딩중등) 업데이트를 받을수 있기 때문입니다.

  updateResult(notifyOptions?: NotifyOptions): void {
    const prevResult = this.#currentResult as
      | QueryObserverResult<TData, TError>
      | undefined

    const nextResult = this.createResult(this.#currentQuery, this.options)

    this.#currentResultState = this.#currentQuery.state
    this.#currentResultOptions = this.options

    if (this.#currentResultState.data !== undefined) {
      this.#lastQueryWithDefinedData = this.#currentQuery
    }

    // 데이터가 변경되지 않았으면 통보하지는 않음
    if (shallowEqualObjects(nextResult, prevResult)) {
      return
    }

    this.#currentResult = nextResult

    // Determine which callbacks to trigger
    const defaultNotifyOptions: NotifyOptions = {}

    const shouldNotifyListeners = (): boolean => {
      if (!prevResult) {
        return true
      }
      return false
    }

    if (notifyOptions?.listeners !== false && shouldNotifyListeners()) {
      defaultNotifyOptions.listeners = true
    }

    // 옵저버 구독 컴포넌트에 통지.
    this.#notify({ ...defaultNotifyOptions, ...notifyOptions })
  }

이전 쿼리와 현재 쿼리를 비교한뒤 결과가 같다면 조기반환하며, 만약 다르다면, shouldNotifyListeners의 결과가 true일때만 옵저버를 구독하는 컴포넌트에 결과 변환을 통지합니다.

하지만 현재 컴포넌트는 옵저버를 구독하고 있지 않기 때문에 결과를 통지받을수 없습니다. 현재 queryobserver와 동기화 되어있다는 사실만 이해해둡시다.

getOptimisticResult

  getOptimisticResult(
    options: DefaultedQueryObserverOptions<
      TQueryFnData,
      TError,
      TData,
      TQueryData,
      TQueryKey
    >,
  ): QueryObserverResult<TData, TError> {
    // 쿼리 캐시에서 쿼리를 가져옵니다.
    const query = this.#client.getQueryCache().build(this.#client, options)

    return this.createResult(query, options)
  }

query에서 결과를 만들어 반환하는데, 앞서 query를 만들었기 때문에 query를 만들지 않으므로 이전 쿼리가 그대로 반환됩니다.

observer 구독

useSyncExternalStore는 react에서 외부 스토어를 구독할수 있도록 제공하는 훅입니다. 첫번째 인자로 구독시 사용할 함수를 넣고, 두번째 인자로 업데이트시 값을 비교할 함수를 넣습니다. 앞서 redux Deep Dive 시에도 살펴본 함수이므로 깊이있게 살펴보지는 않겠습니다.

observer가 변경되면 onStoreChange가 실행되어 두번째 인자의 값의 변경여부를 검사한뒤, 상태변경을 전파해 리렌더링 될수 있도록 합니다. 이때 batchCalls함수로 래핑되어있으므로, 여러 컴포넌트가 동시에 상태변경을 전파받았다면 일괄적으로 수행하여 한번에 많은 리렌더링이 발생하지 않도록 방지합니다.

result 프로퍼티 조회 트래킹

마지막으로 우리가 useQuery에서 꺼내어 사용하는 result를 반환하는데, 이때defaultedOptions.notifyOnChangeProps옵션의 결과에 따라 observer.trackResult함수에 result를 넣어 감싼 값을 반환하게됩니다. 기본적으로 defaultedOptions.notifyOnChangePropsfalse이므로 옵션을 변경하지 않는한 observer.trackResult함수의 실행결과가 반환된다고 보면되겠습니다.

 trackResult(
  result: QueryObserverResult<TData, TError>,
  onPropTracked?: (key: keyof QueryObserverResult) => void
): QueryObserverResult<TData, TError> {
  const trackedResult = {} as QueryObserverResult<TData, TError>;

  Object.keys(result).forEach((key) => {
    Object.defineProperty(trackedResult, key, {
      configurable: false,
      enumerable: true,
      get: () => {
        this.trackProp(key as keyof QueryObserverResult);
        onPropTracked?.(key as keyof QueryObserverResult);
        return result[key as keyof QueryObserverResult];
      },
    });
  });

  return trackedResult;
}

trackProp(key: keyof QueryObserverResult) {
  this.#trackedProps.add(key)
}

이함수의 역할은 결과값 객체(isLoading,data, isFetching등의 값이 들어있음)들중 우리가 꺼내어 사용하는 프로퍼티를 trackProps에 저장하는것입니다. 우리가 꺼내어 사용하는 프로퍼티를 저장하는 방법은, result 객체를 프록시로 감싸서, 프로퍼티를 조회할때 사용되는 get을 재정의하는것입니다.

저장해둔 결과값은 앞서 살펴본 updateResult에서 결과 업데이트시 변경된 상태가 사용중인지 확인하는데 사용합니다. 만약 변경된 상태중 하나도 사용중인 프로퍼티가 없다면 상태를 업데이트 하지 않습니다.

const shouldNotifyListeners = (): boolean => {
      if (!prevResult) {
        return true
      }

      const { notifyOnChangeProps } = this.options
      const notifyOnChangePropsValue =
        typeof notifyOnChangeProps === 'function'
          ? notifyOnChangeProps()
          : notifyOnChangeProps

      if (
        notifyOnChangePropsValue === 'all' ||
        (!notifyOnChangePropsValue && !this.#trackedProps.size)
      ) {
        return true
      }

      const includedProps = new Set(
        notifyOnChangePropsValue ?? this.#trackedProps,
      )

      if (this.options.throwOnError) {
        includedProps.add('error')
      }

      return Object.keys(this.#currentResult).some((key) => {
        const typedKey = key as keyof QueryObserverResult
        const changed = this.#currentResult[typedKey] !== prevResult[typedKey]

        return changed && includedProps.has(typedKey)
      })
    }

이 로직은 앞선 시리즈 였던 react-hook-Form Deep Dive에서도 있었습니다. 여기서는 formState에 대해 적용하였습니다.

마치며

이번 아티클을 통해 useQuery를 사용해 값을 조회하는 과정을 살펴보았습니다.

추가적으로 stale 상태의 쿼리를 refetch하는 과정, queryClient에서 사용가능한 메서드의 동작, useMutaion의 동작등을 분석해보시면 많은 도움이 될것입니다. 핵심 개념을 모두 이해하셨기 때문에 그리 어렵지 않을것입니다.

이번 시리즈는 여기서 끝입니다. react-query가 서버상태 관리를 위해 어떤 방법을 사용하고 있는지 이해하시는데 조금이나마 도움이 되었으면 좋겠습니다.

참고 자료

AbortController