skip to content

In Flutter, why is compute() usually handed a top-level or static function rather than a closure or an instance method of a State?

level: middleimportance: should knowfreq 42%

answer

  1. the callback travels too
  2. a tear-off is bound to this
  3. captured context comes along
  4. copies, not shared objects
  5. the compute docs warn about closures

basics

~20 s

compute() sends the callback itself to the new isolate along with the message. A top-level or static function captures nothing, while a closure or instance-method tear-off drags its captured context, often the whole State, along, which is costly or fails to send.

solid answer

~40 s

On native platforms `compute(callback, message)` runs `Isolate.run(() => callback(message))`, so the callback is part of what gets sent to the new isolate. A **top-level or static function** has no captured state: only the message crosses over. A **closure** captures the variables it uses, and an **instance-method tear-off** such as `_parse` inside a `State` is bound to `this`, so the whole `State` and everything reachable from it (its element, widget, controllers) must be copied. That is slow at best and throws at worst when something reachable cannot be sent. Even when it works, the isolate mutates copies, never the live `State`. The `compute` docs explicitly warn that sending closures can capture more state than needed; a top-level function makes the boundary obvious.

code

dart · 41 lines
dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';

// Top-level: captures nothing; only `body` is sent.
List<String> parseStopNames(String body) =>
    body.split('\n').where((String line) => line.isNotEmpty).toList();

class StopsPage extends StatefulWidget {
  const StopsPage({super.key, required this.body});
  final String body;

  @override
  State<StopsPage> createState() => _StopsPageState();
}

class _StopsPageState extends State<StopsPage> {
  List<String> _stops = const <String>[];

  // Avoid passing this to compute(): the tear-off is bound to the State.
  List<String> _parse(String body) => parseStopNames(body);

  Future<void> _load() async {
    // Risky: await compute(_parse, widget.body);
    final List<String> stops = await compute(parseStopNames, widget.body);
    if (!mounted) {
      return;
    }
    setState(() => _stops = stops);
  }

  @override
  void initState() {
    super.initState();
    _load();
  }

  @override
  Widget build(BuildContext context) {
    return ListView(children: <Widget>[for (final String s in _stops) Text(s)]);
  }
}

go deeper

for a junior

Remember to give compute() a top-level or static function and pass all its inputs in the single message.

for a middle

Explain that the callback is sent too, that a tear-off is bound to this, and what copying a State implies.

for a senior

Spot accidental captures in review, and design callback inputs and outputs as small, plain data so the boundary stays cheap.

for a principal

Encode the isolate boundary in the codebase, for example parsers in pure-Dart files with no Flutter imports, so accidental captures cannot happen.

## What actually crosses the isolate boundary Isolates in Dart do not share memory. When `compute` starts work on another isolate, everything that work needs must be **sent**: on native platforms `compute(callback, message)` is implemented as `Isolate.run(() => callback(message))`, a small closure that holds both the callback and the message. Sending that closure means sending everything it references. - A **top-level function** or a **static method** is just code. Referencing it adds nothing to the message. - A **closure** (an anonymous function) carries the variables it captures from the surrounding scope. - An **instance-method tear-off** like `this._parse`, or just `_parse` inside a class, is a closure bound to its receiver, so it carries `this`. ## Why capturing a State is a problem Inside a `State` subclass, `this` is the `State` object. It references its widget, its element, any `TextEditingController` or `AnimationController` it owns and, through the element, a large part of the running framework. Handing `compute` an instance method therefore asks Dart to copy that whole graph into the new isolate. Three things can happen: 1. **It is slow.** Copying a large object graph costs time and memory, which can erase the benefit of moving the work. 2. **It fails.** The `compute` documentation notes that most objects can be sent but not all; if anything reachable cannot cross, the call throws instead of running your parse. 3. **It silently does nothing useful.** Even when the copy succeeds, the isolate works on copies. Assigning to a field of the copied `State` does not change the one on screen. The Flutter docs on `compute` carry this warning directly: see `SendPort.send` for a note about sending closures, "which can capture more state than needed". ## The pattern that avoids all three | Callback | What is sent | Verdict | |---|---|---| | Top-level function `parseDepartures` | the message only | preferred | | `static` method `ScheduleParser.parse` | the message only | fine | | Closure capturing a local `String` | that string and the message | works, but easy to grow | | Tear-off of a `State` method | the `State`, everything it reaches, the message | avoid | Guidelines that follow from the table: - **Write the callback as a top-level or static function** that takes one parameter and returns the result. - **Pass everything it needs through the message.** For several values, pass a record such as `(String body, int limit)`. - **Return the result instead of mutating shared state.** Apply it on the main isolate after the `await`, inside `setState` if needed. - **Keep closures deliberate.** If you do pass one, make sure it only captures small, sendable values. ## A note on older advice Older Flutter guidance often said the callback had to be top-level or static. Current Dart can send many closures, and the `compute` docs now only warn about what they capture. The top-level rule survives as good practice because it makes the isolate boundary visible in the code and keeps accidental captures out.

  • In Flutter, how do you pass two inputs to a compute() callback that takes one message?
    Bundle them into one value: a record like `(body, limit)` with a callback typed `List<Departure> parse((String, int) args)`, or a small immutable class. Destructure it at the top of the callback. Everything inside must itself be sendable.
  • If the compute() callback assigns to a global variable, does the main isolate see the change?
    No. Each isolate has its own separate copy of global and static variables. The assignment changes the background isolate's copy, which disappears when it exits. Return the value instead and assign it on the main isolate.

saying these in an interview costs you the question

  • An instance-method tear-off sends only the method's code, not its object.
  • The background isolate can update the State's fields directly.
  • Globals set inside the compute() callback are visible on the main isolate.
  • Current Dart forbids sending any closure, so only top-level functions compile.