Skip to content

Commit 3fce2ec

Browse files
feat: add placeholderData to queryObserver (#1161)
* feat: add placeholderData to queryObserver * Add docs and a few tests (one failing) * Add isPlaceholderData * Update api.md
1 parent 6e30aaa commit 3fce2ec

File tree

7 files changed

+130
-18
lines changed

7 files changed

+130
-18
lines changed

docs/src/pages/docs/api.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ const {
2626
enabled,
2727
initialData,
2828
initialStale,
29+
placeholderData,
2930
isDataEqual,
3031
keepPreviousData,
3132
notifyOnStatusChange,
@@ -137,6 +138,12 @@ const queryInfo = useQuery({
137138
- Optional
138139
- If set, this will mark any `initialData` provided as stale and will likely cause it to be refetched on mount
139140
- If a function is passed, it will be called only when appropriate to resolve the `initialStale` value. This can be useful if your `initialStale` value is costly to calculate.
141+
- `initialData` **is persisted** to the cache
142+
- `placeholderData: any | Function() => any`
143+
- Optional
144+
- If set, this value will be used as the placeholder data for this particular query instance while the query is still in the `loading` data and no initialData has been provided.
145+
- If set to a function, the function will be called **once** during the shared/root query initialization, and be expected to synchronously return the initialData
146+
- `placeholderData` is **not persisted** to the cache
140147
- `keepPreviousData: Boolean`
141148
- Optional
142149
- Defaults to `false`
@@ -176,6 +183,8 @@ const queryInfo = useQuery({
176183
- Will be `true` if the cache data is stale.
177184
- `isPreviousData: Boolean`
178185
- Will be `true` when `keepPreviousData` is set and data from the previous query is returned.
186+
- `isPlaceholderData: Boolean`
187+
- Will be `true` if and when the query's `data` is equal to the result of the `placeholderData` option.
179188
- `isFetchedAfterMount: Boolean`
180189
- Will be `true` if the query has been fetched after the component mounted.
181190
- This property can be used to not show any previously cached data.

src/core/queryObserver.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@ import {
66
noop,
77
} from './utils'
88
import { notifyManager } from './notifyManager'
9-
import type { QueryConfig, QueryResult, ResolvedQueryConfig } from './types'
9+
import type {
10+
QueryConfig,
11+
QueryResult,
12+
ResolvedQueryConfig,
13+
PlaceholderDataFunction,
14+
} from './types'
15+
import { QueryStatus } from './types'
1016
import type { Query, Action, FetchMoreOptions, RefetchOptions } from './query'
1117
import { DEFAULT_CONFIG, isResolvedQueryConfig } from './config'
1218

@@ -243,6 +249,7 @@ export class QueryObserver<TResult, TError> {
243249
const { state } = this.currentQuery
244250
let { data, status, updatedAt } = state
245251
let isPreviousData = false
252+
let isPlaceholderData = false
246253

247254
// Keep previous data if needed
248255
if (
@@ -256,6 +263,19 @@ export class QueryObserver<TResult, TError> {
256263
isPreviousData = true
257264
}
258265

266+
if (status === 'loading' && this.config.placeholderData) {
267+
const placeholderData =
268+
typeof this.config.placeholderData === 'function'
269+
? (this.config.placeholderData as PlaceholderDataFunction<TResult>)()
270+
: this.config.placeholderData
271+
272+
if (typeof placeholderData !== 'undefined') {
273+
status = QueryStatus.Success
274+
data = placeholderData
275+
isPlaceholderData = true
276+
}
277+
}
278+
259279
this.currentResult = {
260280
...getStatusProps(status),
261281
canFetchMore: state.canFetchMore,
@@ -270,6 +290,7 @@ export class QueryObserver<TResult, TError> {
270290
isFetchingMore: state.isFetchingMore,
271291
isInitialData: state.isInitialData,
272292
isPreviousData,
293+
isPlaceholderData,
273294
isStale: this.isStale,
274295
refetch: this.refetch,
275296
remove: this.remove,

src/core/tests/queryCache.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -847,4 +847,33 @@ describe('queryCache', () => {
847847
consoleMock.mockRestore()
848848
})
849849
})
850+
851+
describe('QueryObserver', () => {
852+
test('uses placeholderData as non-cache data when loading a query with no data', async () => {
853+
const key = queryKey()
854+
const cache = new QueryCache()
855+
const observer = cache.watchQuery(key, { placeholderData: 'placeholder' })
856+
857+
expect(observer.getCurrentResult()).toMatchObject({
858+
status: 'success',
859+
data: 'placeholder',
860+
})
861+
862+
const results: QueryResult<unknown>[] = []
863+
864+
observer.subscribe(x => {
865+
results.push(x)
866+
})
867+
868+
await cache.fetchQuery(key, async () => {
869+
await sleep(100)
870+
return 'data'
871+
})
872+
873+
expect(results[0].data).toBe('data')
874+
875+
observer.unsubscribe()
876+
cache.clear()
877+
})
878+
})
850879
})

src/core/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export type TypedQueryFunction<
2626
export type TypedQueryFunctionArgs = readonly [unknown, ...unknown[]]
2727

2828
export type InitialDataFunction<TResult> = () => TResult | undefined
29+
export type PlaceholderDataFunction<TResult> = () => TResult | undefined
2930

3031
export type InitialStaleFunction = () => boolean
3132

@@ -49,6 +50,7 @@ export interface BaseQueryConfig<TResult, TError = unknown, TData = TResult> {
4950
queryKeySerializerFn?: QueryKeySerializerFunction
5051
queryFnParamsFilter?: (args: ArrayQueryKey) => ArrayQueryKey
5152
initialData?: TResult | InitialDataFunction<TResult>
53+
placeholderData?: TResult | InitialDataFunction<TResult>
5254
infinite?: true
5355
/**
5456
* Set this to `false` to disable structural sharing between query results.
@@ -204,6 +206,7 @@ export interface QueryResultBase<TResult, TError = unknown> {
204206
isInitialData: boolean
205207
isLoading: boolean
206208
isPreviousData: boolean
209+
isPlaceholderData: boolean
207210
isStale: boolean
208211
isSuccess: boolean
209212
refetch: (options?: RefetchOptions) => Promise<TResult | undefined>

src/react/tests/useInfiniteQuery.test.tsx

Lines changed: 23 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ const initialItems = (page: number): Result => {
2020
}
2121
}
2222

23-
const fetchItems = async (page: number, ts: number, nextId?: any): Promise<Result> => {
23+
const fetchItems = async (
24+
page: number,
25+
ts: number,
26+
nextId?: any
27+
): Promise<Result> => {
2428
await sleep(10)
2529
return {
2630
items: [...new Array(10)].fill(null).map((_, d) => page * pageSize + d),
@@ -74,6 +78,7 @@ describe('useInfiniteQuery', () => {
7478
isInitialData: true,
7579
isLoading: true,
7680
isPreviousData: false,
81+
isPlaceholderData: false,
7782
isStale: true,
7883
isSuccess: false,
7984
refetch: expect.any(Function),
@@ -104,6 +109,7 @@ describe('useInfiniteQuery', () => {
104109
isInitialData: false,
105110
isLoading: false,
106111
isPreviousData: false,
112+
isPlaceholderData: false,
107113
isStale: true,
108114
isSuccess: true,
109115
refetch: expect.any(Function),
@@ -1067,7 +1073,7 @@ describe('useInfiniteQuery', () => {
10671073
it('should compute canFetchMore correctly for falsy getFetchMore return value on refetching', async () => {
10681074
const key = queryKey()
10691075
const MAX = 2
1070-
1076+
10711077
function Page() {
10721078
const fetchCountRef = React.useRef(0)
10731079
const [isRemovedLastPage, setIsRemovedLastPage] = React.useState<boolean>(
@@ -1096,7 +1102,7 @@ describe('useInfiniteQuery', () => {
10961102
getFetchMore: (lastGroup, _allGroups) => lastGroup.nextId,
10971103
}
10981104
)
1099-
1105+
11001106
return (
11011107
<div>
11021108
<h1>Pagination</h1>
@@ -1145,55 +1151,55 @@ describe('useInfiniteQuery', () => {
11451151
</div>
11461152
)
11471153
}
1148-
1154+
11491155
const rendered = render(<Page />)
1150-
1156+
11511157
rendered.getByText('Loading...')
1152-
1158+
11531159
await waitFor(() => {
11541160
rendered.getByText('Item: 9')
11551161
rendered.getByText('Page 0: 0')
11561162
})
1157-
1163+
11581164
fireEvent.click(rendered.getByText('Load More'))
1159-
1165+
11601166
await waitFor(() => rendered.getByText('Loading more...'))
1161-
1167+
11621168
await waitFor(() => {
11631169
rendered.getByText('Item: 19')
11641170
rendered.getByText('Page 0: 0')
11651171
rendered.getByText('Page 1: 1')
11661172
})
1167-
1173+
11681174
fireEvent.click(rendered.getByText('Load More'))
1169-
1175+
11701176
await waitFor(() => rendered.getByText('Loading more...'))
1171-
1177+
11721178
await waitFor(() => {
11731179
rendered.getByText('Item: 29')
11741180
rendered.getByText('Page 0: 0')
11751181
rendered.getByText('Page 1: 1')
11761182
rendered.getByText('Page 2: 2')
11771183
})
1178-
1184+
11791185
rendered.getByText('Nothing more to load')
1180-
1186+
11811187
fireEvent.click(rendered.getByText('Remove Last Page'))
11821188

11831189
await waitForMs(10)
11841190

11851191
fireEvent.click(rendered.getByText('Refetch'))
11861192

11871193
await waitFor(() => rendered.getByText('Background Updating...'))
1188-
1194+
11891195
await waitFor(() => {
11901196
rendered.getByText('Page 0: 3')
11911197
rendered.getByText('Page 1: 4')
11921198
})
1193-
1199+
11941200
expect(rendered.queryByText('Item: 29')).toBeNull()
11951201
expect(rendered.queryByText('Page 2: 5')).toBeNull()
1196-
1202+
11971203
rendered.getByText('Nothing more to load')
11981204
})
11991205
})

src/react/tests/usePaginatedQuery.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ describe('usePaginatedQuery', () => {
4444
isInitialData: true,
4545
isLoading: true,
4646
isPreviousData: false,
47+
isPlaceholderData: false,
4748
isStale: true,
4849
isSuccess: false,
4950
latestData: undefined,
@@ -70,6 +71,7 @@ describe('usePaginatedQuery', () => {
7071
isInitialData: false,
7172
isLoading: false,
7273
isPreviousData: false,
74+
isPlaceholderData: false,
7375
isStale: true,
7476
isSuccess: true,
7577
latestData: 1,

src/react/tests/useQuery.test.tsx

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,7 @@ describe('useQuery', () => {
136136
isInitialData: true,
137137
isLoading: true,
138138
isPreviousData: false,
139+
isPlaceholderData: false,
139140
isStale: true,
140141
isSuccess: false,
141142
refetch: expect.any(Function),
@@ -160,6 +161,7 @@ describe('useQuery', () => {
160161
isInitialData: false,
161162
isLoading: false,
162163
isPreviousData: false,
164+
isPlaceholderData: false,
163165
isStale: true,
164166
isSuccess: true,
165167
refetch: expect.any(Function),
@@ -214,6 +216,7 @@ describe('useQuery', () => {
214216
isInitialData: true,
215217
isLoading: true,
216218
isPreviousData: false,
219+
isPlaceholderData: false,
217220
isStale: true,
218221
isSuccess: false,
219222
refetch: expect.any(Function),
@@ -238,6 +241,7 @@ describe('useQuery', () => {
238241
isInitialData: true,
239242
isLoading: true,
240243
isPreviousData: false,
244+
isPlaceholderData: false,
241245
isStale: true,
242246
isSuccess: false,
243247
refetch: expect.any(Function),
@@ -262,6 +266,7 @@ describe('useQuery', () => {
262266
isInitialData: true,
263267
isLoading: false,
264268
isPreviousData: false,
269+
isPlaceholderData: false,
265270
isStale: true,
266271
isSuccess: false,
267272
refetch: expect.any(Function),
@@ -2361,4 +2366,41 @@ describe('useQuery', () => {
23612366
await waitFor(() => rendered.getByText('data'))
23622367
expect(queryFn).toHaveBeenCalledTimes(1)
23632368
})
2369+
2370+
it('should use placeholder data while the query loads', async () => {
2371+
const key1 = queryKey()
2372+
2373+
const states: QueryResult<string>[] = []
2374+
2375+
function Page() {
2376+
const state = useQuery(key1, () => 'data', {
2377+
placeholderData: 'placeholder',
2378+
})
2379+
2380+
states.push(state)
2381+
2382+
return (
2383+
<div>
2384+
<h2>Data: {state.data}</h2>
2385+
<div>Status: {state.status}</div>
2386+
</div>
2387+
)
2388+
}
2389+
2390+
const rendered = render(<Page />)
2391+
await waitFor(() => rendered.getByText('Data: data'))
2392+
2393+
expect(states).toMatchObject([
2394+
{
2395+
isSuccess: true,
2396+
isPlaceholderData: true,
2397+
data: 'placeholder',
2398+
},
2399+
{
2400+
isSuccess: true,
2401+
isPlaceholderData: false,
2402+
data: 'data',
2403+
},
2404+
])
2405+
})
23642406
})

0 commit comments

Comments
 (0)