In Flutter, why is calling setState from an AnimationController listener on every tick discouraged, and what replaces it?
answer
- whole build method, every frame
- listener plus setState
- push the listening down
- AnimatedBuilder or a FooTransition
- FadeTransition skips build entirely
basics
~20 ssetState in a controller listener re-runs the State's whole build method every frame, rebuilding widgets that never change. AnimatedBuilder or a FooTransition widget scopes the rebuild to what depends on the animation, and FadeTransition avoids widget rebuilds altogether.
solid answer
~40 sThe pattern `_controller.addListener(() => setState(() {}))` works, but it marks the whole `State` dirty on every tick, so its entire `build` runs once per frame - headers, text, lists - although only one value moves. The fix is to push the listening down to the widget that needs the value: wrap just the animated part in an `AnimatedBuilder`, or use a ready-made transition such as `RotationTransition`, `ScaleTransition`, `SlideTransition` or `SizeTransition`, each an `AnimatedWidget` that rebuilds only itself. `FadeTransition` goes further: it is a render-object widget whose `RenderAnimatedOpacity` listens to the animation directly, so ticks update the layer's opacity with no widget rebuild at all. `setState` remains right for discrete changes, such as swapping content once the animation completes.
code
dart · 46 linesimport 'package:flutter/material.dart';
// Before: the whole page rebuilds every frame.
// _controller.addListener(() => setState(() {}));
// ... Transform.rotate(angle: _controller.value * 2 * math.pi, child: logo)
// After: only the RotationTransition rebuilds each tick; the page does not.
class SyncPage extends StatefulWidget {
const SyncPage({super.key});
@override
State<SyncPage> createState() => _SyncPageState();
}
class _SyncPageState extends State<SyncPage>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Syncing')),
body: Center(
child: RotationTransition(
turns: _controller,
child: const Icon(Icons.autorenew, size: 48),
),
),
);
}
}go deeper
Recall that setState in a controller listener rebuilds the whole State every frame, and that AnimatedBuilder or a FooTransition widget is the usual replacement.
Explain that transition widgets are AnimatedWidgets calling setState on themselves, and that FadeTransition works at the render-object level with no rebuild.
Move animation state out of page-level States into small widgets, and read a per-frame rebuild of a whole screen as a design smell rather than tuning around it.
Weigh the readability of a single page State against the frame-time cost on low-end devices, and set a convention for where animation controllers may live.
## The listener-plus-setState pattern The first explicit animation most Flutter developers write looks like this: ```dart _controller.addListener(() { setState(() {}); // rebuild with the new _controller.value }); ``` It works: an `AnimationController` notifies its listeners once per frame while it runs, `setState` marks the `State` dirty, and the next frame calls `build`, which reads `_controller.value`. The problem is the **scope**. `setState` knows nothing about which widgets read the value. It schedules the **whole** `build` method of that `State` to run again, every frame, for as long as the animation lasts. ## What it costs - **Build work proportional to the screen, not the effect.** If the `State` owns a page - an app bar, a header, a form, a list - all of it is reconstructed each frame to move one icon. - **Element updates down the tree.** Every new non-`const` widget instance makes its element update and, for stateless and stateful widgets, build again. - **A listener you must manage by hand.** The listener has to be removed or the controller disposed with the `State`; a transition widget manages its own subscription. - **Frame budget pressure.** On a slow device, a large per-frame build is one of the commonest reasons an otherwise simple animation drops frames. ## Scoping the rebuild | Approach | What rebuilds on each tick | |---|---| | `setState` in a controller listener | the entire `build` of that `State` | | `AnimatedBuilder` around the animated part | the widgets its `builder` returns; a pre-built `child` is reused | | a `FooTransition` (`RotationTransition`, `ScaleTransition`, `SlideTransition`, `SizeTransition`) | only that transition widget, which wraps its `child` in a `Transform`, `FractionalTranslation` or `Align` | | `FadeTransition` | no widget at all - its render object listens and updates the layer's opacity | The transition widgets are subclasses of **`AnimatedWidget`**: a `StatefulWidget` whose private `State` subscribes to the animation and calls `setState` on itself. So they still use `setState` - just on the smallest possible widget. `FadeTransition` is the exception: it is a `SingleChildRenderObjectWidget` that hands the `Animation<double>` to a `RenderAnimatedOpacity`, which listens directly and updates only its compositing layer when the alpha changes. ## Before and after: a loading logo on a busy page 1. **Before:** the page `State` owns the controller, adds a listener that calls `setState`, and its `build` returns the whole page including `Transform.rotate(angle: _controller.value * 2 * math.pi, child: logo)`. 2. **After:** the page's `build` returns `RotationTransition(turns: _controller, child: logo)` in the same place, and the listener is deleted. The page's `build` now runs only when the page's own state changes. 3. **Better still for reuse:** move the controller and the transition into a small `LoadingLogo` widget with its own `State`, so the page no longer owns animation state at all. ## When setState is still the right tool - Reacting **once** to a status change - for example, showing a *Continue* button when the controller reaches `AnimationStatus.completed`. - Changing **which** animation or content is shown, rather than the animated value itself. - Very small `State`s whose whole `build` is the animated widget anyway - though a transition is still clearer. The rule interviewers look for: listen to an animation **as low in the tree as possible**, and let the framework's transition widgets do the listening for you. ## Mistakes interviewers listen for - Saying `setState` rebuilds only the widgets that read `controller.value` - it has no such knowledge. - Claiming the transition widgets avoid `setState` altogether; most of them use it, just on a tiny `State`. - Calling `FadeTransition` an `AnimatedWidget`; it works one level lower, on the render object. - Keeping the page-level listener *and* adding a transition, so the page still rebuilds every frame. - Forgetting that the controller still needs disposing whichever listening style is used.
- The transition widgets also call setState internally. Why is that acceptable?Because the `setState` is on the transition's own tiny `State`, whose `build` returns one wrapper such as a `Transform` around a `child` that was built elsewhere. The rebuild is a couple of widgets rather than the page. `AnimatedWidget` also removes its listener in `dispose`, so there is no manual bookkeeping.
- Why does FadeTransition not rebuild any widget per tick?`FadeTransition` is a `SingleChildRenderObjectWidget`, not an `AnimatedWidget`. It passes the `Animation<double>` to a `RenderAnimatedOpacity`, which subscribes to it directly and, when the alpha changes, updates its compositing layer. No build phase runs for the tick at all.
saying these in an interview costs you the question
- setState rebuilds only the widgets that read controller.value.
- An AnimationController repaints the screen by itself, so no rebuild is needed.
- FadeTransition is an AnimatedWidget that rebuilds itself every tick.
- Transition widgets avoid setState entirely, so they cost nothing per tick.
- setState should never be used anywhere near an animation.