syncx 0.2.1
syncx: ^0.2.1 copied to clipboard
syncx is a lightweight, flexible Flutter state management library with notifiers and reactive UI updates, inspired by provider, bloc, and riverpod.
SyncX #
State management for Flutter built on one idea: a notifier holds your state, widgets rebuild when it changes, and you control exactly when.
class CounterNotifier extends Notifier<int> {
CounterNotifier() : super(0);
void increment() => setState(state + 1);
}
No code generation, no build runner, no annotations. One dependency (provider). The whole library is under 1500 lines.
Install #
flutter pub add syncx
import 'package:syncx/syncx.dart';
A counter, start to finish #
import 'package:flutter/material.dart';
import 'package:syncx/syncx.dart';
class CounterNotifier extends Notifier<int> {
CounterNotifier() : super(0);
void increment() => setState(state + 1);
}
void main() {
runApp(
NotifierRegister(
create: (context) => CounterNotifier(),
child: const MaterialApp(home: CounterPage()),
),
);
}
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: NotifierBuilder<CounterNotifier, int>(
builder: (count, child) => Text('$count'),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterNotifier>().increment(),
child: const Icon(Icons.add),
),
);
}
}
That is the entire loop: setState to change state, NotifierRegister to provide it, NotifierBuilder to render it, context.read to call methods on it.
The widgets #
| Widget | Rebuilds UI | Runs side effects |
|---|---|---|
NotifierBuilder |
yes | no |
NotifierListener |
no | yes |
NotifierConsumer |
yes | yes |
AsyncNotifierBuilder |
yes | no |
AsyncNotifierListener |
no | yes |
AsyncNotifierConsumer |
yes | yes |
Every one of them accepts:
| Parameter | Type | Purpose |
|---|---|---|
builder |
(state, child) => Widget |
What to render |
listener |
(state) => void |
Side effects: navigation, snackbars, logging |
buildWhen |
(previous, current) => bool |
Skip the rebuild when false |
listenWhen |
(previous, current) => bool |
Skip the listener when false |
onInit |
(notifier) => void |
Widget-level setup, once |
child |
Widget? |
Passed to builder, not rebuilt with it |
buildWhen compares against what is currently on screen. listenWhen compares against the previous notification. Neither is consulted on the first build.
Controlling rebuilds #
NotifierBuilder<CounterNotifier, int>(
buildWhen: (previous, current) => current.isEven,
builder: (count, child) => Text('$count'),
)
NotifierListener<CounterNotifier, int>(
listenWhen: (previous, current) => current > 10,
listener: (count) => ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Past ten: $count')),
),
child: const MyForm(),
)
A rejected buildWhen costs one predicate call and nothing else — no rebuild, no allocation. A NotifierListener with no builder never rebuilds its subtree at all, so the child above is built once no matter how often the count changes.
Async state #
AsyncNotifier models loading, data and error as one value. Return the initial state from onInit:
class GreetingNotifier extends AsyncNotifier<String> {
GreetingNotifier(this.api);
final GreetingApi api;
@override
Future<AsyncState<String>> onInit() async {
return AsyncState.data(await api.fetch());
}
Future<void> refresh() async {
setLoading();
try {
setData(await api.fetch());
} catch (error, stackTrace) {
setError(error, message: 'Could not load', stackTrace: stackTrace);
}
}
}
A thrown error inside onInit becomes an error state automatically — you do not need to catch it there.
Render it with named callbacks per state:
AsyncNotifierBuilder<GreetingNotifier, String>.withData(
loadingBuilder: (child) => const CircularProgressIndicator(),
dataBuilder: (greeting, child) => Text(greeting),
errorBuilder: (error, child) => Text(error.message ?? '${error.error}'),
)
Or handle the state yourself, which is the same thing one level lower:
AsyncNotifierBuilder<GreetingNotifier, String>(
builder: (state, child) => state.when(
loading: () => const CircularProgressIndicator(),
data: (greeting) => Text(greeting),
error: (error) => Text(error.message ?? '${error.error}'),
),
)
AsyncNotifierListener and AsyncNotifierConsumer take the same .withData shape, with loadingListener, dataListener and errorListener.
Keeping the old value during a refresh. setLoading() and setError() preserve the previous data, so a refresh does not have to blank the screen. when and whenData branch on status alone and will not hand it back — read state.data directly:
AsyncNotifierBuilder<GreetingNotifier, String>(
builder: (state, child) => state.when(
loading: () => Text(state.data ?? 'Loading...'),
data: (greeting) => Text(greeting),
error: (error) => Text(state.data ?? 'Failed'),
),
)
Sharing a notifier across screens #
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => NotifierRegister.value(
notifier: context.read<CounterNotifier>(),
child: const DetailPage(),
),
),
)
NotifierRegister.value does not own the notifier, so it will not dispose it when the route pops.
Lifecycle #
class CounterNotifier extends Notifier<int> {
CounterNotifier() : super(0);
@override
void onInit() {}
@override
void onUpdate(int state) {}
}
onInit runs once, when the notifier is constructed. onUpdate runs after every accepted state change, before listeners are notified. For AsyncNotifier, onInit is Future<AsyncState<S>> and its result becomes the state.
Gotchas #
Pass dependencies through the constructor's initializer list, not its body. Dart runs a superclass constructor body before the subclass's, and that is where onInit starts. A field assigned in your own constructor body is still unset inside onInit:
// Wrong: repository is null inside onInit
class UserNotifier extends Notifier<User?> {
UserNotifier() : super(null) {
repository = UserRepository();
}
UserRepository? repository;
@override
void onInit() => repository!.load(); // throws
}
// Right
class UserNotifier extends Notifier<User?> {
UserNotifier(this.repository) : super(null);
final UserRepository repository;
@override
void onInit() => repository.load();
}
Do not call onInit yourself. It already ran by the time any widget builds. The widgets' onInit parameter is for your own setup:
onInit: (notifier) => notifier.onInit(), // duplicate initialization
onInit: (notifier) => notifier.loadMore(), // what it is for
Use setState, never notifyListeners. The latter throws, by design — it would update listeners without updating state.
setState ignores unchanged state. Pass forced: true when you mutate a list or map in place and the reference has not changed, or notify: false to update state without rebuilding.
Roadmap #
- A top-level observer for watching state changes across the app
- Debugging tools
BaseAsyncStatefolded intoAsyncState, in the next breaking release
Links #
MIT licensed. The API borrows naming from bloc, provider and riverpod, so patterns from those carry over.