This guide is currently specific to the Dart/Flutter SDK. We may expand it to cover other SDKs in the future.
Our demo apps for Flutter are intentionally kept simple to focus on demonstrating PowerSync APIs. Instead of using heavy state management solutions, they use simple global fields to make the PowerSync database accessible to widgets.
When adopting PowerSync in your own app, you might want a more sophisticated approach for state management. This guide explains how PowerSync’s Dart/Flutter SDK integrates with popular state management packages.
Adopting PowerSync can actually simplify your app architecture by using a local SQLite database as the single source of truth for all data. For a general discussion on how PowerSync fits into modern app architecture, see this blog post.
PowerSync exposes database queries with the standard Future and Stream classes from dart:async. Given how widely used these are
in the Dart ecosystem, PowerSync works well with all popular approaches for state management, such as:
- Providers with
package:provider: Create your database as a Provider and expose watched queries to child widgets with StreamProvider!
The provider for databases should close() the database in dispose.
- Providers with
package:riverpod: We mention relevant snippets below.
- Dependency injection with
package:get_it: PowerSync databases can be registered with registerSingletonAsync. Again, make sure
to close() the database in the dispose callback.
- The BLoC pattern with the
bloc package: You can easily listen to watched queries in Cubits (although, if you find your
Blocs and Cubits becoming trivial wrappers around database streams, consider just watch()ing database queries in widgets directly.
That doesn’t make your app less testable!).
To simplify state management, avoid the use of hydrated blocs and cubits for state that depends on database queries. With PowerSync,
regular data is already available locally and doesn’t need a second local cache.
Riverpod
We have a complete example using PowerSync with modern Flutter libraries like Riverpod, Drift, and auto_route.
A good way to open PowerSync databases with Riverpod is to use an async provider. You can manage your connect and disconnect calls there, for instance by listening to authentication state:
@Riverpod(keepAlive: true)
Future<PowerSyncDatabase> powerSyncInstance(Ref ref) async {
final db = PowerSyncDatabase(
schema: schema,
path: await _getDatabasePath(),
logger: attachedLogger,
);
await db.initialize();
// TODO: Listen for auth changes and connect() the database here.
ref.listen(yourAuthProvider, (prev, next) {
if (next.isAuthenticated && !prev.isAuthenticated) {
db.connect(connector: MyConnector());
}
// ...
});
ref.onDispose(db.close);
return db;
}
Querying Data
To expose auto-updating query results, use a StreamProvider that reads from the database:
final _lists = StreamProvider((ref) async* {
final database = await ref.read(powerSyncInstanceProvider.future);
yield* database.watch('SELECT * FROM lists');
});
Waiting for sync
If you were awaiting waitForFirstSync before, you can keep doing that:
final db = await ref.read(powerSyncInstanceProvider.future);
await db.waitForFirstSync();
Alternatively, you can expose the sync status as a provider and use that to determine
whether the synchronization has completed:
final syncStatus = statefulProvider<SyncStatus>((ref, change) {
final status = Stream.fromFuture(ref.read(powerSyncInstanceProvider.future))
.asyncExpand((db) => db.statusStream);
final sub = status.listen(change);
ref.onDispose(sub.cancel);
return const SyncStatus();
});
@riverpod
bool didCompleteSync(Ref ref, [BucketPriority? priority]) {
final status = ref.watch(syncStatus);
if (priority != null) {
return status.statusForPriority(priority).hasSynced ?? false;
} else {
return status.hasSynced ?? false;
}
}
final class MyWidget extends ConsumerWidget {
const MyWidget({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final didSync = ref.watch(didCompleteSyncProvider());
if (!didSync) {
return const Text('Busy with sync...');
}
// ... content after first sync
}
}
Attachment queue
If you’re using the attachment queue helper to synchronize media assets, you can also wrap that in a provider:
@Riverpod(keepAlive: true)
Future<YourAttachmentQueue> attachmentQueue(Ref ref) async {
final db = await ref.read(powerSyncInstanceProvider.future);
final queue = YourAttachmentQueue(db, remoteStorage);
await queue.init();
return queue;
}
Reading and awaiting this provider can then be used to show attachments:
final class PhotoWidget extends ConsumerWidget {
final TodoItem todo;
const PhotoWidget({super.key, required this.todo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final photoState = ref.watch(_getPhotoStateProvider(todo.photoId));
if (!photoState.hasValue) {
return Container();
}
final data = photoState.value;
if (data == null) {
return Container();
}
String? filePath = data.photoPath;
bool fileIsDownloading = !data.fileExists;
bool fileArchived =
data.attachment?.state == AttachmentState.archived.index;
if (fileArchived) {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text("Unavailable"),
const SizedBox(height: 8),
],
);
}
if (fileIsDownloading) {
return const Text("Downloading...");
}
File imageFile = File(filePath!);
int lastModified = imageFile.existsSync()
? imageFile.lastModifiedSync().millisecondsSinceEpoch
: 0;
Key key = ObjectKey('$filePath:$lastModified');
return Image.file(
key: key,
imageFile,
width: 50,
height: 50,
);
}
}
class _ResolvedPhotoState {
String? photoPath;
bool fileExists;
Attachment? attachment;
_ResolvedPhotoState(
{required this.photoPath, required this.fileExists, this.attachment});
}
@riverpod
Future<_ResolvedPhotoState> _getPhotoState(Ref ref, String? photoId) async {
if (photoId == null) {
return _ResolvedPhotoState(photoPath: null, fileExists: false);
}
final queue = await ref.read(attachmentQueueProvider.future);
final photoPath = await queue.getLocalUri('$photoId.jpg');
bool fileExists = await File(photoPath).exists();
final row = await queue.db
.getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]);
if (row != null) {
Attachment attachment = Attachment.fromRow(row);
return _ResolvedPhotoState(
photoPath: photoPath, fileExists: fileExists, attachment: attachment);
}
return _ResolvedPhotoState(
photoPath: photoPath, fileExists: fileExists, attachment: null);
}