사내 관리자에 tanstack-query를 적용/개발할 때 리스트 화면에서 pagination 이동할 때 화면이 깜빡거리는 이슈가 발생했다.
페이지 변경할 때마다 useQuery
를 통해 api를 요청했다.
api 요청을 하고 가지고 오는 사이에 값이 없기 때문에 순간적으로 빈 화면이 노출되었다가 리스트가 보여지는 것이다. (값이 없을 때는 데이터가 없다는 화면을 노출해줬다)
이를 해결하기 위해 placeholderData
옵션에 keepPreviousData
함수를 넣어줌으로써 해결을 했다.
Tanstack Query에서는 placeholderData
와 비슷한 역할을 하는 initailData
가 존재한다.
Tanstack Query에서 placeholderData
와 initialData
는 비동기 데이터를 다루는 동안 UI의 사용자 경험을 개선하기 위해 사용된다. 두 개념 모두 데이터를 로드하기 전의 초기 상태를 설정하는 데 사용되지만, 용도와 동작 방식이 다르다.
한번 알아보자!
placeholderData
는 실제 데이터를 가져오면 대체된다.placeholderData
는 Query Cache에 저장되지 않으며, 실제 데이터와 교체 후에는 더 이상 유지되지 않는다. (이게 initialData와의 큰 차이다)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
으로 간주하고 호출한다. 이 함수는 두 가지 인자를 받고 있다.this.#lastQueryWithDefinedData?.state.data
: 이전에 데이터가 정의된 마지막 쿼리의 데이터 (#lastQueryWithDefinedData
는 updateResult
내에서 현재 쿼리 객체로 바인딩된다.)
this.#lastQueryWithDefinedData
: 이전 쿼리 객체 자체.
placeholderData: (previousData, previousQuery) => previousData
placeholderData
를 동적으로 생성해서 반환한다. 반환된 값이 placeholderData
가 된다.options.placeholderData
값을 그대로 사용.그리고 마지막으로 select
가 정의되어 있으면, placeholderData
를 변환하는 함수로 처리한다.
💡 결론적으로 페이지가 변경될 때마다 신규 데이터를 불러올 때 기존에는 불러오기 전까지는
undefined
였다면placeholderData
를 사용해서 불러오기 전까지 기존 데이터를 보여주게 함으로써 유저 입장에서는 깜빡임 없이 신규 데이터를 바로 볼 수 있도록 된 것이다.
initialData
는 Query Cache에 저장되며, 데이터가 새로 로드되기 전까지는 해당 값을 반환한다.queryKey
를 기준으로 캐싱되며, 다음 호출 시 기본값으로 사용될 수 있다.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
값에 넣고 리턴해주고 있다.
즉, #initialState
가 initialData
값을 가지고 있게 된다. (getDefaultState
함수는 initialData
를 기반으로 초기 상태(QueryState
)를 생성)
this.state = config.state ?? this.#initialState
그리고 config.state
가 없다면 #initialState
로 초기값으로 설정해주고 있다.
💡 정리하자면
1.QueryCache
의build
메서드를 수행할 때 특정 쿼리키 기반으로 쿼리 인스턴스가 없다면 신규 생성한다.
2.new Query
를 통해 신규 인스턴스를 생성할 때getDefaultState
함수에서initialData
가 있을 경우 해당 값으로 기반으로 상태를 생성한다.
3. 그리고new Query
를 할 때 전달 받은config.state
가 없다면getDefaultState
함수를 기반으로 생성된 상태가 해당 쿼리의 상태가 된다.
다음과 같이 staleTime
을 1분으로 두자.
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 60 * 1000,
},
},
})
확실히 확인을 위해 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초가 지난 뒤에 요청이 완료되면 해당 값으로 업데이트가 된다. 그리고 해당 데이터는 당연히 캐시에 저장될 것이다.
이제 위 코드에서 다음과 같이 변경해주자.
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." }]
})
initialData
는 Fresh
한 데이터로 취급되고 캐시에 저장되기 때문에 최초에 접근하면 api 요청을 하지 않는다.
staleTime
이 1분으로 설정되어 있기 때문에 해당 시간이 지나고 나서 새로 fetch를 하게 되는 것이다.
최초로 페이지에 접근하면 이미 데이터가 Fresh
한 상태로 취급된다.
1분이 지나면 다음과 같이 stale
상태가 된다.
이제 뒤로가기를 통해 이전 페이지를 갔다 다시 해당 페이지를 접근하면 다시 api 요청을 하는 것을 확인할 수 있다.
실제 코드를 작성하고 확인하니 확실히 이해가 잘 된다!
마지막으로 tanstack-query의 제작자는 다음과 같이 구분해서 사용한다고 한다.
💡 항상 그렇듯이, 이는 전적으로 여러분께 달렸습니다. 저는 개인적으로 다른 쿼리로부터 어떤 쿼리를 미리 채워놓을 때
initialData
를 사용하고 그 외에는placeholderData
를 사용하는 것을 선호합니다.
placeholderData
vs initialData
특징 | placeholderData | initialData |
---|---|---|
역할 | 임시 데이터 제공 | 초기 상태로 사용할 데이터 제공 |
캐싱 여부 | 캐싱되지 않음 | Query Cache에 저장됨 |
실제 데이터 로드 시 | 대체되며 더 이상 사용되지 않음 | 최신 데이터로 대체되더라도 캐시에 남음 |
용도 | 로딩 중 사용자 경험 개선 | 초기 상태 동기화 및 성능 최적화 |
동적 값 지원 | 가능 (placeholderData: () => ... ) | 가능 (initialData: () => ... ) |
참고글