Core Concepts
Signals
A signal is a reactive container. Read it by calling it, write with .set(...).
dart
final name = signal(context, 'Oref');
name(); // read
name.set('Oref v2'); // writeNullable Context (Widget-bound vs Global)
All core APIs accept BuildContext?. When you pass a widget context, Oref memoizes the reactive node and disposes it with the widget. When you pass null, the node becomes global/standalone and you must manage cleanup.
dart
// Inside a widget build -> auto-bound & auto-disposed
final count = signal(context, 0);
effect(context, () => debugPrint('count: ${count()}'));
// Outside widgets -> pass null and clean up manually
final globalCount = signal<int>(null, 0);
final stop = effect(null, () => debugPrint('global: ${globalCount()}'));
// later...
stop(); // dispose effectWrite API in Practice
Signals are callables, not mutable fields. To update, read then .set(...):
dart
final count = signal(context, 0);
void increment() {
count.set(count() + 1);
}
void reset() {
count.set(0);
}Computed
Computed values derive from signals and cache automatically.
dart
final count = signal(context, 2);
final squared = computed(context, (_) => count() * count());Writable Computed
A writable computed value can map writes back to a source.
dart
import 'dart:math' as math;
final count = signal<double>(context, 0);
final squared = writableComputed<double>(
context,
get: (_) => count() * count(),
set: (value) => count.set(math.sqrt(value)),
);Effects
Effects track dependencies and re-run when signals change.
dart
final count = signal(context, 0);
effect(context, () {
debugPrint('count = ${count()}');
});Batch and Untrack
Batch multiple updates to avoid extra recomputations:
dart
batch(() {
a.set(1);
b.set(2);
});Use untrack when you want to read without subscribing:
dart
final value = untrack(() => count());Flutter Examples (from the example app)
dart
class CounterSection extends StatelessWidget {
const CounterSection({super.key});
@override
Widget build(BuildContext context) {
final count = signal<double>(context, 2);
final doubled = computed<double>(context, (_) => count() * 2);
final squared = writableComputed<double>(
context,
get: (_) => count() * count(),
set: (value) {
final safe = value < 0 ? 0.0 : value;
count.set(math.sqrt(safe));
},
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('count: ${count().toStringAsFixed(1)}'),
Text('doubled (computed): ${doubled().toStringAsFixed(1)}'),
Text('squared (writable): ${squared().toStringAsFixed(1)}'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: () => count.set(count() + 1),
child: const Text('Increment'),
),
OutlinedButton(
onPressed: () => squared.set(81),
child: const Text('Set squared = 81'),
),
],
),
],
);
}
}dart
class EffectBatchSection extends StatelessWidget {
const EffectBatchSection({super.key});
@override
Widget build(BuildContext context) {
final a = signal<int>(context, 1);
final b = signal<int>(context, 2);
final sum = computed<int>(context, (_) => a() + b());
final effectRuns = signal<int>(context, 0);
effect(context, () {
sum();
final current = untrack(() => effectRuns());
effectRuns.set(current + 1);
});
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('a: ${a()} b: ${b()} sum (computed): ${sum()}'),
Text('effect runs: ${effectRuns()}'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
children: [
ElevatedButton(
onPressed: () => a.set(a() + 1),
child: const Text('Increment A'),
),
ElevatedButton(
onPressed: () => b.set(b() + 1),
child: const Text('Increment B'),
),
OutlinedButton(
onPressed: () {
batch(() {
a.set(a() + 1);
b.set(b() + 1);
});
},
child: const Text('Batch +1 both'),
),
],
),
],
);
}
}