Connectivity
The network mode guide says what a query does while the client believes it is offline. This one is about the belief: where it comes from, and why the obvious source is not quite enough.
Nothing is installed by default. Neither package depends on a connectivity plugin, so a client nobody tells otherwise believes it is online — the same as TanStack Query with no listener. A request that cannot reach the network then simply fails and is retried like any other failure. For many apps that is fine. An app used on the move, where "no signal" is normal, gets a better experience from pausing instead: queries that wait, writes that queue, and everything continuing when the signal returns.
OnlineStatus
QueryClientProvider takes an onlineStatus, one value with two modes:
OnlineStatus.fixed(online) | this is the state, with no source of changes — a test, a desktop build, a developer's offline switch |
OnlineStatus.stream(changes, initial: …) | follow changes, and assume initial until the first event |
null (the default) | bring nothing: the client keeps believing it is online |
initial is required on the stream form because a Stream has no
current value. A provider that only listened would start out believing the
default — online — however long the first event took, and an app launched in
airplane mode would fetch once against a network that is not there.
With connectivity_plus
The plugin stays your dependency. Ask it once for the current state, then follow its changes:
// lib/main.dart
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
final connectivity = Connectivity();
bool isOnline(List<ConnectivityResult> results) =>
!results.contains(ConnectivityResult.none);
// Built once, outside `build`: a new stream on every rebuild would be
// listened to again each time.
final changes = connectivity.onConnectivityChanged.map(isOnline);
final online = isOnline(await connectivity.checkConnectivity());
runApp(
QueryClientProvider.create(
create: QueryClient.new,
onlineStatus: OnlineStatus.stream(changes, initial: online),
child: const MyApp(),
),
);
}
A broadcast stream always works. A single-subscription one works only while
exactly one provider listens to it, once — no remount, no second provider,
no switching away and back — and a second listen fails with a FlutterError
that points to asBroadcastStream(). Wrap it when in doubt.
Fixed
With no stream at all, a fixed status is the whole verdict. A changed value reaches the client on the rebuild that changes it — a developer setting that simulates offline, for instance:
QueryClientProvider(
client: client,
onlineStatus: OnlineStatus.fixed(online),
child: const MyApp(),
),
When the status changes hands
- A swapped client under the same provider inherits the last value the stream reported.
- Taking the status away (setting it to
null) or disposing the provider puts the client back online — once no other provider has a status for that client. - A replacement provider on the same client, under a new key or moved to another parent, mounts before the old one goes and keeps its own verdict.
A link is not reachability
connectivity_plus reports a link: Wi-Fi joined, a cellular bearer up. It
does not say that a request will get through. A phone on hotel Wi-Fi behind
a captive portal reports "connected"; so does one whose router has lost its
uplink, and so does every phone when your backend is down. In each case the
client believes it is online, requests fail, and the retries run.
That is not a disaster — it is what happens with no connectivity source at
all — but an app that wants "offline" to mean "cannot reach us" can ask.
OnlineStatus.stream takes any Stream<bool>, so a reachability check is
one more stream: online when the link is up and a probe of the backend
answers.
/// `true` while the link is up *and* [probe] reaches the backend. Probes again
/// every [recheck] while the link is up: a captive portal or a server outage
/// ends without the link changing, and the last answer stands until the
/// next one arrives. A probe that throws or takes longer than [timeout]
/// counts as unreachable.
///
/// A single-subscription stream: hand it to one provider's
/// `OnlineStatus.stream`.
Stream<bool> reachability(
Stream<bool> link,
Future<bool> Function() probe, {
Duration recheck = const Duration(seconds: 20),
Duration timeout = const Duration(seconds: 5),
}) {
StreamSubscription<bool>? linkChanges;
Timer? timer;
var linkUp = false;
var cancelled = false;
// Moves on every link change: an answer to a probe started before it is
// stale.
var epoch = 0;
var probing = false;
late final StreamController<bool> out;
Future<bool> ask() async {
try {
return await probe().timeout(timeout, onTimeout: () => false);
} on Object {
return false;
}
}
Future<void> check() async {
if (cancelled) return;
if (!linkUp) {
out.add(false);
return;
}
if (probing) return; // one probe at a time; a recheck waits for it
probing = true;
final asked = epoch;
final reachable = await ask();
probing = false;
if (asked == epoch) {
if (!cancelled) out.add(reachable);
} else {
await check(); // the link changed meanwhile: ask again
}
}
out = StreamController<bool>(
onListen: () {
linkChanges = link.listen((up) {
linkUp = up;
epoch++;
check().ignore();
});
timer = Timer.periodic(recheck, (_) => check().ignore());
},
onCancel: () {
cancelled = true;
timer?.cancel();
return linkChanges?.cancel();
},
);
return out.stream.distinct();
}
The re-probe is the part that is easy to forget. Without it, a probe that failed behind a captive portal leaves the app offline until the link changes — and logging in to the portal does not change the link.
The probe itself is one cheap request with a short timeout. With
package:http:
// lib/data/reachability.dart
Future<bool> probeBackend() async {
try {
final response = await http
.head(Uri.parse('https://api.example.com/health'))
.timeout(const Duration(seconds: 3));
// Over HTTPS a captive portal cannot answer for our host, so a 2xx
// is our server.
return response.statusCode >= 200 && response.statusCode < 300;
} on Exception {
return false;
}
}
and the two joined in main, the link from connectivity_plus as above:
final initial = online && await probeBackend();
runApp(
QueryClientProvider.create(
create: QueryClient.new,
onlineStatus: OnlineStatus.stream(
reachability(changes, probeBackend),
initial: initial,
),
child: const MyApp(),
),
);
reachability listens to the link stream again whenever its own listener
comes back, so give it a broadcast link stream.
Keep the probe's traffic in proportion: one HEAD every twenty seconds while
the link is up is small, but it is not nothing on a metered connection, and
it runs for as long as the provider listens, whether or not a screen needs
the network.
Without Flutter
A pure-Dart client has no provider to hand a status to. Its
onlineManager takes the same thing directly — an adapter over any source,
or a value set by hand:
client.onlineManager.setEventListener((setOnline) {
final subscription = reachable.listen(setOnline);
return subscription.cancel;
});
// …or by hand:
client.onlineManager.setOnline(false);
Resuming paused work needs a mounted client (client.mount()); see
pure Dart.
Testing offline
OnlineStatus.fixed(false) in a widget test's provider puts the client
offline from the first frame, and pumping a new provider with
OnlineStatus.fixed(true) brings it back. In the live demo on the
network mode page, the Online switch does the same
thing by hand: it calls client.onlineManager.setOnline.
TanStack Query listens to the browser's online and offline events by
default — which report a link, like connectivity_plus — and
onlineManager.setEventListener is how a React Native app installs
NetInfo. Here nothing is installed by default; the provider's
onlineStatus or onlineManager.setEventListener is where a source goes.
See differences from TanStack Query.