Lifecycle and connectivity wiring
Two facts decide when a query fetches on its own: whether the user is looking
at the app, and whether the network is there. The first comes for free —
QueryClientProvider follows the app lifecycle, so data that went stale while
the app was in the background refetches when it comes back. The second is not
installed at all: the client believes it is online until told otherwise, so a
phone in a tunnel fails every fetch and burns its retries. This recipe wires
both in main: connectivity from connectivity_plus, a lighter focus rule for
an app the user switches away from often, a probe for when a link is not
enough, and a switch to try the offline states from a debug build.
The finished code
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:query_kit_flutter/query_kit_flutter.dart';
import 'app/query_client.dart';
import 'data/api_client.dart';
import 'data/dio_product_api.dart';
import 'data/product_api.dart';
import 'features/products/product_list_screen.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final connectivity = Connectivity();
bool isOnline(List<ConnectivityResult> results) =>
!results.contains(ConnectivityResult.none);
// Built once, here: a stream made in `build` would be a new one on every
// rebuild, and the provider would listen again each time.
final changes = connectivity.onConnectivityChanged.map(isOnline);
// What is true right now, so an app started in flight mode does not
// fetch once against a network that is not there.
final online = isOnline(await connectivity.checkConnectivity());
final api = DioProductApi(ApiClient(baseUrl: 'https://api.example.com/v1'));
runApp(
ProductApiScope(
api: api,
child: QueryClientProvider.create(
create: createQueryClient,
onlineStatus: OnlineStatus.stream(changes, initial: online),
child: MaterialApp(
scaffoldMessengerKey: scaffoldMessengerKey,
home: const ProductListScreen(),
),
),
),
);
}
createQueryClient is the client from
A global error snackbar, and
DioProductApi the API from
Wiring dio or package:http. This is connectivity_plus
6, whose events are lists of ConnectivityResults — one per interface — so
"online" means "any interface but none".
How it works
OnlineStatus.streamfollows the link. Every value the stream sends tells the client whether it is online. While it is not, a query in the default network mode does not fetch: one with nothing cached stayspendingwithfetchStatus: paused, one with data keeps showing it. A mutation started offline waits.initialis what is true at start. A stream has no current value, and the first event can take a while.checkConnectivity()answers at once, so an app launched in flight mode starts offline instead of fetching once against no network.- The stream is built once. It is made in
main, beforerunApp. A stream built in abuildmethod would be a new stream on every rebuild, and the provider would listen again each time.onConnectivityChangedis a broadcast stream, so a remounted provider can listen again. - Coming back online resumes. When the stream says online again, paused
fetches continue, paused mutations are sent, and active queries whose data
is stale refetch (
refetchOnReconnect,RefetchOn.ifStaleby default). - The lifecycle needs nothing. The provider maps
AppLifecycleStateto the client's focus. Back in front, every active query whose data is stale refetches (refetchOnWindowFocus).
A calmer focus refetch
On a phone the user leaves the app for a notification and is back in five
seconds. With short staleTimes that is a refetch of every screen, every time.
AppFocusManager can ignore short absences:
QueryClient createClientWithFocusRules() => QueryClient(
// Back after less than a minute away? Not a reason to refetch the
// world. Paused work still resumes at once.
focusManager: AppFocusManager(
refetchMinBackgroundDuration: const Duration(minutes: 1),
),
);
Back after less than a minute, focus-triggered refetches are skipped. Fetches that were paused still resume at once — they are waiting for the app, not refreshing it.
When a link is not enough
connectivity_plus reports a link: a phone on hotel wifi behind a captive
portal is "connected", and so is one whose mobile data has run out. When that
matters, ask your own backend:
/// Asks [probe] — "can I reach my own backend?" — every [every], and says
/// whenever the answer changes.
Stream<bool> reachability(
Future<bool> Function() probe, {
Duration every = const Duration(seconds: 30),
}) async* {
bool? last;
while (true) {
final reachable = await probe();
if (reachable != last) yield last = reachable;
await Future<void>.delayed(every);
}
}
probe is a cheap request — HEAD /health with a short timeout, returning
false on any error. The generator only sends a value when the answer
changes. It is a single-subscription stream, so make it broadcast before it
reaches a provider, and give it the link's verdict as its start:
// pingBackend: Future<bool> Function() — HEAD /health, false on any error.
final reachable = reachability(pingBackend).asBroadcastStream();
// … and in the provider, instead of the link's stream:
onlineStatus: OnlineStatus.stream(reachable, initial: online),
A debug switch for offline
Trying the offline states on a device means flight mode and waiting. In a debug build, a switch is quicker:
/// A developer's "pretend to be offline" switch, for trying the offline
/// states without a flight-mode dance.
final ValueNotifier<bool> simulateOffline = ValueNotifier<bool>(false);
class DebugOnlineSwitch extends StatelessWidget {
const DebugOnlineSwitch({
super.key,
required this.client,
required this.child,
});
final QueryClient client;
final Widget child;
Widget build(BuildContext context) => ValueListenableBuilder<bool>(
valueListenable: simulateOffline,
builder: (context, offline, _) => QueryClientProvider(
client: client,
onlineStatus: OnlineStatus.fixed(!offline),
child: child,
),
);
}
In a debug build (kDebugMode), use DebugOnlineSwitch in place of the
provider in main — with the client created there, createQueryClient() —
rather than around it: two providers giving one client a verdict would
overrule each other. Toggle simulateOffline from a debug menu. OnlineStatus.fixed is a verdict with no
source of changes; a changed one reaches the client on the rebuild that
changes it.
Try it: turn "Online" off in the demo and press "Refetch" — the query pauses instead of failing. Turn it back on and the paused work continues.
Traps
- Nothing is installed by default. Without an
onlineStatus, the client is online for ever. That is a safe default — a fetch that cannot reach the network fails and retries — but none of the pausing on this page happens. - A link is not reachability. A "connected" phone may reach nothing. Pair the link with a probe, or accept that a captive portal fails fetches instead of pausing them.
- Offline is not an error. A paused query is
pendingor shows its data, withisPausedtrue and no error. A screen that shows a spinner for everypendingspins until the network is back; show "Offline" whenisPaused. - A single-subscription stream fails on remount. The provider listens
again when it is rebuilt with a new stream or remounted. Pass a broadcast
stream, or wrap it in
asBroadcastStream(). - One focus source. The provider's lifecycle listener and a
setEventListenerof your own both write the client's focus. Turn one off (observeAppLifecycle: false) if you install the other.
Variations
- Per-query network mode. A query that talks to a local server or a
device on the LAN can say
networkMode: NetworkMode.alwaysand ignore the online status altogether. See Network mode. - Pause polling in the background. A
refetchIntervalstops while the app is not in front unlessrefetchIntervalInBackgroundsays otherwise; see Polling. - Desktop. On macOS, Windows and Linux, a window that loses focus counts as
unfocused; pass
isAppShownto the provider if your app reads the lifecycle differently.
React Native wires the same two managers by hand:
onlineManager.setEventListener with NetInfo, and focusManager.setFocused
from AppState. Here the focus side is built into the provider, and the
online side is one argument.
See also
- Connectivity —
OnlineStatusand its rules. - App focus refetching
— the lifecycle mapping and
refetchMinBackgroundDuration. - Network mode — what a paused query and a paused mutation do, and how they resume.