0 purchases
creator core
Creator is a state management library that enables concise, fluid, readable, and testable business logic code.
Read and update state with compile time safety:
// Creator creates a stream of data.
final counter = Creator.value(0);
copied to clipboard
Widget build(BuildContext context) {
return Column(
children: [
// Watcher will rebuild whenever counter changes.
Watcher((context, ref, _) => Text('${ref.watch(counter)}')),
TextButton(
// Update state is easy.
onPressed: () => context.ref.update<int>(counter, (count) => count + 1),
child: const Text('+1'),
),
],
);
}
copied to clipboard
Write clean and testable business logic:
// repo.dart
// Pretend calling a backend service to get fahrenheit temperature.
Future<int> getFahrenheit(String city) async {
await Future.delayed(const Duration(milliseconds: 100));
return 60 + city.hashCode % 20;
}
copied to clipboard
// logic.dart
// Simple creators bind to UI.
final cityCreator = Creator.value('London');
final unitCreator = Creator.value('Fahrenheit');
// Write fluid code with methods like map, where, etc.
final fahrenheitCreator = cityCreator.asyncMap(getFahrenheit);
// Combine creators for business logic.
final temperatureCreator = Emitter<String>((ref, emit) async {
final f = await ref.watch(fahrenheitCreator);
final unit = ref.watch(unitCreator);
emit(unit == 'Fahrenheit' ? '$f F' : '${f2c(f)} C');
});
// Fahrenheit to celsius converter.
int f2c(int f) => ((f - 32) * 5 / 9).round();
copied to clipboard
// main.dart
Widget build(BuildContext context) {
return Watcher((context, ref, _) =>
Text(ref.watch(temperatureCreator.asyncData).data ?? 'loading'));
}
... context.ref.set(cityCreator, 'Paris'); // triggers backend call
... context.ref.set(unitCreator, 'Celsius'); // doesn't trigger backend call
copied to clipboard
Getting started:
dart pub add creator
copied to clipboard
Table of content:
Why Creator?
Concept
Usage
Creator
Emitter
CreatorGraph
Watcher
Listen to change
Name
Keep alive
Extension method
Creator equality
Creator group
Service locator
Error handling
Testing
Example
Simple example
Starter template
Best practice
Read source code
FAQ
What's the difference between context.ref vs ref in Creator((ref) => ...)?
What's the difference between Creator<Future<T>> vs Emitter<T>?
What do I need to know if I'm a riverpod user?
That's it
Why Creator? #
When we built our Flutter app, we started with
flutter_bloc. Later we switched to
riverpod. However, we encountered
several issues related to its async providers and realized we wanted a different
mechanism.
So we built Creator. It is heavily inspired by riverpod, but with a simpler
data model, better async support, and a much simpler implementation.
The benefit of using Creator:
Enables concise, fluid, readable, and testable business logic. Sync or async.
No need to worry when to "provide" creators.
Concept is extremely simple and easy to learn.
No magic. Build this library yourself with 100 lines of code.
Concept #
Creator's concept is extremely simple. There are only two types of creators:
Creator which creates a stream of T.
Emitter which creates a stream of Future<T>.
Here stream is in its logical term, not the Stream class.
Both Creator and Emitter:
Can depend on other creators, and update its state when others' state changes.
Are loaded lazily and disposed automatically.
Dependencies form a graph, for example, this is the graph for the weather
example above:
The library simply maintains the graph with an adjacency list and propagates
state changes along the edges.
Usage #
Creator #
Creator takes a function you write to create a state. The function takes a Ref, which
provides API to interact with the internal graph.
final number = Creator.value(42); // Same as Creator((ref) => 42)
final double = Creator((ref) => ref.watch(number) * 2);
copied to clipboard
Calling watch adds an edge number -> double to the graph, so double's create
function will rerun whenever number's state changes.
The nice part is that creator comes with methods like map, where, reduce, etc. They
are similar to those methods in Iterable or Stream. So double can
simply be:
final double = number.map((n) => n * 2);
copied to clipboard
You can also read a creator when watch doesn't make sense, for example, inside
a touch event handler.
TextButton(
onPressed: () => print(context.ref.read(number)),
child: const Text('Print'));
copied to clipboard
To update the creator's state, use either set or update:
... ref.set(number, 42); // No-op if value is the same
... ref.update<int>(number, (n) => n + 10); // Same as read then set
copied to clipboard
Note creator determines state change using T.==, so it should work with immutable data.
If T is a class, use const constructor and override == and hashCode. Or use package like equatable.
If T is a list, create a new list rather than update the existing one.
Creator's dependency can be dynamic:
final C = Creator((ref) {
final value = ref.watch(A);
return value >= 0 ? value : ref.watch(B);
});
copied to clipboard
In this example, A -> C always exists, B -> C may or may not exist. The
library will update the graph properly as dependency changes.
Emitter #
Emitter works very similar to Creator, but it creates Future<T> instead
of <T>. The main difference is Creator has valid data to begin with, while
Emitter might need to wait for some async work before it yields the first
data.
In practice, Emitter is very useful to deal with data from backend
services, which is async by nature.
final stockCreator = Creator.value('TSLA');
final priceCreator = Emitter<int>((ref, emit) async {
final stock = ref.watch(stockCreator);
final price = await fetchStockPrice(stock);
emit(price);
});
copied to clipboard
Emitter takes a FutureOr<void> Function(Ref ref, void Function(T) emit),
where ref allows getting data from the graph, and emit allows pushing data
back to the graph. You can emit multiple times.
Existing Stream can be converted to Emitter easily with Emitter.stream. It
works both sync or async:
final authCreator = Emitter.stream(
(ref) => FirebaseAuth.instance.authStateChanges());
final userCreator = Emitter.stream((ref) async {
final authId = await ref.watch(
authCreator.where((auth) => auth != null).map((auth) => auth!.uid));
return FirebaseFirestore.instance.collection('users').doc(authId).snapshots();
});
copied to clipboard
This example also shows the extension method where and map. With them,
userCreator will only recreate when auth id changes, and ignore changes on other
auth properties.
In some sense, you can think Emitter as a different version of
Stream, which makes combining streams super easy.
Emitter generates Future<T>, so it can be hooked to Flutter's
FutureBuilder for UI. Or you can use Emitter.asyncData, which is a creator
of AsyncData<T>. AsyncData is similar to AsyncSnapshot for future/stream:
enum AsyncDataStatus { waiting, active }
class AsyncData<T> {
const AsyncData._(this.status, this.data);
const AsyncData.waiting() : this._(AsyncDataStatus.waiting, null);
const AsyncData.withData(T data) : this._(AsyncDataStatus.active, data);
final AsyncDataStatus status;
final T? data;
}
copied to clipboard
With it building widget with Emitter is easy:
Watcher((context, ref, _) {
final user = ref.watch(userCreator.asyncData).data;
return user != null ? Text(user!.name) : const CircularProgressIndicator();
});
copied to clipboard
CreatorGraph #
To make creators work, wrap your app in a CreatorGraph:
void main() {
runApp(CreatorGraph(child: const MyApp()));
}
copied to clipboard
CreatorGraph is a InheritedWidget. It holds a Ref object (which holds the
graph) and exposes it through context.ref.
CreatorGraph uses DefaultCreatorObserver by default, which prints logs when
creator state changes. It can be replaced with your own log collection observer.
Watcher #
Watcher is a simple StatefulWidget which holds a Creator<void> internally
and calls setState when its dependency changes.
It takes builder function Widget Function(BuildContext context, Ref ref, Widget child). You can use ref to watch other creators. child can
be used optionally if the subtree should not rebuild when dependency changes:
Watcher((context, ref, child) {
final color = ref.watch(userFavoriteColor);
return Container(color: color, child: child);
}, child: ExpensiveAnimation()); // this child is passed into the builder above
copied to clipboard
You can control your widget rebuild precisely with fine grain reactive approach:
Watcher((context, ref, _) {
// Only rebuild when user's name changes.
ref.watch(userCreator.map((user) => user.name));
// Read other user data as needed.
final user = ref.read(userCreator);
return TextButton(onPressed() => print('clicked ${user.id}'), child: Text(user.name));
});
copied to clipboard
Listen to change #
Watching a creator will get its latest state. What if you also want previous
state? Simply call watch(someCreator.change) to get a Change<T>, which is
an object with two properties T? before and T after.
Creator((ref) {
final change = ref.watch(number.change);
return 'Number changed from ${change.before} to ${change.after}';
});
copied to clipboard
For your convenience, Watcher can also take a listener. It can be used to
achieve side effects or run background tasks:
// If builder is null, child widget is directly returned. You can set both
// builder and listener. They are independent of each other.
Watcher(null, listener: (ref) {
final change = ref.watch(number.change);
print('Number changed from ${change.before} to ${change.after}');
}, child: SomeChildWidget());
copied to clipboard
Name #
Creators can have names for logging purpose. Setting name is recommended for any serious app.
final numberCreator = Creator.value(0, name: 'number');
final doubleCreator = numberCreator.map((n) => n * 2, name: 'double');
copied to clipboard
Keep alive #
By default, creators are disposed when losing all its watchers. This can be
overridden with keepAlive parameter. It is useful if the creator maintains a
connection to backend (e.g. listen to firestore realtime updates).
final userCreator = Emitter.stream((ref) {
return FirebaseFirestore.instance.collection('users').doc('123').snapshots();
}, keepAlive: true);
copied to clipboard
To summarize the life cycle of a creator:
It is added to the graph when firstly being watched or set.
It can be removed from the graph manually by Ref.dispose.
If it has watchers, it is automatically removed from the graph when losing all its
watchers, unless keepAlive property is set.
Extension method #
Our favorite part of the library is that you can use methods like map,
where, reduce on creators (full list here). They are similar to those methods in
Iterable or Stream.
final numberCreator = Creator.value(0);
final oddCreator = numberCreator.where((n) => n.isOdd);
copied to clipboard
Note that Creator needs to have valid state at the beginning, while where((n) => n.isOdd) cannot guarantee that. This is why where returns an Emitter
rather than a Creator. Here is the implementation of the where method. It
is quite simple and you can write similar extensions if you want:
extension CreatorExtension<T> on Creator<T> {
Emitter<T> where(bool Function(T) test) {
return Emitter((ref, emit) {
final value = ref.watch(this);
if (test(value)) {
emit(value);
}
});
}
}
copied to clipboard
You can use extension methods in two ways:
// Define oddCreator explicitly as a stable variable.
final oddCreator = numberCreator.where((n) => n.isOdd);
final someCreator = Creator((ref) {
return 'this is odd: ${ref.watch(oddCreator)}');
})
copied to clipboard
// Create "oddCreator" anonymously on the fly.
final someCreator = Creator((ref) {
return 'this is odd: ${ref.watch(numberCreator.where((n) => n.isOdd))}');
})
copied to clipboard
If you use the "on the fly" approach, please read next section about creator equality.
Creator equality #
The graph checks whether two creators are equal using ==. This
means creator should be defined in global variables, static variables or any
other ways which can keep variable stable during its life cycle.
What happens if creators are defined in local variables on the fly?
final text = Creator((ref) {
final double = Creator((ref) => ref.watch(number) * 2);
return 'double: ${ref.watch(double)}';
})
copied to clipboard
Here double is a local variable, it has different instances whenever text is
recreated. The internal graph could change from number -> double_A -> text to
number -> double_B -> text as the number changes. text still generates
correct data, but there is an extra cost to swap the node in the graph. Because the
change is localized to only one node, the cost can be ignored as long as the
create function is simple.
If needed, an optional List<Object?> args can be set to ask the library to
find an existing creator with the same args in the graph. Now when number
changes, the graph won't change:
final text = Creator((ref) {
// args need to be globally unique. ['text', 'double'] is likely unique.
final double = Creator((ref) => ref.watch(number) * 2, args: ['text', 'double']);
return 'double: ${ref.watch(double)}';
})
copied to clipboard
The same applies to using extension methods on the fly:
final text = Creator((ref) {
return 'double: ${ref.watch(number.map((n) => n * 2, args: ['text', 'double']))}';
})
copied to clipboard
Internally, args powers these features:
Async data. userCreator.asyncData is a creator with args [userCreator, 'asyncData'].
Change. number.change is a creator with args [number, 'change'].
Creator group #
Creator group can generate creators with external parameter. It is nothing special, but leveraging
the args parameter in previous section.
For example, in Instagram app, there might be multiple profile pages on navigation stack, thus we
need multiple instance of profileCreator.
// Instagram has four tabs: instagram, reels, video, tagged
Creator<String> tabCreator(String userId) => Creator.value('instagram', args: ["tab", userId]);
Emitter<Profile> profileCreator(String userId)
=> tabCreator(userId).asyncMap(fetchProfileData, args: ["profle", userId]);
// Now switching tab in user A's profile page will not affect user B.
... ref.watch(profileCreator('userA'));
... ref.set(tabCreator('userA'), 'reels');
copied to clipboard
Service locator #
State management libraries are commonly used as service locators:
class UserRepo {
void changeName(User user, String name) {...}
}
final userRepo = Creator.value(UserRepo(), keepAlive: true);
... context.ref.read(userRepo).changeName(user, name);
copied to clipboard
If needed, ref can be passed to UserRepo Creator((ref) => UserRepo(ref)).
This allows UserRepo read or set other creators. Do not watch though,
because it might recreate UserRepo.
Error handling #
The library will:
For Creator, store exception happened during create and throw it when watch.
For Emitter, naturally use Future.error, so error is returned when watch.
In either case, error is treated as a state change.
This means that errors can be handled in the most natural way, at the place
makes the most sense. Use the weather app above as an example:
// Here we don't handle error, meaning it returns Future.error if network error
// occurs. Alternately we can catch network error and return some default value,
// add retry logic, convert network error to our own error class, etc.
final fahrenheitCreator = cityCreator.asyncMap(getFahrenheit);
// Here we choose to handle the error in widget.
Widget build(BuildContext context) {
return Watcher((context, ref, _) {
try {
return Text(ref.watch(temperatureCreator.asyncData).data ?? 'loading');
} catch (error) {
return TextButton('Something went wrong, click to retry',
onPressed: () => ref.recreate(fahrenheitCreator));
}
};
}
copied to clipboard
Testing #
Testing creator is quite easy by combining watch, read, set. Use the weather app above as an example:
// No setUp, no tearDown, no mocks. Writing tests becomes fun.
test('temperature creator change unit', () async {
final ref = Ref();
expect(await ref.watch(temperatureCreator), "60 F");
ref.set(unitCreator, 'Celsius');
await Future.delayed(const Duration()); // allow emitter to propagate
expect(await ref.watch(temperatureCreator), "16 C");
});
test('temperature creator change fahrenheit value', () async {
final ref = Ref();
expect(await ref.watch(temperatureCreator), "60 F");
ref.emit(fahrenheitCreator, 90);
await Future.delayed(const Duration()); // allow emitter to propagate
expect(await ref.watch(temperatureCreator), "90 F");
});
copied to clipboard
Example #
Simple example #
Source code here.
DartPad link
Description
Counter
A counter app shows basic Creator/Watcher usage.
Decremental counter
A counter app shows how to hide state and expose state mutate APIs.
Weather
Simple weather app shows splitting backend/logic/ui code and writing logic with Creator and Emitter.
News
Simple news app with infinite list of news. It shows combining creators for loading indicator and fetching data with pagination.
Graph
Simple app shows how the creator library builds the internal graph dynamically.
Starter template #
Source code here. An async counter app with login! A minimal template to start a Flutter project with:
Go router for routing
Creator for state management.
Optional Firebase Auth, or your own auth mechanism.
Best practice #
Creator is quite flexible and doesn't force a particular style. Best practices
also depend on the project and personal preference. Here we just list a few
things we follow:
Split code into repo files (backend service call), logic files (creator), and UI files (widget).
Keep creator small for testability. Put derived state in derived creators (using map, where, etc).
Read source code #
Creator's implementation is surprisingly simple. In fact, the core logic
is less than 500 lines of code.
You can optionally read this
article
first, which describes how we built the first version with 100 lines of code.
Read creator_core library in this order:
graph.dart: a simple implementation of a bi-directed graph using adjacency
list. It can automatically delete nodes which become zero out-degree.
creator.dart: the CreatorBase class and its two sub classes, Creator and Emitter.
Their main job is to recreate state when asked.
ref.dart: manages the graph and provides watch, read, set methods to user.
extension.dart: implement extension methods map, where, etc.
Read creator library in this order:
creator_graph.dart: A simple InheritedWidget which expose Ref through context.
watcher.dart: A stateful widget which holds a Creator<Widget> internally.
FAQ #
What's the difference between context.ref vs ref in Creator((ref) => ...)? #
They both point to the same internal graph, the only difference is that the
first ref's _owner field is null, while the second ref's _owner field is the
creator itself. This means:
It is the same to read, set or update any creators with either ref. The operation is
passed to the internal graph.
If ref._owner is null, ref.watch(foo) will simply add foo to the graph.
If ref._owner is not null, ref.watch(foo) will also add an edge foo -> ref._owner to the graph.
What's the difference between Creator<Future<T>> vs Emitter<T>? #
They are both extended from CreatorBase<Future<T>>, whose state is
Future<T>. However, there are two important differences, which make
Emitter<T> better for async tasks:
Emitter<T> stores T in addition to Future<T>, so that we can log change
of T or populate AsyncData<T> properly.
Emitter<T> notify its watcher when T is emitted, so its watchers can start
their work immediately. Creator<Future<T>> notify its watchers when the future
is started, so its watchers are still blocked until the future is finished.
What do I need to know if I'm a riverpod user? #
Check FAQ for riverpod user.
That's it #
Hope you enjoyed reading this doc and will enjoy using Creator. Feedback and
contribution are welcome!
For personal and professional use. You cannot resell or redistribute these repositories in their original state.
There are no reviews.