agentleFS
gabuldevgabuldev/flutter-skills/skills/flutter-concurrency/SKILL.md

flutter-concurrency

Skill12 starsChanged 56 days agoMITView on GitHub

Use when dealing with asynchrony in Flutter - async/await inside a Cubit, Streams and RxDart, Isolates for heavy work, cancelling an in-flight operation, or choosing between FutureBuilder and BlocBuilder. Triggers on UI freezes, jank, race conditions, or "setState after dispose".

1 other project uses a version of this.

Adopted by

Most recently taken up by

The file

We split this skill into the rules it gives an agent

10 rules. About 0 are common, 2 are distinctive, and 8 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈2

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 8

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Very specific:

    Rule of thumb: if it does NOT block the main isolate for more than one frame (~16 ms), use async/await.

    line 19
  2. Very specific:

    Always emit Loading before the async call

    line 50
  3. Distinctive:

    Never emit after the Cubit is closed (see Pitfalls section)

    line 52
  4. Very specific:

    Always dispose BehaviorSubject and cancel StreamSubscription in close()

    line 96
  5. Very specific:

    Use debounceTime for user input to avoid excessive API calls

    line 97
  6. Very specific:

    Use distinct() to skip duplicate emissions

    line 98
  7. Very specific:

    Use BehaviorSubject (not PublishSubject) when you need the latest value on subscription

    line 99
  8. Distinctive:

    All arguments must be sendable across isolate boundaries (primitives, Lists, Maps, typed data classes — no Cubits, no Dio instances)

    line 149
  9. Very specific:

    Always provide a way to stop the isolate (kill() + close the port)

    line 233
  10. Very specific:

    Rule: Every subscription, subject, and cancel token created in a controller must be cleaned up in close().

    line 316

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

You are implementing concurrency for a Flutter feature. Choose the pattern based on the skill argument, if one was given, or on the context of the operation.

Decision Framework

Pick the right tool for the workload:

WorkloadPatternExamples
I/O (network, storage)async/awaitDio API calls, SharedPreferences, Hive reads
CPU < 16 msasync/awaitSmall list transforms, formatting
One-time heavy CPUIsolate.run()PDF receipt generation, large JSON parsing
Continuous backgroundIsolate.spawn() with portsPayment status polling, data sync

Rule of thumb: if it does NOT block the main isolate for more than one frame (~16 ms), use async/await. Otherwise, offload to an isolate.


Pattern 1: Async in Cubits (default)

Best for: API calls, storage reads, form submissions.

Controllers follow the Loading → Success/Failure emission pattern:

class OrderController extends Cubit<OrderStatus> {
  final OrderRepository _repository;

  OrderController(this._repository) : super(const OrderInitial());

  Future<void> loadOrders() async {
    emit(const OrderLoading());
    try {
      final orders = await _repository.getOrders();
      emit(OrderSuccess(orders));
    } on DioException catch (e) {
      emit(OrderFailure(e.message ?? 'Erro ao carregar pedidos'));
    } catch (e) {
      emit(OrderFailure(e.toString()));
    }
  }
}

Rules:

  • Always emit Loading before the async call
  • Catch DioException separately for HTTP errors
  • Never emit after the Cubit is closed (see Pitfalls section)

Pattern 2: Stream Management with RxDart

Best for: real-time data, debounced search, reactive pipelines.

class SearchController extends Cubit<SearchStatus> {
  final SearchRepository _repository;
  final _querySubject = BehaviorSubject<String>.seeded('');
  StreamSubscription<String>? _querySub;

  SearchController(this._repository) : super(const SearchInitial()) {
    _querySub = _querySubject
        .debounceTime(const Duration(milliseconds: 400))
        .where((q) => q.length >= 2)
        .distinct()
        .listen(_performSearch);
  }

  void onQueryChanged(String query) => _querySubject.add(query);

  Future<void> _performSearch(String query) async {
    emit(const SearchLoading());
    try {
      final results = await _repository.search(query);
      emit(SearchSuccess(results));
    } catch (e) {
      emit(SearchFailure(e.toString()));
    }
  }

  @override
  Future<void> close() {
    _querySub?.cancel();
    _querySubject.close();
    return super.close();
  }
}

Rules:

  • Always dispose BehaviorSubject and cancel StreamSubscription in close()
  • Use debounceTime for user input to avoid excessive API calls
  • Use distinct() to skip duplicate emissions
  • Use BehaviorSubject (not PublishSubject) when you need the latest value on subscription

Pattern 3: Isolate.run() for One-Time Heavy Work

Best for: PDF receipt generation, parsing large JSON payloads, image processing.

PDF Receipt Generation

class ReceiptController extends Cubit<ReceiptStatus> {
  ReceiptController() : super(const ReceiptInitial());

  Future<void> generateReceipt(Order order) async {
    emit(const ReceiptLoading());
    try {
      // Offload PDF generation to a background isolate
      final pdfBytes = await Isolate.run(() => _buildReceiptPdf(order));
      emit(ReceiptSuccess(pdfBytes));
    } catch (e) {
      emit(ReceiptFailure('Erro ao gerar comprovante: $e'));
    }
  }
}

// Top-level or static function (required for Isolate.run)
Uint8List _buildReceiptPdf(Order order) {
  final pdf = pw.Document();
  pdf.addPage(
    pw.Page(
      pageFormat: PdfPageFormat(80 * PdfPageFormat.mm, double.infinity),
      build: (context) => pw.Column(
        crossAxisAlignment: pw.CrossAxisAlignment.start,
        children: [
          pw.Text('Pedido #${order.id}', style: pw.TextStyle(fontSize: 14, fontWeight: pw.FontWeight.bold)),
          pw.SizedBox(height: 4),
          ...order.items.map((item) => pw.Text('${item.quantity}x ${item.name} - R\$ ${item.total.toStringAsFixed(2)}')),
          pw.Divider(),
          pw.Text('Total: R\$ ${order.total.toStringAsFixed(2)}', style: pw.TextStyle(fontWeight: pw.FontWeight.bold)),
        ],
      ),
    ),
  );
  return pdf.save();
}

Rules:

  • The function passed to Isolate.run() must be top-level or static (no closures over instance state)
  • All arguments must be sendable across isolate boundaries (primitives, Lists, Maps, typed data classes — no Cubits, no Dio instances)
  • Isolate.run() handles spawning and killing the isolate automatically

Large Data Processing

Future<void> processReport(List<Map<String, dynamic>> rawData) async {
  emit(const ReportLoading());
  try {
    final processed = await Isolate.run(() => _aggregateReportData(rawData));
    emit(ReportSuccess(processed));
  } catch (e) {
    emit(ReportFailure('Erro ao processar relatório: $e'));
  }
}

// Top-level function
ReportData _aggregateReportData(List<Map<String, dynamic>> rawData) {
  // Heavy aggregation, grouping, sorting — runs off the main isolate
  final grouped = <String, double>{};
  for (final entry in rawData) {
    final category = entry['category'] as String;
    final value = (entry['value'] as num).toDouble();
    grouped[category] = (grouped[category] ?? 0) + value;
  }
  return ReportData(grouped);
}

Pattern 4: Isolate.spawn() for Continuous Background Work

Best for: payment status polling, background data sync.

class PaymentPollingService {
  Isolate? _isolate;
  ReceivePort? _receivePort;
  StreamSubscription? _portSub;

  Future<void> startPolling(String transactionId) async {
    _receivePort = ReceivePort();

    _isolate = await Isolate.spawn(
      _pollPaymentStatus,
      _PollingArgs(transactionId, _receivePort!.sendPort),
    );

    _portSub = _receivePort!.listen((message) {
      if (message is PaymentStatusUpdate) {
        // Forward to EventBus/Broker or Cubit
        Broker.instance.fire(PaymentStatusEvent(message));
      }
    });
  }

  void stopPolling() {
    _portSub?.cancel();
    _receivePort?.close();
    _isolate?.kill(priority: Isolate.immediate);
    _isolate = null;
  }
}

// Top-level function
void _pollPaymentStatus(_PollingArgs args) async {
  while (true) {
    await Future.delayed(const Duration(seconds: 3));
    // Simulate checking payment status (no Dio here — use dart:io HttpClient or pass data via ports)
    final status = await _checkStatus(args.transactionId);
    args.sendPort.send(PaymentStatusUpdate(status));
    if (status == 'approved' || status == 'declined') break;
  }
}

class _PollingArgs {
  final String transactionId;
  final SendPort sendPort;
  _PollingArgs(this.transactionId, this.sendPort);
}

Rules:

  • Always provide a way to stop the isolate (kill() + close the port)
  • The spawned function cannot use objects from the main isolate (no Dio, no Cubit, no repos)
  • Communicate via SendPort / ReceivePort only with sendable types
  • Clean up in the service's dispose() or controller's close()

FutureBuilder vs BlocBuilder

With the Cubit pattern, prefer BlocBuilder over FutureBuilder.

FutureBuilderBlocBuilder
Use whenQuick prototype, one-off Future with no stateThe default for anything with state
Re-triggers on rebuildYes (unless Future is cached)No (reacts to state only)
TestableHardEasy (mock Cubit, emit states)
Error handlingIn builder snapshot.hasErrorIn state class (FailureStatus)
// PREFERRED
BlocBuilder<OrderController, OrderStatus>(
  builder: (context, state) {
    if (state is OrderLoading) return const CircularProgressIndicator();
    if (state is OrderSuccess) return OrderListView(orders: state.orders);
    if (state is OrderFailure) return ErrorWidget(message: state.message);
    return const SizedBox.shrink();
  },
)

// AVOID unless truly one-off and outside of a Cubit-managed feature
FutureBuilder<List<Order>>(
  future: _future, // must be cached in initState, never inline
  builder: (context, snapshot) { ... },
)

Cancellation

HTTP Requests with Dio CancelToken

class ProductController extends Cubit<ProductStatus> {
  final ProductRepository _repository;
  CancelToken? _cancelToken;

  ProductController(this._repository) : super(const ProductInitial());

  Future<void> loadProducts() async {
    _cancelToken?.cancel('New request started');
    _cancelToken = CancelToken();

    emit(const ProductLoading());
    try {
      final products = await _repository.getProducts(cancelToken: _cancelToken!);
      emit(ProductSuccess(products));
    } on DioException catch (e) {
      if (e.type == DioExceptionType.cancel) return; // silently ignore
      emit(ProductFailure(e.message ?? 'Erro'));
    }
  }

  @override
  Future<void> close() {
    _cancelToken?.cancel('Controller closed');
    return super.close();
  }
}

Stream Subscriptions

@override
Future<void> close() {
  _subscription?.cancel();  // StreamSubscription
  _subject.close();         // BehaviorSubject
  _cancelToken?.cancel();   // Dio CancelToken
  return super.close();
}

Rule: Every subscription, subject, and cancel token created in a controller must be cleaned up in close().


Common Pitfalls

1. Emit after close

// BAD — can throw StateError if Cubit was closed during the await
Future<void> load() async {
  emit(const Loading());
  final data = await _repo.fetch();
  emit(Success(data)); // 💥 if navigated away
}

// GOOD — guard before emitting
Future<void> load() async {
  emit(const Loading());
  final data = await _repo.fetch();
  if (isClosed) return;
  emit(Success(data));
}

2. Uncancelled subscriptions

// BAD — leaks the subscription
class MyController extends Cubit<MyStatus> {
  MyController(Stream<int> stream) : super(const MyInitial()) {
    stream.listen((val) => emit(MyUpdated(val))); // never cancelled
  }
}

// GOOD
class MyController extends Cubit<MyStatus> {
  late final StreamSubscription<int> _sub;
  MyController(Stream<int> stream) : super(const MyInitial()) {
    _sub = stream.listen((val) => emit(MyUpdated(val)));
  }
  @override
  Future<void> close() { _sub.cancel(); return super.close(); }
}

3. Blocking the main isolate

// BAD — 200ms of JSON parsing freezes the UI
final data = jsonDecode(hugeJsonString) as List;

// GOOD — offload to isolate
final data = await Isolate.run(() => jsonDecode(hugeJsonString) as List);

4. Passing non-sendable objects to isolates

// BAD — Dio is not sendable
await Isolate.run(() => dio.get('/endpoint')); // 💥 runtime error

// GOOD — fetch on main, process on isolate
final response = await dio.get('/endpoint');
final processed = await Isolate.run(() => _heavyParse(response.data));

5. FutureBuilder with inline Future

// BAD — re-creates the Future on every build
Widget build(BuildContext context) {
  return FutureBuilder(
    future: repository.load(), // called every rebuild!
    builder: (ctx, snap) => ...,
  );
}

// GOOD — cache the Future
late final _future = repository.load();
Widget build(BuildContext context) {
  return FutureBuilder(future: _future, builder: (ctx, snap) => ...);
}

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.