Reactive List / Set / Map / Queue for Flutter. Mutate in
place, rebuild on real change only. Ships matching flutter_hooks
hooks for one-line widget integration.
final todos = useListNotifier<String>(['buy milk']);
todos.add('walk dog'); // rebuilds — new element
todos[0] = 'buy milk'; // silent — same value
todos[0] = 'buy eggs'; // rebuilds — value changed
todos.clear(); // rebuilds — was non-empty
todos.clear(); // silent — already empty- In-place collection state in a widget: selection toggles, todo lists, key/value settings, FIFO/LIFO queues.
- Anywhere
ValueNotifier<List<T>>would force a[...state, x]copy per mutation.
- Not a state container. Wrap with
ChangeNotifierProvider(Provider) or expose via a Riverpod notifier when you need DI. - Not deep-reactive. Mutating an element in place
(
list[0].field = x) bypasses the equality check — usefreezedorequatablefor element types. - Not for a single value. Reach for stdlib
ValueNotifier<T>.
flutter pub add collection_notifiersimport 'package:collection_notifiers/collection_notifiers.dart';flutter_hooks is a runtime
dependency — the hook variants ship with the package.
| Need | Class | Hook |
|---|---|---|
| Ordered, indexable, reorderable | ListNotifier |
useListNotifier |
| Unique elements, selection toggles | SetNotifier |
useSetNotifier |
| Key → value lookup | MapNotifier |
useMapNotifier |
| FIFO/LIFO head-or-tail mutation | QueueNotifier |
useQueueNotifier |
Each class extends package:collection's DelegatingX and mixes in
ChangeNotifier — drop-in replacement for the matching dart:core
collection, plus ValueListenable<List<E>> / <Set<E>> / <Map<K, V>>
/ <Queue<E>>.
The hook owns the lifecycle: creates the notifier on first build, disposes on unmount, rebuilds the host widget on every mutation. Zero boilerplate.
class TodoList extends HookWidget {
const TodoList({super.key});
@override
Widget build(BuildContext context) {
final todos = useListNotifier<String>(['buy milk']);
return Column(
children: [
FilledButton(
onPressed: () => todos.add('walk dog'),
child: const Text('Add'),
),
for (final t in todos) Text(t),
],
);
}
}initial is consumed once. To reset on a dependency change, scope
the host widget under a different key so the hook re-mounts.
If the notifier is owned upstream (Riverpod, parent widget), subscribe without recreating it:
useListenable(notifier);If flutter_hooks is not on the project, fall back to
ValueListenableBuilder — the notifier exposes itself as the value:
final todos = ListNotifier<String>(['buy milk']);
ValueListenableBuilder<List<String>>(
valueListenable: todos,
builder: (context, items, _) => Column(
children: [for (final t in items) Text(t)],
),
);Dispose todos yourself — ChangeNotifierProvider /
State.dispose / ref.onDispose.
Mutating methods call notifyListeners() only when the underlying
collection actually changes.
final tags = SetNotifier<String>({'flutter', 'dart'});
tags.add('rust'); // notifies — new element
tags.add('rust'); // silent — already present
tags.remove('rust'); // notifies — element removed
tags.remove('rust'); // silent — wasn't there
tags.clear(); // notifies — set drained
tags.clear(); // silent — already empty
final config = MapNotifier<String, int>({'volume': 50});
config['volume'] = 75; // notifies — value changed
config['volume'] = 75; // silent — same value
config['bass'] = 30; // notifies — new keyThe per-method strategy — length-delta diff, equality-guarded
single-slot, containsKey disambiguation for nullable values — is
documented in each method's dartdoc.
ListNotifier.sort/shuffleonlength > 1always notify. Verifying order-preservation costs O(n) per call and defeats the optimisation budget. Lists of length 0 or 1 short-circuit silently.MapNotifier.addEntriesuses a length-only check: re-inserting an existing key with a different value mutates the map but does not notify. Useoperator []=oraddAllwhen per-key value-diff matters.
final selected = useSetNotifier<int>();
CheckboxListTile(
value: selected.contains(id),
onChanged: (_) => selected.invert(id),
title: Text('Item $id'),
);invert(e) toggles presence — returns true if added, false if
removed.
final settings = useMapNotifier<String, Object>({
'darkMode': false,
'fontSize': 14,
});
settings['darkMode'] = !(settings['darkMode']! as bool);
settings['fontSize'] = 14; // silent — already 14final todos = useListNotifier<Todo>();
todos.add(Todo(title: 'learn Flutter'));
todos.removeWhere((t) => t.completed);
// reorder
final item = todos.removeAt(oldIndex);
todos.insert(newIndex, item);
// sort always notifies on length > 1 — see "Exceptions"
todos.sort((a, b) => a.priority.compareTo(b.priority));final todosProvider = ChangeNotifierProvider((ref) {
return ListNotifier<String>(['initial']);
});
class TodoList extends ConsumerWidget {
const TodoList({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final todos = ref.watch(todosProvider);
return Column(children: [for (final t in todos) Text(t)]);
}
}A bundled skill ships at
skills/flutter-collection-notifiers/SKILL.md.
Vendor it into your agent's skill directory
(~/.claude/skills/flutter-collection-notifiers/, or the Cursor /
AntiGravity equivalent). It teaches the agent to pick the right
notifier, wire the matching hook, respect dispose discipline, and
stop replacing the collection instead of mutating it.
| ❌ | ✅ |
|---|---|
notifier = ListNotifier([...]) — reassigning kills listeners |
notifier..clear()..addAll([...]) — mutate in place |
Custom element types with default == / hashCode |
freezed / equatable so equality checks can see real changes |
useListNotifier(seed) with a fresh seed per rebuild expecting a reset |
initial is consumed once — change the host widget's key to reset |
StatefulWidget holding a notifier without disposing |
Use the matching hook, or override dispose and call notifier.dispose() |
Expecting addEntries to fire on value change for an existing key |
Use operator []= / addAll — addEntries is length-only |
Breaking: SetNotifier.invert(e) return value:
// v1.x — returned whichever of add()/remove() ran
// v2.x — true if element was added, false if removed
selected.invert(1);MIT.