Flutter Intermediate Lesson 54 of 135 3 min read
Signals in Flutter: Fine-Grained Reactive State Made Simple
Learn the signals package for Flutter with easy runnable examples: signal, computed, effect and the Watch widget for fine-grained reactive state management.
On this page
Signals is a small, fast reactive state library. A signal holds a value. Anything that reads it, a computed value, an effect or a Watch widget, updates automatically when it changes. Only the exact widgets that read a signal rebuild, which is called fine-grained reactivity.
The same idea is used by web frameworks such as SolidJS, Preact and Angular.
Install
flutter pub add signals
The counter
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
final counter = signal(0);
final doubled = computed(() => counter.value * 2);
void main() => runApp(const MaterialApp(home: CounterPage()));
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Signals')),
body: Center(
child: Watch((context) => Text(
'${counter.value} × 2 = ${doubled.value}',
style: const TextStyle(fontSize: 36),
)),
),
floatingActionButton: FloatingActionButton(
onPressed: () => counter.value++,
child: const Icon(Icons.add),
),
);
}
}
signal(0)creates a value you can read and set with.value.computed(() => ...)is recalculated only when a signal it reads changes.Watch((context) => ...)rebuilds only the widget inside it.
A shopping list
import 'package:flutter/material.dart';
import 'package:signals/signals_flutter.dart';
final items = listSignal<String>(['Milk', 'Bread']);
final count = computed(() => items.length);
void main() => runApp(const MaterialApp(home: ListPage()));
class ListPage extends StatefulWidget {
const ListPage({super.key});
@override
State<ListPage> createState() => _ListPageState();
}
class _ListPageState extends State<ListPage> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _add() {
final text = _controller.text.trim();
if (text.isEmpty) return;
items.add(text); // listSignal notifies on add/remove
_controller.clear();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Watch((context) => Text('Shopping (${count.value})'))),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(12),
child: TextField(
controller: _controller,
onSubmitted: (_) => _add(),
decoration: InputDecoration(
hintText: 'Add an item',
suffixIcon: IconButton(icon: const Icon(Icons.add), onPressed: _add),
),
),
),
Expanded(
child: Watch((context) => ListView(
children: [
for (var i = 0; i < items.length; i++)
ListTile(
title: Text(items[i]),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => items.removeAt(i),
),
),
],
)),
),
],
),
);
}
}
Effects: react to changes
final dispose = effect(() {
debugPrint('Counter changed to ${counter.value}');
});
// Call dispose() to stop.
Use effects for side effects: saving, logging, analytics. Do not change the signal you are watching inside its own effect.
Building blocks
| Function | What it gives you |
|---|---|
signal(v) | A readable and writable value |
computed(() => ...) | A read-only derived value |
effect(() => ...) | Code that reruns when signals it reads change |
listSignal, mapSignal, setSignal | Collections that notify on change |
futureSignal, streamSignal | Async values with loading and error states |
batch(() => ...) | Several changes, one update |
Watch((context) => ...) | A widget that rebuilds when signals inside change |
Pros and cons
- Pros: tiny API; very fast; only the widgets that read a value rebuild; works in pure Dart too.
- Cons: newer than Provider, Riverpod and Bloc, so fewer tutorials and team conventions; global signals need discipline in big apps.
Try it yourself
Add a computed signal for “items starting with B” and show it in a second Watch at the bottom of the screen.