Imported from JavascriptMick/signals-agent-skills (
.agent/skills/flutter-bloc-to-signals/SKILL.md). Install upstream withnpx skills add JavascriptMick/signals-agent-skills --skill flutter-bloc-to-signals. Copyright stays with the author.
BLoC → Signals Migration Skill
Do not use this skill when
- The project is intentionally keeping BLoC and only wants advice within BLoC
- The migration target is a library other than
signals/signals_flutter
Scope Gate — Read First
Before starting, identify which BLoC flavour is in use:
| Pattern | How to identify | This skill |
|---|---|---|
| Cubit | Class extends Cubit<SomeState>, mutations call emit(state.copyWith(...)) |
✅ Fully covered |
| Raw Bloc | Class extends Bloc<Event, State>, uses on<Event>() handlers |
⚠️ Partially covered — map events to Store methods, flag anything ambiguous |
If the code is raw Bloc, proceed with best effort: treat each event handler as a Store method, note deviations, and ask the user to confirm the output before moving on.
Mental Model: The Concept Map
| BLoC concept | Signals equivalent |
|---|---|
Cubit<State> class |
Store class (plain Dart class) |
Bloc<Event, State> |
Store class with one method per event |
@freezed State fields |
Individual signal<T>() fields on the Store |
state getter |
signal.value |
| Computed getter on State | computed<T>(() => ...) field on the Store |
emit(state.copyWith(...)) |
signal.value = newValue or batch(() { ... }) |
BlocProvider |
Provider<MyStore>.value(value: store) in MultiProvider |
context.read<MyCubit>() |
context.read<MyStore>() |
context.watch<B>() |
Signal read inside Watch |
BlocBuilder<C,S> |
Watch.builder((context) { ... }) |
BlocListener |
effect(() { ... }) |
BlocConsumer |
Watch.builder + effect together |
BlocSelector<C,S,T> |
computed<T> + Watch on the computed |
HydratedCubit |
HiveCacheBox for key/value persistence |
stream.listen(...) in Cubit |
effect(() { ... }) to (re)subscribe |
MultiBlocProvider |
MultiProvider |
Package Import Rule
Always use package:signals_flutter/signals_flutter.dart in Flutter app code (Stores, screens, widgets).
package:signals/signals.dart is for pure Dart only (no Flutter).
// ✅ correct for stores and screens
import 'package:signals_flutter/signals_flutter.dart';
Step-by-Step Refactor Process
Work through these steps for each domain being migrated.
Step 1 — Audit the BLoC
Read the existing files before writing anything.
- State file (
some_state.dart, often@freezed): list every field → each becomes asignal<T>()on the Store - Computed extensions (
some_state_computed_*.dart): list every getter → each becomes acomputed<T>()on the Store - Cubit file (
some_cubit.dart): list every mutation method → each becomes aFuture<void>orvoidmethod on the Store - Identify backing: is this domain backed by a DB/stream (e.g. PowerSync, Firestore), or is it pure in-memory state?
- Identify hydration: does this Cubit extend
HydratedCubit? If yes, the Store will need Hive persistence.
Step 2 — Create the DB class (DB-backed stores only)
If the domain reads from / writes to a database, extract all DB logic into a stateless class first.
- Create
lib/signals/<domain>/db/some_db.dart - All methods are
static - Accepts only basic Dart types — no signals, no Store references
- Returns
Stream<List<T>>for subscriptions,Future<void>for mutations
// lib/signals/<domain>/db/some_db.dart
class SomeDB {
static Stream<List<SomeItem>> getStream(int accountId) {
return psDB.watch(
'SELECT * FROM some_items WHERE account_id = ?',
parameters: [accountId],
throttle: const Duration(milliseconds: 300),
triggerOnTables: const ['some_items'],
).map((results) => results.map(SomeItem.fromRow).toList(growable: false));
}
static Future<void> insertItem(int accountId, String name) async {
await inWriteTxn((txn) async {
await txn.execute(
'INSERT INTO some_items (id, account_id, name) VALUES (uuid(), ?, ?)',
[accountId, name],
);
});
}
}
Step 3 — Create the Store class
Create lib/signals/<domain>/some_store.dart. Follow this layout exactly — it helps human reviewers verify the migration.
// ignore_for_file: non_constant_identifier_names
import 'dart:async';
import 'package:logging/logging.dart';
import 'package:signals_flutter/signals_flutter.dart';
import 'package:my_app/signals/signals_common.dart'; // for .untrackedValue
final log = Logger('some_store');
class SomeStore {
// 1. Service / API dependencies
final MyAPIClient apiClient;
// 2. Primary signals (mirror DB state for DB-backed stores)
final items = listSignal<MyModel>([], debugLabel: 'items');
// 3. UI-controlled state signals
final isLoading = signal<bool>(false, debugLabel: 'isLoading');
final selectedId = signal<String?>(null, debugLabel: 'selectedId');
// 4. Signals passed in from other stores (constructor dependencies)
final Signal<int> _activeAccountId;
// 5. Cross-wired signals (for circular dependencies — wired after construction)
late final ListSignal<OtherModel> _otherItems;
void wireOtherItems(ListSignal<OtherModel> s) { _otherItems = s; }
// 6. DB subscription infrastructure (DB-backed only)
StreamSubscription? _dbSub;
late final EffectCleanup _dbSubEffect;
// 7. Computed views
late final itemsView = computed<List<MyModelView>>(
() => items().map((item) => toMyModelView(item, _otherItems())).toList(),
debugLabel: 'itemsView',
);
// 8. Constructor
SomeStore(this.apiClient, this._activeAccountId) {
// DB-backed only: effect re-subscribes when account/store selection changes
_dbSubEffect = effect(() {
_subToDB(_activeAccountId());
}, debugLabel: 'someStoreSubEffect');
}
// 9. DB subscription method (DB-backed only)
void _subToDB(int accountId) {
_dbSub?.cancel();
if (accountId == 0) return;
_dbSub = SomeDB.getStream(accountId).listen((data) {
if (!listEquality(data, items())) {
items.value = data; // only update if data actually changed
}
});
}
// 10. Mutation methods
Future<void> doSomething(String itemId) async {
final accountId = _activeAccountId.untrackedValue; // untracked in mutations!
await SomeDB.updateItem(accountId, itemId);
// For non-DB stores: update signals directly
// isLoading.value = false;
}
// 11. Lifecycle hooks (if needed)
void handleAppResumed() { /* ... */ }
// 12. dispose
void dispose() {
_dbSubEffect(); // stop the re-sub effect
_dbSub?.cancel(); // stop the DB stream
isLoading.dispose();
selectedId.dispose();
items.dispose();
itemsView.dispose();
}
}
Step 4 — Extract view transformers
If computed views contain complex transformation logic, move it to pure functions:
- Create
lib/signals/<domain>/view_transformers.dart - Functions accept model types, return view model types
- No signals, no store references
- The Store's
computedcalls these functions
Step 5 — Wire in app.dart
// Construct with signals (not stores) as dependencies
someStore = SomeStore(
apiClient,
userAccountStore.activeAccountId, // Signal<int>, not the store
);
// Wire circular dependencies after all stores are constructed
someStore.wireOtherItems(otherStore.otherItems);
// Register with MultiProvider
MultiProvider(
providers: [
Provider<SomeStore>.value(value: someStore),
// ...
],
)
Step 6 — Update screens
BlocBuilder → Watch.builder
// Before
@override
Widget build(BuildContext context) {
return BlocBuilder<SomeCubit, SomeState>(
builder: (context, someState) {
// ...
},
);
}
// After
@override
Widget build(BuildContext context) {
final someStore = context.read<SomeStore>(); // read store once, at top of build
return Watch.builder(
builder: (context) {
final items = someStore.itemsView(); // signals accessed here are tracked
return ListView(children: items.map((i) => ItemTile(item: i)).toList());
},
);
}
- Read the store with
context.read<SomeStore>()once at the top ofbuild, outside theWatch.builder - Access signals inside the
Watch.buildercallback — that's where tracking happens - Replace
context.read<SomeCubit>()→context.read<SomeStore>() - Replace cubit method calls →
store.doSomething(itemId) - Remove all
flutter_blocimports from screen files
BlocListener → effect in StatefulWidget
late final EffectCleanup _cleanup;
@override
void initState() {
super.initState();
_cleanup = effect(() {
if (authStore.status.value == AuthStatus.failure) {
// schedule post-frame to access context safely
WidgetsBinding.instance.addPostFrameCallback((_) {
ScaffoldMessenger.of(context).showSnackBar(...);
});
}
});
}
@override
void dispose() {
_cleanup();
super.dispose();
}
BlocConsumer → Watch.builder + effect
Replace with Watch.builder for the UI rebuild portion and a separate effect (as above) for the side-effect portion.
Step 7 — Verify
flutter analyze # must be zero errors
Then hot reload and manually test the affected screen.
Key Signal Rules
Reading values
someSignal() // ✅ preferred in computed/effects/widgets — tracks dependency
someSignal.value // ✅ use when writing: someSignal.value = x
someSignal.untrackedValue // ✅ use in mutation methods — does NOT subscribe
Note:
.untrackedValueis not built into the signals package — add this extension to your project (e.g.lib/signals/signals_common.dart) before using it:import 'package:signals/signals_flutter.dart'; extension SignalUntrackedValueUtils<T> on Signal<T> { T get untrackedValue => untracked(() => value); } extension ReadonlySignalUntrackedValueUtils<T> on ReadonlySignal<T> { T get untrackedValue => untracked(() => value); } extension ComputedUntrackedValueUtils<T> on Computed<T> { T get untrackedValue => untracked(() => value); }
Never subscribe inside mutation methods
// ❌ WRONG — creates a subscription, may cause cycles
Future<void> doThing() async {
final id = _activeAccountId(); // subscribes!
}
// ✅ CORRECT
Future<void> doThing() async {
final id = _activeAccountId.untrackedValue;
}
Batch multiple signal writes
batch(() {
isLoading.value = false;
results.value = newResults;
errorMessage.value = null;
});
Always add debugLabel
final count = signal(0, debugLabel: 'count');
final doubled = computed(() => count() * 2, debugLabel: 'doubled');
final cleanup = effect(() { ... }, debugLabel: 'myEffect');
Never use effect() to set another signal
// ❌ WRONG — creates a cycle risk
effect(() { derivedValue.value = a() + b(); });
// ✅ CORRECT — use computed for derived state
final derivedValue = computed(() => a() + b(), debugLabel: 'derivedValue');
Cross-Store Dependencies
Never pass a Store into another Store's constructor. Pass signals instead.
// ✅ Pass the specific signal, not the whole store
someStore = SomeStore(
apiClient,
userAccountStore.activeAccountId, // Signal<int>
otherStore.relevantIds, // ReadonlySignal<List<int>>
);
// ✅ For circular dependencies, use wire methods after all stores are constructed
someStore.wireAnotherItems(anotherStore.anotherItems);
anotherStore.wireSomeItems(someStore.someItems);
Hydration (replacing HydratedCubit)
When the source Cubit extends HydratedCubit, use Hive for persistence:
final _cache = HiveCacheBox<bool>(boxName: 'some_store');
Future<void> initFromCache() async {
final cached = await _cache.get('myKey') ?? false;
mySignal.value = cached;
}
Future<void> setMyValue(bool newValue) async {
mySignal.value = newValue;
unawaited(_cache.put('myKey', newValue)); // fire and forget
}
initFromCache() should be called before runApp() in main.dart (or early in initState).
Human Reviewability Rules
These are non-negotiable when producing code for human review:
- Preserve comments — copy relevant comments from the old BLoC code
- Preserve variable names — even if inconsistent; renaming makes diffs hard to read
- Preserve order — state fields → computed → constructor → DB sub → mutations → dispose
- Preserve whitespace style — match the surrounding file
- Structural changes only — cosmetics (renames, reordering) are a separate pass; never mix them with structural changes
Migration Strategy
- Identify leaf Cubits first — migrate small, independent Cubits before complex Blocs
- Keep BLoC and signals coexisting during migration — they don't conflict
- Replace event classes with methods — signals removes the need for sealed event hierarchies
- Replace
BlocBuilderlast — swap the widget once the underlying Store is migrated - Audit
disposecalls — signals require explicit disposal; ensure everySignalandEffectCleanupis disposed
Common Mistakes
| Mistake | Consequence | Fix |
|---|---|---|
signal() call inside a mutation method |
Creates unwanted subscription, possible cycles | Use .untrackedValue |
Multiple signal writes without batch() |
Intermediate recomputes, jank | Wrap in batch() |
| Passing a Store as a constructor arg | Tight coupling, circular dependency risk | Pass the specific Signal<T> |
| Writing directly to signal in a DB-backed mutation | DB and signal drift out of sync | Write to DB; let the stream update the signal |
Forgetting to cancel _dbSub in dispose() |
Memory leak | _dbSub?.cancel() + _dbSubEffect() |
Missing debugLabel |
Painful to debug | Always add debugLabel |
Using effect() to set another signal |
Infinite update loop | Use computed() for derived state |
Importing signals.dart in Flutter widget files |
Missing Flutter integrations | Use signals_flutter.dart |
Checklist
- All
Cubit/Blocclasses converted to Store classes withSignalfields -
emit()calls replaced withsignal.value =orbatch(() { ... }) -
BlocBuilderreplaced withWatch.builder -
BlocListenerreplaced witheffect(withEffectCleanupstored and called indispose) -
BlocConsumerreplaced withWatch.builder+effect -
BlocProvider/MultiBlocProviderreplaced withProvider.value/MultiProvider -
context.read<MyCubit>()replaced withcontext.read<MyStore>() - Stores constructed with signal dependencies (not other stores)
-
HydratedCubitreplaced withHiveCacheBox -
flutter_blocdependency removed frompubspec.yaml - All signals, computed, and effects are disposed
-
flutter analyzereports zero errors