Skip to main content

Initial query data

The user taps a light in the device list. The detail screen is about to fetch /devices/42 — but the list screen fetched that device a second ago, name, room and state included. Showing a spinner for data the app is already holding is a waste of the user's time.

When the app already has the data a query will fetch, hand it over as initial data. The query starts as a QuerySuccess with it, and no spinner is shown.

InitialData is written to the cache. It is indistinguishable from a fetch result: every reader of the key sees it, it is shared structurally with what the fetch later returns, and it ages under staleTime like fetched data. If it should not be believed — a skeleton, a partial object — you want placeholder data instead.

InitialData.value(v)this value
InitialData.compute(() => …)computed; returning null means "none". InitialData.value(null) is a value of null
initialDataUpdatedAt: DateTime?how old it is; null means now
initialDataUpdatedAtCompute: () => DateTime?the lazy form, evaluated only when the data is actually seeded. Give one form or the other, never both

Initial data is only a seed. It is used when the cache entry has no data — an entry that already holds data, fetched or seeded, keeps it.

Data that ships with the app​

The "add a device" picker lists every device type the gateway supports. The list changes a few times a year, so the app ships with a copy and asks the server for the current one in the background:

// The catalogue ships with the app, so the "add a device" picker never
// shows a spinner. Dated when it was bundled, it is older than a day on
// most phones, and the server's copy replaces it in the background.
QueryObserverOptions<List<DeviceType>> deviceTypesQuery() =>
QueryObserverOptions(
queryKey: QueryKey(<Object?>['device-types']),
queryFn: (context) => deviceRepository.types(signal: context.signal),
staleTime: const StaleTime.duration(Duration(days: 1)),
initialData: const InitialData.value(bundledDeviceTypes),
initialDataUpdatedAt: bundledDeviceTypesDate,
);

The date is what makes this right. Without it, the bundled copy would count as fetched now, stay fresh for a day, and the picker would show last release's catalogue until tomorrow. Dated when it was bundled, it is older than the staleTime on any phone that installed the app more than a day after the build — so the picker shows it at once and refetches behind it.

Seeding a detail from a list​

InitialData.compute can look in another cache entry. The task list's row becomes the task detail's first state:

QueryObserverOptions<Task> taskSeededFromList(QueryClient client, String id) =>
QueryObserverOptions(
queryKey: taskKey(id),
queryFn: (context) => api.getTask(id, signal: context.signal),
initialData: InitialData.compute(
() => client
.getQueryData<List<Task>>(tasksKey)
?.where((task) => task.id == id)
.firstOrNull,
),
// As old as the list it came from.
initialDataUpdatedAtCompute: () => client.queryCache
.find(filters: QueryFilters(queryKey: tasksKey))
?.state
.dataUpdatedAt,
);

Giving the list's dataUpdatedAt as the seed's age means the detail is as old as the list it came from, and goes stale at the same moment. The lazy form is asked only when a seed is actually written — not on every rebuild.

Only seed a detail from a list when the row holds everything the detail screen shows. A list endpoint that returns a summary — a name and a room, but not the device's settings — seeds a detail with gaps that the screen then renders as real. That is a placeholder, not initial data.

Only when the list is recent​

A device's state changes on its own — a shutter moves, a light is switched at the wall. A list fetched ten minutes ago is not the device's current state, and showing it as such, even for the length of a refetch, flickers between the wrong state and the right one. Seed only from a recent list:

QueryObserverOptions<Device> deviceSeededIfRecent(
QueryClient client,
String id,
) =>
QueryObserverOptions(
queryKey: DeviceKeys.detail(id),
queryFn: (context) => deviceRepository.byId(id, signal: context.signal),
initialData: InitialData.compute(() {
final list = client.getQueryState<List<Device>>(DeviceKeys.list);
final updatedAt = list?.dataUpdatedAt;
// An old list is not worth showing as the device's current state.
if (updatedAt == null ||
DateTime.now().difference(updatedAt) >
const Duration(seconds: 10)) {
return null;
}
return list?.data?.where((device) => device.id == id).firstOrNull;
}),
);

Returning null means "no seed": the detail loads like any query.

InitialData.compute is asked again on every options update and every fetch until the entry holds data, so a seed that appears later — the list lands while the detail is still loading — still lands. Keep it cheap.

With staleTime​

With the default staleTime of zero, initial data is stale at once, so it is shown and refetched behind. With a staleTime, initial data younger than it is not refetched — which is why its age matters:

Seed datedstaleTimeOn mount
now (no date given)zeroshown, refetched at once
now (no date given)one minuteshown, no request; stale a minute later
the list's dataUpdatedAt, 40 s agoone minuteshown, no request; stale 20 s later, with the list
bundled, weeks agoone dayshown, refetched at once

Try it: in the screen below, open a post from card A within thirty seconds of the list loading. Its title shows at once from the list, and the strip's fetches= stays 0 — the seed is as old as the list, younger than the thirty-second staleTime. Turn on Treat initial data as old and open another: the title still shows at once, and one fetch follows. Card D does the same with the lazy date: fresh fetches nothing, backdated refetches straight away, and computeCalls= stays at 1 through every rebuild.

Live demoInitial and placeholder dataData before the first fetch: written to the cache, or shown only.~3 MB, runs in your browser; no server involved.

Other ways to have data before the screen opens​

Initial data lives in the options, so the query that needs it says where it comes from. The alternatives write to the cache from outside: prefetching fetches ahead of a navigation, and updates from mutation responses write what a write returned. Both are better when the data is not at hand at the moment the query is created.

In React Query

initialData takes a value or a function, and initialDataUpdatedAt a number of milliseconds or a function; here InitialData.value or InitialData.compute, with initialDataUpdatedAt a DateTime and initialDataUpdatedAtCompute its lazy form, as separate fields. See differences from TanStack Query.