Skip to main content

App focus refetching

A user switches to another app, answers a message, comes back five minutes later. The device list on screen is five minutes old, and the thermostat it shows may have been turned down in the meantime. When the app returns to the foreground, every query that has a reader and stale data refetches — the data stays on screen while it does (see background fetching indicators).

That is refetchOnWindowFocus, and it takes a RefetchOn:

RefetchOnWhen the app comes back
RefetchOn.ifStalethe default: refetch if the data is stale
RefetchOn.alwaysrefetch even if the data is fresh
RefetchOn.neverdo not refetch
RefetchOn.when((query) => …)decide per query, at that moment; return one of the other three

Only queries with at least one reader are refetched. An entry whose screen is gone waits in the cache until something reads it again, and the refetchOnMount rule decides then. The same RefetchOn type drives refetchOnMount and refetchOnReconnect.

With the default staleTime of zero, every return refetches every screen's data. A staleTime is the usual way to make that rarer — data younger than it is fresh and skipped. See important defaults.

Try it: in the screen below, set Stale time to 0 — the screen starts at thirty seconds, so its data is fresh and a return leaves it alone. Turn App focused off and on again and watch entry A's serial= grow; set A's On focus to never and the same round trip leaves it alone.

Live demoFocus refetchWhat happens when the app comes back to the foreground.~3 MB, runs in your browser; no server involved.

Turning it off​

For every query, in the client's defaults:

QueryClient clientWithoutFocusRefetch() => QueryClient(
defaultOptions: const DefaultOptions(
queries: QueryDefaults(refetchOnWindowFocus: RefetchOn.never),
),
);

For one query, in its options:

// The settings form copies the device into its fields once. A refetch on
// return would not change what the user typed, but it would make the
// "discard changes" button compare against a newer device than the form.
QueryObserverOptions<Device> deviceSettingsQuery(String id) =>
QueryObserverOptions(
queryKey: DeviceKeys.detail(id),
queryFn: (context) => deviceRepository.byId(id, signal: context.signal),
refetchOnWindowFocus: RefetchOn.never,
);

An option set on the query wins over the client's default; see query options.

A rule of your own​

RefetchOn.when is asked per query each time the app comes back. Here, a return refetches only data older than a minute, whatever staleTime says for everything else:

const RefetchOn refetchIfOlderThanAMinute = RefetchOn.when(_olderThanAMinute);

RefetchOn _olderThanAMinute(Query<Object?> query) =>
query.isStaleByTime(const StaleTime.duration(Duration(minutes: 1)))
? RefetchOn.always
: RefetchOn.never;

A top-level function keeps the value const, so the options compare equal on every rebuild; an inline closure is a new value each time. See describing a query once.

What "focused" means in Flutter​

A browser tab has one focus event. A Flutter app has an AppLifecycleState, and QueryClientProvider maps every state the app reports onto the client's focus while it is mounted:

AppLifecycleStateFocused?
resumedyes
inactivedepends on the platform — see below
hidden, paused, detachedno

inactive means two different things:

  • On iOS, Android and Fuchsia it is a short interruption: the notification shade pulled down, a system dialog, the app switcher, an incoming call. The app counts as focused — treating each of those as a departure would refetch everything on the way back from a glance.
  • On macOS, Windows and Linux it is the window losing focus to another window, which is exactly the event this option is named after. The app counts as unfocused.

On the web, Flutter reports inactive when the browser window loses focus and hidden when the tab is hidden, and the platform is the one the browser runs on. A desktop browser therefore refetches when its window comes back to the front, and a phone's browser when the tab is shown again.

Without a provider — a pure-Dart client, or a widget test that builds none — nothing sets focus and the client counts as focused forever; see the mount contract.

Your own mapping​

isAppShown replaces the mapping, for an app whose idea of "looking at it" differs from the platform's:

QueryClientProvider(
client: client,
// Only a fully resumed app counts as focused, on every platform.
isAppShown: (state) => state == AppLifecycleState.resumed,
child: const MyApp(),
),

The mapping given on the latest build is the one in force; changing it needs no new client.

Your own focus source​

Some apps know better than the lifecycle — a desktop app with several windows, a kiosk that counts "someone is standing in front of it" as focus. The client's focus manager takes an adapter over any source:

void followWindowFocus(QueryClient client, Stream<bool> windowFocus) {
client.focusManager.setEventListener((setFocused) {
final subscription = windowFocus.listen(setFocused);
return subscription.cancel;
});
}

The adapter returns its cleanup. It is removed when the client unmounts and installed again when it mounts. client.focusManager.setFocused(bool) is the same report made by hand.

An adapter replaces the provider's lifecycle listener; it does not sit on top of it. Both write through setFocused, so with both installed the last one to report wins and neither sees the other. Turn the lifecycle off when you install your own:

QueryClientProvider(
client: client,
// The lifecycle listener and a setEventListener adapter are two sources
// of focus for one manager. Pick one.
observeAppLifecycle: false,
child: const MyApp(),
),

Skipping refetches after a short absence​

A phone's user leaves the app for two seconds to copy a code from a message. Refetching every screen on the way back is wasted traffic. The focus manager takes a threshold: an absence shorter than it does not start focus refetches.

QueryClient appClient() => QueryClient(
focusManager: AppFocusManager(
// A glance at another app is not a reason to reload every screen.
refetchMinBackgroundDuration: const Duration(seconds: 30),
),
);

The default is Duration.zero: every return refetches. The threshold holds back only new refetches. A fetch that paused while the app was in the background — a retry waiting for focus — continues on the way back whatever the threshold, and reconnecting is never affected. The manager belongs to the client from construction, so the threshold is set where the client is built.

Focus and other timers​

Focus decides two more things, both covered on their own pages:

  • Polling skips its turns while the app is unfocused, unless refetchIntervalInBackground is set. See polling.
  • Retries pause while the app is unfocused and continue when it comes back. See query retries.
In React Query

refetchOnWindowFocus takes true, false, 'always' or a function; here it is RefetchOn.ifStale, never, always and when. The browser's visibilitychange listener is TanStack Query's built-in focus source; here it is the app lifecycle, installed by QueryClientProvider, and focusManager.setEventListener has the same role as it does for a React Native app's AppState. refetchMinBackgroundDuration has no counterpart: TanStack Query refetches on every return. See differences from TanStack Query.