[Tanstack Query] placeholderData와 initialData

이희제·2024년 12월 2일
1
post-thumbnail

사내 관리자에 tanstack-query를 적용/개발할 때 리스트 화면에서 pagination 이동할 때 화면이 깜빡거리는 이슈가 발생했다.

페이지 변경할 때마다 useQuery를 통해 api를 요청했다.

api 요청을 하고 가지고 오는 사이에 값이 없기 때문에 순간적으로 빈 화면이 노출되었다가 리스트가 보여지는 것이다. (값이 없을 때는 데이터가 없다는 화면을 노출해줬다)

이를 해결하기 위해 placeholderData 옵션에 keepPreviousData 함수를 넣어줌으로써 해결을 했다.

Tanstack Query에서는 placeholderData 와 비슷한 역할을 하는 initailData 가 존재한다.

Tanstack Query에서 placeholderDatainitialData는 비동기 데이터를 다루는 동안 UI의 사용자 경험을 개선하기 위해 사용된다. 두 개념 모두 데이터를 로드하기 전의 초기 상태를 설정하는 데 사용되지만, 용도와 동작 방식이 다르다.

한번 알아보자!


1. placeholderData

  • 정의: 데이터가 로드되기 전에 UI에 "임시 데이터"를 표시하기 위한 옵션이다.
  • 사용 목적: 사용자가 요청 중인 데이터를 기다리는 동안 빈 화면 대신, 대략적인 데이터를 보여줍니다. 이 데이터는 가짜 데이터, 스켈레톤 UI 등으로 활용될 수 있다.
  • 특징:
    1. 임시적: placeholderData는 실제 데이터를 가져오면 대체된다.
    2. 캐싱되지 않음: placeholderData는 Query Cache에 저장되지 않으며, 실제 데이터와 교체 후에는 더 이상 유지되지 않는다. (이게 initialData와의 큰 차이다)
    3. 동적인 값 가능: 함수 형태로 제공하여 현재 queryKey를 기반으로 동적 데이터를 반환할 수 있다.

동적인 값이 가능하기 때문에 함수 형태로 제공이 가능하다. 내가 다음과 같이 사용한 것도 되는 것이다.

import { keepPreviousData, useQuery } from '@tanstack/react-query'

type Params = {
  queryKey: string[]
  gameCd: string
  enableCondition: boolean
  selectCallback: (data: any) => any
}

const getAppliedTheme = async (gameCd: string) => {
  const res = await fetch(`/proxy/theme?url=/api/theme/setting/${gameCd}`)
  return res.json()
}

const useAppliedThemeQuery = ({ queryKey, gameCd, enableCondition, selectCallback }: Params) => {
  return useQuery({
    queryKey,
    queryFn: () => getAppliedTheme(gameCd),
    enabled: enableCondition,
    select: selectCallback,
    placeholderData: keepPreviousData
  })
}

export default useAppliedThemeQuery

공식 문서를 보면 v5부터 keepPreviousData 함수를 제공하는 것을 알 수 있다. 기존 useQuery 에서 제공하던 keepPreviousData: Boolean 값 옵션을 제거하고 keepPreviousData 함수를 제공하는 것이다.

keepPreviousData 함수 구현을 보면 간단하다. previousData 를 인자로 받아 그대로 반환한다.

export function keepPreviousData<T>(
  previousData: T | undefined,
): T | undefined {
  return previousData
}

위의 내장함수를 안쓰고 직접 아래와 같이 해도 된다.

useQuery({
  queryKey,
  queryFn,
  placeholderData: (previousData, previousQuery) => previousData, // 항등 함수
});

placeholderData 에 값도 넣을 수 있고 함수도 넣을 수 있는데 이를 어떻게 처리하는지 궁금해서 내부 코드를 더 살펴봤다.

queryObserver.ts 의 일부이다. 현재 data 가 없고 placeholderData 가 있다면 해당 값을 data 에 넣어준다. (로딩 중 placeholderData를 사용하여 임시 데이터를 설정)

if (
      options.placeholderData !== undefined &&
      data === undefined &&
      status === 'pending'
    ) {
      let placeholderData

      // Memoize placeholder data
      if (
        prevResult?.isPlaceholderData &&
        options.placeholderData === prevResultOptions?.placeholderData
      ) {
        placeholderData = prevResult.data
      } else {
        placeholderData =
          typeof options.placeholderData === 'function'
            ? (
                options.placeholderData as unknown as PlaceholderDataFunction<TQueryData>
              )(
                this.#lastQueryWithDefinedData?.state.data,
                this.#lastQueryWithDefinedData as any,
              )
            : options.placeholderData
        if (options.select && placeholderData !== undefined) {
          try {
            placeholderData = options.select(placeholderData)
            this.#selectError = null
          } catch (selectError) {
            this.#selectError = selectError as TError
          }
        }
      }

      if (placeholderData !== undefined) {
        status = 'success'
        data = replaceData(
          prevResult?.data,
          placeholderData as unknown,
          options,
        ) as TData
        isPlaceholderData = true
      }
    }
  • 이전과 현재의 placeholderData 옵션이 동일한지(options.placeholderData === prevResultOptions?.placeholderData)를 확인하고 조건을 만족하면, 새로운 placeholderData를 생성하지 않고, 이전에 사용된 데이터를 재사용(prevResult.data)한다.

  • placeholderData가 함수인지 확인한다.

    • 함수일 경우: PlaceholderDataFunction으로 간주하고 호출한다. 이 함수는 두 가지 인자를 받고 있다.
      1. this.#lastQueryWithDefinedData?.state.data: 이전에 데이터가 정의된 마지막 쿼리의 데이터 (#lastQueryWithDefinedDataupdateResult 내에서 현재 쿼리 객체로 바인딩된다.)

      2. this.#lastQueryWithDefinedData: 이전 쿼리 객체 자체.

        placeholderData: (previousData, previousQuery) => previousData
      • 해당 함수는 placeholderData를 동적으로 생성해서 반환한다. 반환된 값이 placeholderData 가 된다.
    • 값일 경우: options.placeholderData 값을 그대로 사용.
  • 그리고 마지막으로 select가 정의되어 있으면, placeholderData를 변환하는 함수로 처리한다.

💡 결론적으로 페이지가 변경될 때마다 신규 데이터를 불러올 때 기존에는 불러오기 전까지는 undefined 였다면 placeholderData 를 사용해서 불러오기 전까지 기존 데이터를 보여주게 함으로써 유저 입장에서는 깜빡임 없이 신규 데이터를 바로 볼 수 있도록 된 것이다.


2. initialData

  • 정의: 쿼리가 실행되기 전, 초기 데이터 상태를 설정하는 옵션이다.
  • 사용 목적: 이미 사용자가 알고 있는 데이터나 서버 상태와 동기화하기 위해, 로컬에서 캐싱된 데이터를 기본값으로 설정한다.
  • 특징:
    1. 초기 값 제공: initialDataQuery Cache에 저장되며, 데이터가 새로 로드되기 전까지는 해당 값을 반환한다.
    2. 캐싱 가능: 제공된 데이터는 queryKey를 기준으로 캐싱되며, 다음 호출 시 기본값으로 사용될 수 있다.
    3. 동적인 값 가능: 함수 형태로 제공하여 queryKey에 기반한 초기 데이터를 반환할 수 있다. (placeholderData 랑 동일)

placeholderData 랑 가장 큰 차이점은 캐시에 저장되는 지에 대한 여부이다. 그리고 initialData는 캐시 레벨에서 작동하는 반면, placeholderData는 옵저버 레벨에서 작동한다.

캐시 레벨에서 작동하는 initialData:

  • 캐시의 쿼리 상태에 영향을 미치므로, 초기 데이터가 전역적으로 공유된다.
  • 동일한 queryKey를 사용하는 모든 옵저버에서 동일한 초기 상태를 볼 수 있다.

옵저버 레벨에서 작동하는 placeholderData :

  • 캐시 상태를 변경하지 않으므로, 데이터는 특정 옵저버의 내부 상태에서만 관리된다.
  • 해당 데이터는 옵저버 레벨에서 작동하기 때문에 이론적으로 컴포넌트 별로 서로 다른 placeholderData를 갖고 있을 수 있다.

initialData는 실제 캐시에 들어갈 만큼 좋은 수준의 데이터이고 또 유효한 데이터이기 때문에, staleTime을 따른다.

그러니까 initialData 는 캐시 레벨에서 동작하기 때문에 캐시에 저장이 되고 동일한 쿼리키를 사용하는 모든 옵저버에서 동일한 초기 상태를 볼 수 있다.

자 내부 구현체를 또 보자!

내가 궁금한거는 initialData 를 쿼리 캐시에 저장하는 로직이다.

queryCache.ts 파일을 보자.

  • build: 해당 메서드는 캐시에서 쿼리를 검색하거나 새로 생성하여 저장한다.
  • add: 새 쿼리를 캐시에 추가한다.
  • get: queryHash로 쿼리를 검색한다.
export class QueryCache extends Subscribable<QueryCacheListener> {
  #queries: QueryStore

  constructor(public config: QueryCacheConfig = {}) {
    super()
    this.#queries = new Map<string, Query>()
  }

  build<
    TQueryFnData = unknown,
    TError = DefaultError,
    TData = TQueryFnData,
    TQueryKey extends QueryKey = QueryKey,
  >(
    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({
        cache: this,
        queryKey,
        queryHash,
        options: client.defaultQueryOptions(options),
        state,
        defaultOptions: client.getQueryDefaults(queryKey),
      })
      this.add(query) // 캐시에 추가
    }

    return query
  }
  
  add(query: Query<any, any, any, any>): void {
    if (!this.#queries.has(query.queryHash)) {
      this.#queries.set(query.queryHash, query)

      this.notify({
        type: 'added',
        query,
      })
    }
  }

  get<
    TQueryFnData = unknown,
    TError = DefaultError,
    TData = TQueryFnData,
    TQueryKey extends QueryKey = QueryKey,
  >(
    queryHash: string,
  ): Query<TQueryFnData, TError, TData, TQueryKey> | undefined {
    return this.#queries.get(queryHash) as
      | Query<TQueryFnData, TError, TData, TQueryKey>
      | undefined
  }
}

build 메서드를 보면 신규 queryKey 기반으로 쿼리 인스턴스가 없다면 new Query() 를 통해 인스터스를 신규로 생성해주고 있다.

Query 클래스를 보자. (중요 부분만 남겼다.)

생성자에서 this.state = config.state ?? this.#initialState 를 주목하자.

//...생략

constructor(config: QueryConfig<TQueryFnData, TError, TData, TQueryKey>) {
    super()

    this.#abortSignalConsumed = false
    this.#defaultOptions = config.defaultOptions
    this.setOptions(config.options)
    this.observers = []
    this.#cache = config.cache
    this.queryKey = config.queryKey
    this.queryHash = config.queryHash
    this.#initialState = getDefaultState(this.options)
    this.state = config.state ?? this.#initialState
    this.scheduleGc()
  }
  
//.. 생략
  
function getDefaultState<
  TQueryFnData,
  TError,
  TData,
  TQueryKey extends QueryKey,
>(
  options: QueryOptions<TQueryFnData, TError, TData, TQueryKey>,
): QueryState<TData, TError> {
  const data =
    typeof options.initialData === 'function'
      ? (options.initialData as InitialDataFunction<TData>)()
      : options.initialData

  const hasData = data !== undefined

  const initialDataUpdatedAt = hasData
    ? typeof options.initialDataUpdatedAt === 'function'
      ? (options.initialDataUpdatedAt as () => number | undefined)()
      : options.initialDataUpdatedAt
    : 0

  return {
    data,
    dataUpdateCount: 0,
    dataUpdatedAt: hasData ? (initialDataUpdatedAt ?? Date.now()) : 0,
    error: null,
    errorUpdateCount: 0,
    errorUpdatedAt: 0,
    fetchFailureCount: 0,
    fetchFailureReason: null,
    fetchMeta: null,
    isInvalidated: false,
    status: hasData ? 'success' : 'pending',
    fetchStatus: 'idle',
  }
}

getDefaultState 함수에서 options.initialData 를 확인하고 data 값에 넣고 리턴해주고 있다.

즉, #initialStateinitialData 값을 가지고 있게 된다. (getDefaultState 함수는 initialData를 기반으로 초기 상태(QueryState)를 생성)

this.state = config.state ?? this.#initialState

그리고 config.state가 없다면 #initialState 로 초기값으로 설정해주고 있다.

💡 정리하자면
1. QueryCachebuild 메서드를 수행할 때 특정 쿼리키 기반으로 쿼리 인스턴스가 없다면 신규 생성한다.
2. new Query 를 통해 신규 인스턴스를 생성할 때 getDefaultState 함수에서 initialData 가 있을 경우 해당 값으로 기반으로 상태를 생성한다.
3. 그리고 new Query 를 할 때 전달 받은 config.state 가 없다면 getDefaultState 함수를 기반으로 생성된 상태가 해당 쿼리의 상태가 된다.


차이점 확인

다음과 같이 staleTime 을 1분으로 두자.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000,
    },
  },
})

1. placeholderData 적용

확실히 확인을 위해 api 요청 부분에 인위적으로 5초 딜레이를 줬다.

import { useQuery } from '@tanstack/react-query'

const delay = (ms: number) => new Promise(resolve => setTimeout(resolve, ms));

const getHotCoffee = async () => {
  await delay(5000); // 5초 딜레이
  const res = await fetch('https://api.sampleapis.com/coffee/hot');
  return res.json();
};

const QueryPlay = () =>{

  const {data} = useQuery({
    queryKey: ['hotCoffee'],
    queryFn: getHotCoffee,
    placeholderData: [{id: 1, title: 'no coffee', image: "https://images.unsplash.com/photo-1494314671902-399b18174975?auto=format&fit=crop&q=80&w=1887&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", description: "Svart kaffe är så enkelt som det kan bli med malda kaffebönor dränkta i hett vatten, serverat varmt. Och om du vill låta fancy kan du kalla svart kaffe med sitt rätta namn: café noir." }]
  })

  return (
    <main>
      <h1>Coffee</h1>
      <div>
        {data?.map((coffee: any) => <div key={coffee.id}>
          <h3>{coffee.title}</h3>
          <p>{coffee.description}</p>
          <img src={coffee.image} style={{width: "100px"}} />
        </div>)}
      </div>
    </main>

  )
}

export default QueryPlay;

화면을 확인하면 api 요청이 바로 이뤄지는 것을 확인할 수 있다. Fetching 상태일 때는 placeholderData 가 노출된다.

왜일까? placeholderData 는 캐시에 저장되지 않는다. 그냥 임시 데이터일뿐이다. 실제 데이터가 아니다. 따라서 실제 데이터를 가져오는 것이다.

5초가 지난 뒤에 요청이 완료되면 해당 값으로 업데이트가 된다. 그리고 해당 데이터는 당연히 캐시에 저장될 것이다.

2. initialData 적용

이제 위 코드에서 다음과 같이 변경해주자.

  const {data} = useQuery({
    queryKey: ['hotCoffee'],
    queryFn: getHotCoffee,
    initialData: [{id: 1, title: 'no coffee', image: "https://images.unsplash.com/photo-1494314671902-399b18174975?auto=format&fit=crop&q=80&w=1887&ixlib=rb-4.0.3&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D", description: "Svart kaffe är så enkelt som det kan bli med malda kaffebönor dränkta i hett vatten, serverat varmt. Och om du vill låta fancy kan du kalla svart kaffe med sitt rätta namn: café noir." }]
  })

initialDataFresh 한 데이터로 취급되고 캐시에 저장되기 때문에 최초에 접근하면 api 요청을 하지 않는다.

staleTime 이 1분으로 설정되어 있기 때문에 해당 시간이 지나고 나서 새로 fetch를 하게 되는 것이다.

최초로 페이지에 접근하면 이미 데이터가 Fresh 한 상태로 취급된다.

1분이 지나면 다음과 같이 stale 상태가 된다.

이제 뒤로가기를 통해 이전 페이지를 갔다 다시 해당 페이지를 접근하면 다시 api 요청을 하는 것을 확인할 수 있다.

실제 코드를 작성하고 확인하니 확실히 이해가 잘 된다!

마지막으로 tanstack-query의 제작자는 다음과 같이 구분해서 사용한다고 한다.

💡 항상 그렇듯이, 이는 전적으로 여러분께 달렸습니다. 저는 개인적으로 다른 쿼리로부터 어떤 쿼리를 미리 채워놓을 때 initialData를 사용하고 그 외에는 placeholderData를 사용하는 것을 선호합니다.


3. 간단 비교: placeholderData vs initialData

특징placeholderDatainitialData
역할임시 데이터 제공초기 상태로 사용할 데이터 제공
캐싱 여부캐싱되지 않음Query Cache에 저장됨
실제 데이터 로드 시대체되며 더 이상 사용되지 않음최신 데이터로 대체되더라도 캐시에 남음
용도로딩 중 사용자 경험 개선초기 상태 동기화 및 성능 최적화
동적 값 지원가능 (placeholderData: () => ...)가능 (initialData: () => ...)

참고글

profile
그냥 하자

0개의 댓글