state_beacon 0.37.0 state_beacon: ^0.37.0 copied to clipboard
A reactive primitive and simple state managerment solution for dart and flutter
Overview #
A Beacon is a reactive primitive(signal
) and simple state management solution for Dart and Flutter.
Flutter web demo(source): https://flutter-beacon.surge.sh/
All examples: https://github.com/jinyus/dart_beacon/tree/main/examples
Installation #
dart pub add state_beacon
Usage #
import 'package:flutter/material.dart';
import 'package:state_beacon/state_beacon.dart';
final name = Beacon.writable("Bob");
class ProfileCard extends StatelessWidget {
const ProfileCard({super.key});
@override
Widget build(BuildContext context) {
// rebuilds whenever the name changes
return Text(name.watch(context));
}
}
Using an asynchronous function
final counter = Beacon.writable(0);
// The future will be recomputed whenever the counter changes
final futureCounter = Beacon.future(() async {
final count = counter.value;
return await fetchData(count);
});
Future<String> fetchData(int count) async {
await Future.delayed(Duration(seconds: count));
return '$count second has passed.';
}
class FutureCounter extends StatelessWidget {
const FutureCounter({super.key});
@override
Widget build(BuildContext context) {
return switch (futureCounter.watch(context)) {
AsyncData<String>(value: final v) => Text(v),
AsyncError(error: final e) => Text('$e'),
_ => const CircularProgressIndicator(),
};
}
}
Linting (optional) #
It is recommended to use state_beacon_lint for package specific rules.
dart pub add custom_lint state_beacon_lint --dev
Enable the custom_lint
plugin in your analysis_options.yaml
file by adding the following.
analyzer:
plugins:
- custom_lint
NB: Create the file if it doesn't exist.
Features #
- Beacon.writable: Mutable beacon that allows both reading and writing.
- Beacon.scopedWritable: Returns a
ReadableBeacon
and a function for setting its value.
- Beacon.scopedWritable: Returns a
- Beacon.readable: Immutable beacon that only emit values, ideal for readonly data.
- Beacon.effect: React to changes in beacon values.
- BeaconScheduler: Configure the scheduler for all beacons.
- Beacon.derived: Derive values from other beacons, keeping them reactively in sync.
- Beacon.future: Derive values from asynchronous operations, managing state during computation.
- overrideWith: Replace the callback.
- Beacon.stream: Create derived beacons from Dart streams. values are wrapped in an
AsyncValue
. - Beacon.streamRaw: Like
Beacon.stream
, but it doesn't wrap the value in anAsyncValue
. - Beacon.debounced: Delay value updates until a specified time has elapsed, preventing rapid or unwanted updates.
- Beacon.throttled: Limit the frequency of value updates, ideal for managing frequent events or user input.
- Beacon.filtered: Update values based on filter criteria.
- Beacon.timestamped: Attach timestamps to each value update.
- Beacon.undoRedo: Provides the ability to undo and redo value changes.
- Beacon.bufferedCount: Create a buffer/list of values based an
int
limit. - Beacon.bufferedTime: Create a buffer/list of values based on a time limit.
- Beacon.list: Manage reactive lists that automatically update dependent beacons upon changes.
- Beacon.hashSet: Like Beacon.list, but for Sets.
- Beacon.hashMap: Like Beacon.list, but for Maps.
- AsyncValue: A wrapper around a value that can be in one of four states:
idle
,loading
,data
, orerror
.- unwrap: Casts this [AsyncValue] to [AsyncData] and return its value.
- lastData: Returns the latest valid data value or null.
- tryCatch: Execute a future and return [AsyncData] or [AsyncError].
- optimistic updates: Update the value optimistically when using tryCatch.
- Beacon.family: Create and manage a family of related beacons.
- Extension Methods: Additional methods for beacons that can be chained.
- stream: Obtain a stream from a beacon, enabling integration with stream-based APIs and libraries.
- wrap: Wraps an existing beacon and consumes its values
- ingest: Wraps any stream and consumes its values
- next: Allows awaiting the next value as a future.
- Chaining Beacons: Seamlessly chain beacons to create sophisticated reactive pipelines, combining multiple functionalities for advanced value manipulation and control.
- buffer: Returns a Beacon.bufferedCount that wraps this beacon.
- bufferTime: Returns a Beacon.bufferedTime that wraps this beacon.
- throttle: Returns a Beacon.throttled that wraps this beacon.
- filter: Returns a Beacon.filtered that wraps this beacon.
- map: Returns a [Beacon.readable] that wraps a beacon and transform its values.
- debounce: Returns a Beacon.debounced that wraps this beacon.
Beacon.writable: #
Creates a WritableBeacon
from a value that can be read and written to.
final counter = Beacon.writable(0);
counter.value = 10;
print(counter.value); // 10
Beacon.lazyWritable: #
Like Beacon.writable
but behaves like a late
variable. It must be set before it's read.
NB: All writable beacons have a lazy counterpart.
final counter = Beacon.lazyWritable();
print(counter.value); // throws UninitializeLazyReadException()
counter.value = 10;
print(counter.value); // 10
Beacon.readable: #
Creates an immutable ReadableBeacon
from a value. This is useful for exposing a beacon's value to consumers without allowing them to modify it.
final _internalCounter = Beacon.writable(10);
// Expose the beacon's value without allowing it to be modified
ReadableBeacon<int> get counter => _internalCounter;
Beacon.effect: #
An effect is just a function that will re-run whenever one of its dependencies change. An effect is scheduled to run immediately after creation.
final age = Beacon.writable(15);
Beacon.effect(() {
if (age.value >= 18) {
print("You can vote!");
} else {
print("You can't vote yet");
}
});
// Outputs: "You can't vote yet"
age.value = 20; // Outputs: "You can vote!"
BeaconScheduler: #
Effects
are not synchronous, their execution is controlled by a scheduler. When a dependency of an effect
changes, it is added to a queue and the scheduler decides when is the best time to flush the queue. By default, the queue is flushed with a DARTVM microtask which runs on the next loop; this can be changed by setting a custom scheduler.
A 60fps scheduler is included, this limits processing effects to 60 times per second. This can be done by calling BeaconScheduler.use60FpsScheduler();
in the main
function. You can also create your own custom scheduler for more advanced use cases. eg: Gaming
: Synchronize flushing with your game loop.
When testing synchronous code, it is necessary to flush the queue manually. This can be done by calling BeaconScheduler.flush();
in your test.
Note
When writing widget tests, manual flushing isn't needed. The queue is automatically flushed when you call tester.pumpAndSettle()
.
final a = Beacon.writable(10);
var called = 0;
// effect is queued for execution. The scheduler decides when to run the effect
Beacon.effect(() {
print("current value: ${a.value}");
called++;
});
// manually flush the queue to run the all effect immediately
BeaconScheduler.flush();
expect(called, 1);
a.value = 20; // effect will be queued again.
BeaconScheduler.flush();
expect(called, 2);
Beacon.derived: #
Creates a DerivedBeacon
whose value is derived from a computation function.
This beacon will recompute its value every time one of it's dependencies change.
These beacons are lazy and will only compute their value when accessed, subscribed to or being watched by a widget or an effect.
Example:
final age = Beacon.writable<int>(18);
final canDrink = Beacon.derived(() => age.value >= 21);
print(canDrink.value); // Outputs: false
age.value = 22;
print(canDrink.value); // Outputs: true
Beacon.future: #
Creates a FutureBeacon
whose value is derived from an asynchronous computation.
This beacon will recompute its value every time one of its dependencies change.
The result is wrapped in an AsyncValue
, which can be in one of four states: idle
, loading
, data
, or error
.
If manualStart
is true
(default: false), the beacon will be in the idle
state and the future will not execute until start()
is called. Calling start()
on a beacon that's already started will have no effect.
If shouldSleep
is true
(default), the callback will not execute if the beacon is no longer being watched.
It will resume executing once a listener is added or its value is accessed.
This means that it will enter the loading
state when woken up.
Important
Only beacons accessed before the async gap will be tracked as dependencies. See pitfalls for more details.
Example:
final counter = Beacon.writable(0);
// The future will be recomputed whenever the counter changes
final futureCounter = Beacon.future(() async {
final count = counter.value;
await Future.delayed(Duration(seconds: count));
return '$count second has passed.';
});
class FutureCounter extends StatelessWidget {
const FutureCounter({super.key});
@override
Widget build(BuildContext context) {
return switch (futureCounter.watch(context)) {
AsyncData<String>(value: final v) => Text(v),
AsyncError(error: final e) => Text('$e'),
AsyncLoading() || AsyncIdle() => const CircularProgressIndicator(),
};
}
}
Can be transformed into a future with myFutureBeacon.toFuture()
This can useful when a FutureBeacon depends on another FutureBeacon.
This functionality is also available to StreamBeacons.
var count = Beacon.writable(0);
var firstName = Beacon.future(() async {
final val = count.value;
await Future.delayed(k10ms);
return 'Sally $val';
});
var lastName = Beacon.future(() async {
final val = count.value + 1;
await Future.delayed(k10ms);
return 'Smith $val';
});
var fullName = Beacon.future(() async {
// wait for the future to complete
// we don't have to manually handle all the states
final [fname, lname] = await Future.wait(
[
firstName.toFuture(),
lastName.toFuture(),
],
);
return '$fname $lname';
});
FutureBeacon.overrideWith:
Replaces the current callback and resets the beacon by running the new callback.
var futureBeacon = Beacon.future(() async => 1);
await Future.delayed(k1ms);
expect(futureBeacon.value.unwrap(), 1);
futureBeacon.overrideWith(() async => throw Exception('error'));
await Future.delayed(k1ms);
expect(futureBeacon.value, isA<AsyncError>());
Beacon.stream: #
Creates a StreamBeacon
from a given stream.
When a dependency changes, the beacon will unsubscribe from the old stream and subscribe to the new one.
This beacon updates its value based on the stream's emitted values.
The emitted values are wrapped in an AsyncValue
, which can be in one of 4 states:idle
, loading
, data
, or error
.
If shouldSleep
is true
(default), it will unsubscribe from the stream if it's no longer being watched.
It will resubscribe once a listener is added or its value is accessed.
This means that it will enter the loading
state when woken up.
This can we wrapped in a Throttled or Filtered beacon to control the rate of updates(see method chaining).
Can be transformed into a future with mystreamBeacon.toFuture()
:
var myStream = Stream.periodic(Duration(seconds: 1), (i) => i);
var myBeacon = Beacon.stream(() => myStream);
myBeacon.subscribe((value) {
print(value); // Outputs AsyncLoading(),AsyncData(0),AsyncData(1),AsyncData(2),...
});
Beacon.streamRaw: #
Like Beacon.stream
, but it doesn't wrap the value in an AsyncValue
.
When a dependency changes, the beacon will unsubscribe from the old stream and subscribe to the new one.
If shouldSleep
is true
(default), it will unsubscribe from the stream if it's no longer being watched.
It will resubscribe once a listener is added or its value is accessed.
One of the following must be true
if an initial value isn't provided:
- The type is nullable
isLazy
is true (beacon must be set before it's read from)
var myStream = Stream.periodic(Duration(seconds: 1), (i) => i);
var myBeacon = Beacon.streamRaw(() => myStream, initialValue: 0);
myBeacon.subscribe((value) {
print(value); // Outputs 0,1,2,3,...
});
Beacon.debounced: #
Creates a DebouncedBeacon
that will delay updates to its value based on the duration. This is useful when you want to wait until a user has stopped typing before performing an action.
var query = Beacon.debounced('', duration: Duration(seconds: 1));
query.subscribe((value) {
print(value); // Outputs: 'apple' after 1 second
});
// simulate user typing
query.value = 'a';
query.value = 'ap';
query.value = 'app';
query.value = 'appl';
query.value = 'apple';
// after 1 second, the value will be updated to 'apple'
Beacon.throttled: #
Creates a ThrottledBeacon
that will limit the rate of updates to its value based on the duration.
If dropBlocked
is true
(default), values will be dropped while the beacon is blocked, otherwise, values will be buffered and emitted one by one when the beacon is unblocked.
const k10ms = Duration(milliseconds: 10);
var beacon = Beacon.throttled(10, duration: k10ms);
beacon.set(20);
expect(beacon.value, equals(20)); // first update allowed
beacon.set(30);
expect(beacon.value, equals(20)); // too fast, update ignored
await Future.delayed(k10ms * 1.1);
beacon.set(30);
expect(beacon.value, equals(30)); // throttle time passed, update allowed
Beacon.filtered: #
Creates a FilteredBeacon
that will only updates its value if it passes the filter criteria.
The filter function receives the previous and new values as arguments.
The filter function can also be changed using the setFilter
method.
Simple Example:
// only positive values are allowed
var pageNum = Beacon.filtered(10, filter: (prev, next) => next > 0);
pageNum.value = 20; // update is allowed
pageNum.value = -5; // update is ignored
Example when filter function depends on another beacon:
In this example, posts
is a derived future beacon that will fetch the posts whenever pageNum
changes.
We want to prevent the user from changing pageNum
while posts
is loading.
var pageNum = Beacon.filtered(1); // we will set the filter function later
final posts = Beacon.future(() => Repository.getPosts(pageNum.value));
// can't change pageNum while loading
pageNum.setFilter((prev, next) => !posts.isLoading);
Extracted from the infinite list example
Beacon.timestamped: #
Creates a TimestampBeacon
that attaches a timestamp to each value update.
var myBeacon = Beacon.timestamped(10);
print(myBeacon.value); // Outputs: (value: 10, timestamp: __CURRENT_TIME__)
Beacon.undoRedo: #
Creates an UndoRedoBeacon
that allows undoing and redoing changes to its value.
var age = Beacon.undoRedo(0, historyLimit: 10);
age.value = 10;
age.value = 20;
age.undo(); // Reverts to 10
age.redo(); // Goes back to 20
Beacon.bufferedCount: #
Creates a BufferedCountBeacon
that collects and buffers a specified number
of values. Once the count threshold is reached, the beacon's value is updated
with the list of collected values and the buffer is reset.
This beacon is useful in scenarios where you need to aggregate a certain number of values before processing them together.
var countBeacon = Beacon.bufferedCount<int>(3);
countBeacon.subscribe((values) {
print(values);
});
countBeacon.add(1);
countBeacon.add(2);
countBeacon.add(3); // Triggers update and prints [1, 2, 3]
You may also access the currentBuffer
as a readable beacon.
See it in use in the konami example;
Beacon.bufferedTime: #
Creates a BufferedTimeBeacon
that collects values over a specified time duration.
Once the time duration elapses, the beacon's value is updated with the list of
collected values and the buffer is reset.
var timeBeacon = Beacon.bufferedTime<int>(duration: Duration(seconds: 5));
timeBeacon.subscribe((values) {
print(values);
});
timeBeacon.add(1);
timeBeacon.add(2);
// After 5 seconds, it will output [1, 2]
Beacon.list: #
The ListBeacon
provides methods to add, remove, and update items in the list and notifies listeners without having to make a copy.
NB: The previousValue
and current value will always be the same because the same list is being mutated. If you need access to the previousValue, use Beacon.writable
Beacon.hashSet:
Similar to Beacon.list(), but for Sets.
Beacon.hashMap:
Similar to Beacon.list(), but for Maps.
var nums = Beacon.list<int>([1, 2, 3]);
Beacon.effect(() {
print(nums.value); // Outputs: [1, 2, 3]
});
nums.add(4); // Outputs: [1, 2, 3, 4]
nums.remove(2); // Outputs: [1, 3, 4]
AsyncValue: #
An AsyncValue
is a wrapper around a value that can be in one of four states:idle
, loading
, data
, or error
.
This is the value type of FutureBeacons,FutureBeacons and StreamBeacons.
var myBeacon = Beacon.future(() async {
return await Future.delayed(Duration(seconds: 1), () => 'Hello');
});
print(myBeacon.value); // Outputs AsyncLoading immediately
await Future.delayed(Duration(seconds: 1));
print(myBeacon.value); // Outputs AsyncData('Hello')
AsyncValue.unwrap():
Casts this [AsyncValue] to [AsyncData] and return its value. This will throw an error if the value is not an [AsyncData].
var name = AsyncData('Bob');
print(name.unwrap()); // Outputs: Bob
name = AsyncLoading();
print(name.unwrap()); // Throws error
AsyncValue.lastData:
Returns the latest valid data value or null. This is useful when you want to display the last valid value while loading new data.
var myBeacon = Beacon.future(() async {
return await Future.delayed(Duration(seconds: 1), () => 'Hello');
});
print(myBeacon.value); // Outputs AsyncLoading immediately
print(myBeacon.value.lastData); // Outputs null as there is no valid data yet
await Future.delayed(Duration(seconds: 1));
print(myBeacon.value.lastData); // Outputs 'Hello'
myBeacon.reset();
print(myBeacon.value); // Outputs AsyncLoading
print(myBeacon.value.lastData); // Outputs 'Hello' as the last valid data when in loading state
AsyncValue.tryCatch:
Executes the future provided and returns [AsyncData] with the result if successful or [AsyncError] if an exception is thrown.
Supply an optional [WritableBeacon] that will be set throughout the various states.
Supply an optional [optimisticResult] that will be set while loading, instead of [AsyncLoading].
Future<String> fetchUserData() {
// Imagine this is a network request that might throw an error
return Future.delayed(Duration(seconds: 1), () => 'User data');
}
beacon.value = AsyncLoading();
beacon.value = await AsyncValue.tryCatch(fetchUserData);
You can also pass the beacon as a parameter.
loading
,data
and error
states,
as well as the last successful data will be set automatically.
await AsyncValue.tryCatch(fetchUserData, beacon: beacon);
// or use the extension method.
await beacon.tryCatch(fetchUserData);
See it in use in the shopping cat example.
If you want to do optimistic updates, you can supply an optional optimisticResult
parameter.
await beacon.tryCatch(mutateUserData, optimisticResult: 'User data');
Without tryCatch
, handling the potential error requires more
boilerplate code:
beacon.value = AsyncLoading();
try {
beacon.value = AsyncData(await fetchUserData());
} catch (err,stacktrace) {
beacon.value = AsyncError(err, stacktrace);
}
Beacon.family: #
Creates and manages a family of related Beacon
s based on a single creation function.
This class provides a convenient way to handle related beacons that share the same creation logic but have different arguments.
Type Parameters: #
T
: The type of the value emitted by the beacons in the family.Arg
: The type of the argument used to identify individual beacons within the family.BeaconType
: The type of the beacon in the family.
If cache
is true
, created beacons are cached. Default is false
.
Example:
final apiClientFamily = Beacon.family(
(String baseUrl) {
return Beacon.readable(ApiClient(baseUrl));
},
);
final githubApiClient = apiClientFamily('https://api.github.com');
final twitterApiClient = apiClientFamily('https://api.twitter.com');
Extensions: #
myBeacon.stream: #
This returns a stream that emits the beacon's value whenever it changes.
myWritable.wrap(anyBeacon): #
Wraps an existing beacon and consumes its values
Supply a (then
) function to customize how the emitted values are
processed.
var bufferBeacon = Beacon.bufferedCount<String>(10);
var count = Beacon.writable(5);
// Wrap the bufferBeacon with the readableBeacon and provide a custom transformation.
bufferBeacon.wrap(count, then: (value) {
// Custom transformation: Convert the value to a string and add it to the buffer.
bufferBeacon.add(value.toString());
});
print(bufferBeacon.buffer); // Outputs: ['5']
count.value = 10;
print(bufferBeacon.buffer); // Outputs: ['5', '10']
This method is available on all writable beacons, including BufferedBeacons; and can wrap any beacon since all beacons are readable.
myWritable.ingest(anyStream): #
This functions like .wrap()
but it's specifically for streams. It listens to the stream and updates the beacon's value with the emitted values.
final beacon = Beacon.writable(0);
final myStream = Stream.fromIterable([1, 2, 3]);
beacon.ingest(myStream);
beacon.subscribe((value) {
print(value); // Outputs: 1, 2, 3
});
mybeacon.next(): #
Listens for the next value emitted by this Beacon and returns it as a Future.
This method subscribes to this Beacon and waits for the next value
that matches the optional [filter] function. If [filter] is provided and
returns false
for a emitted value, the method continues waiting for the
next value that matches the filter. If no [filter] is provided,
the method completes with the first value received.
If a value is not emitted within the specified [timeout] duration (default is 10 seconds), the method times out and returns the current value of the beacon.
final age = Beacon.writable(20);
Timer(Duration(seconds: 1), () => age.value = 21;);
final nextAge = await age.next(); // returns 21 after 1 second
Chaining methods: #
Seamlessly chain beacons to create sophisticated reactive pipelines, combining multiple functionalities for advanced value manipulation and control.
// every write to this beacon will be filtered then debounced.
final searchQuery = Beacon.writable('').filter((prev, next) => next.length > 2).debounce(duration: k500ms);
Important
When chaining beacons, all writes made to the returned beacon will be re-routed to the first writable beacon in the chain. It is recommended to mutate the source beacons directly.
const k500ms = Duration(milliseconds: 500);
final count = Beacon.writable(10);
final filteredCount = count
.debounce(duration: k500ms),
.filter((prev, next) => next > 10);
filteredCount.value = 20;
// The mutation will be re-routed to count
// before being passed to the debounced beacon
// then to the filtered beacon.
// This is equivalent to count.value = 20;
expect(count.value, equals(20));
await Future.delayed(k500ms);
expect(filteredCount.value, equals(20));
Warning
buffer
and bufferTime
cannot be mid-chain. If they are used, they have to be the last in the chain.
// GOOD
someBeacon.filter().buffer(10);
// BAD
someBeacon.buffer(10).filter();
mybeacon.buffer(): #
Returns a Beacon.bufferedCount that wraps this beacon.
NB: The returned beacon will be disposed when the wrapped beacon is disposed.
final age = Beacon.writable(20);
final bufferedAge = age.buffer(10);
bufferedAge.subscribe((value) {
print(value); // Outputs: [20, 21, 22, 23, 24, 25, 26, 27, 28, 29]
});
for (var i = 0; i < 10; i++) {
age.value++;
}
mybeacon.bufferTime(): #
Returns a Beacon.bufferedTime that wraps this beacon.
mybeacon.throttle(): #
Returns a Beacon.throttled that wraps this beacon.
mybeacon.filter(): #
Returns a Beacon.filtered that wraps this beacon.
mybeacon.map(): #
Returns a [ReadableBeacon] that wraps a Beacon and tranforms its values.
final stream = Stream.periodic(k1ms, (i) => i).take(5);
final beacon = stream
.toRawBeacon(isLazy: true)
.map((v) => v + 1)
.filter((_, n) => n.isEven);
await expectLater(beacon.stream, emitsInOrder([1, 2, 4]));
Note
When map
returns a different type, writes to the returned beacon will not be re-routed to the original beacon. In the example below, writes to filteredBeacon
will NOT be re-routed to count
because map
returns a String
; which means the type of the returned beacon is FilteredBeacon
final count = Beacon.writable(0);
final filteredBeacon = count.map((v) => '$v').filter((_, n) => n.length > 1);
mybeacon.debounce(): #
Returns a Beacon.debounced that wraps this beacon.
final query = Beacon.writable('');
const k500ms = Duration(milliseconds: 500);
final debouncedQuery = query
.filter((prev, next) => next.length > 2)
.debounce(duration: k500ms);
Pitfalls #
When using Beacon.future
, only beacons accessed before the async gap(await
) will be tracked as dependencies.
final counter = Beacon.writable(0);
final doubledCounter = Beacon.derived(() => counter.value * 2);
final futureCounter = Beacon.future(() async {
// This will be tracked as a dependency because it's accessed before the async gap
final count = counter.value;
await Future.delayed(Duration(seconds: count));
// This will NOT be tracked as a dependency because it's accessed after `await`
final doubled = doubledCounter.value;
return '$count x 2 = $doubled';
});
When a future depends on multiple future/stream beacons
- DON'T:
final futureCounter = Beacon.future(() async {
// in this instance lastNameStreamBeacon will not be tracked as a dependency
// because it's accessed after the async gap
final firstName = await firstNameFutureBeacon.toFuture();
final lastName = await lastNameStreamBeacon.toFuture();
return 'Fullname is $firstName $lastName';
});
- DO:
final futureCounter = Beacon.future(() async {
// store the futures before the async gap ie: don't use await
final firstNameFuture = firstNameFutureBeacon.toFuture();
final lastNameFuture = lastNameStreamBeacon.toFuture();
// wait for the futures to complete
final (String firstName, String lastName) = await (firstNameFuture, lastNameFuture).wait;
return 'Fullname is $firstName $lastName';
});