Skip to content

Loading data

Apps often need to wait for data, then show a result—or an error—when it arrives. Use FutureBuilder for one result and StreamBuilder for values that keep arriving.

The source produces…Dart typeBuild with
One eventual resultFuture<T>FutureBuilder<T>
Values over timeStream<T>StreamBuilder<T>
App-owned values changed by actionsA field or notifierState management

The builders do not fetch, parse, cache, or save anything. They listen to an async source and pass its latest AsyncSnapshot<T> to your UI.

Use FutureBuilder when a request produces one result. This example fetches and decodes a real photo from Lorem Picsum, then renders the loading, error, and success states. It uses two packages:

Terminal window
dart pub add http image
You write · editable
class _PhotoViewerState extends State<PhotoViewer> {
late Future<img.Image> _photo = widget.loadPhoto();
void _reload() => setState(() => _photo = widget.loadPhoto());
@override
Widget build(BuildContext context) => FutureBuilder<img.Image>(
future: _photo,
builder: (context, snapshot) {
final loading = snapshot.connectionState == ConnectionState.waiting;
if (snapshot.hasError && !loading) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Could not load a photo.'),
Button(text: 'Retry', onPressed: _reload),
],
);
}
final photo = snapshot.data;
if (photo == null) return const Text('Loading a photo from the web…');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(loading ? 'Loading a new photo…' : 'Random landscape'),
SizedBox(
width: 48,
height: 10,
child: Image.decoded(
photo,
fit: ImageFit.cover,
semanticLabel: 'Random landscape photo',
),
),
Button(text: 'Load another', onPressed: loading ? null : _reload),
],
);
},
);
}
Live preview
Load one result, then refresh it
Live · interactive
click & type to interact ⓘ how this demo runs

Press Load a photo to mount PhotoViewer. The preview waits for that press so that opening this page sends no request to Lorem Picsum; the viewer itself starts loading as soon as it builds.

Create the future once and keep it in state, as _photo does. A future created in build would start a new request on every rebuild. widget.loadPhoto is fetchPhoto with a new seed each time.

Check for an error before reading data, and keep side effects out of the builder: it can run more than once for the same snapshot.

On browser surfaces and terminals with inline-image support, Image renders true pixels. Other terminals fall back to character art from the same decoded image.

To load again, replace the future inside setState. The builder switches to the new future and ignores a late result from the old one; the old request itself is not cancelled.

Until the new future completes, the snapshot keeps the previous data and error, with connectionState set to waiting. That is what keeps the last photo on screen while Load another fetches the next one. It is also why the viewer shows an error only when it is not loading: after a failed attempt, Retry shows the loading state instead of repeating the old error, and the disabled button prevents a second request while one is running.

A snapshot’s connectionState says where the source is: none before there is one, waiting, active (streams only), then done. data and error carry the latest outcome. Most views need five states: nothing requested, loading, failed, empty, and ready. Choose a state to connect a matching Future:

You write · editable
(String, String) describe(AsyncSnapshot<List<String>> snapshot) {
if (snapshot.connectionState == ConnectionState.none) {
return ('DISCONNECTED', 'Choose a source to begin.');
}
if (snapshot.connectionState == ConnectionState.waiting) {
return ('LOADING', 'Loading files…');
}
if (snapshot.hasError) return ('ERROR', 'Connection lost. Try again.');
final files = snapshot.requireData;
if (files.isEmpty) return ('EMPTY', 'The request completed with no files.');
return ('READY', '${files.length} files loaded');
}
Live preview
Choose the async result the builder should render
Live · interactive
click & type to interact ⓘ how this demo runs

describe reads the connection state before the error, for the same reason as the photo viewer, and AsyncStateCard draws its label and detail in a bordered card. Choose Error, then Loading: the card shows the new request, not the old failure.

Why the Error preview calls ..ignore()

A future that fails before anything listens reports an unhandled error, even if a builder subscribes a moment later. The preview creates its failed future ahead of the frame that renders it, so it marks the error as handled with ..ignore(). The builder still receives the error and renders the card. A real request is usually handed to a builder or awaited right away; if you start one early, handle its errors where you start it.

A Fleury app renders, reads input, and handles signals on one Dart isolate. Waiting on I/O costs nothing, but synchronous CPU work — decoding a large image, parsing a big JSON document, sorting or diffing a large dataset — holds all of it until the work finishes: no frames, no keystrokes, and not even Ctrl+C, which arrives as input. Move that work to another isolate with Isolate.run and give the resulting future to a builder as usual:

import 'dart:isolate';
import 'dart:typed_data';
import 'package:image/image.dart' as img;
Future<img.Image> decodePhotoInBackground(Uint8List bytes) => Isolate.run(
() =>
img.decodeImage(bytes) ??
(throw const FormatException('Response was not an image')),
);

To use it in the photo example, replace the return statement at the end of fetchPhoto, which calls img.decodeImage inline, with the background decode:

return decodePhotoInBackground(response.bodyBytes);

Isolate.run works in terminal apps and under fleury serve, where the app runs as a native process. A browser embed compiles to JavaScript, which has no isolates, so keep work there small — which is why the live photo example above decodes inline.

Use StreamBuilder when values keep arriving. This demo treats a tiny star-map transmission as five packets; each click emits a larger snapshot until the stream closes:

You write · editable
class _TransmissionViewState extends State<TransmissionView> {
var _transmission = Transmission();
late var _updates = _transmission.updates;
void _restart() {
_transmission.dispose();
setState(() {
_transmission = Transmission();
_updates = _transmission.updates;
});
}
@override
void dispose() {
_transmission.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => StreamBuilder<List<String>>(
key: ValueKey(_transmission),
stream: _updates,
initialData: const [],
builder: (context, snapshot) {
if (snapshot.hasError) {
return Button(text: 'Signal lost. Restart', onPressed: _restart);
}
final lines = snapshot.requireData;
final status = switch (snapshot.connectionState) {
ConnectionState.none => 'OFFLINE',
ConnectionState.waiting => 'CONNECTING',
ConnectionState.active => 'LIVE',
ConnectionState.done => 'COMPLETE',
};
final complete = snapshot.connectionState == ConnectionState.done;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$status · ${lines.length}/5 packets'),
for (final line in lines) Text(line),
Row(
children: [
Button(
text: 'Next packet',
onPressed: complete ? null : _transmission.receiveNext,
),
const SizedBox(width: 1),
Button(text: 'Restart', onPressed: _restart),
],
),
],
);
},
);
}
Live preview
Receive the star map one packet at a time
Live · interactive
click & type to interact ⓘ how this demo runs

Keep the stream in state so rebuilds do not subscribe again. initialData supplies the data for the first frame, while the snapshot is still waiting; the first packet makes it active, and closing the stream makes it done.

Restart creates a new transmission and stream. Like FutureBuilder, a StreamBuilder that switches streams keeps showing the last data until the new one emits. Keying the builder by the transmission starts it over from initialData instead.

The builder cancels its subscription when it switches streams or leaves the tree. It does not close the source: whoever creates the StreamController closes it, as dispose and _restart do here. Dart’s stream guide covers transforming, listening to, and creating streams in more depth.

Builders fit one subtree reflecting one async source. Move the work into an app-owned model or repository when widgets share it or you need caching, pagination, optimistic updates, coordinated sources, or offline policy. The UI can listen to that model with the tools in State management.