In Flutter, what triggers the 'multiple heroes that share the same tag within a subtree' error, and how do tag design or HeroMode fix it?
answer
- one tag per route
- raised when a transition collects heroes
- same product in two sections
- prefix the tag with its section
- HeroMode(enabled: false) skips a subtree
basics
~20 sTwo Heroes with equal tags on one route trigger it in debug builds, when a navigation starts and the HeroController collects that route's heroes. Make tags unique per slot, such as a section prefix, or wrap one copy in HeroMode(enabled: false).
solid answer
~50 sWhen a `PageRoute` transition starts, the `HeroController` walks each route's subtree and builds a map from tag to hero. If it meets a second `Hero` with a tag already in the map, a debug-mode `FlutterError` reports *There are multiple heroes that share the same tag within a subtree*, naming the tag. It happens on navigation, not on first render, so the page looks fine until the user taps something. In a furniture store it typically comes from one product appearing twice on a page - in *Recommended* and in the main grid. Fixes: make the tag describe the slot, not only the product (`'grid-product-42'`, `'recommended-product-42'`), and give the detail page the tag of the tile that was tapped; or wrap the section that should not fly in `HeroMode(enabled: false)`, whose subtree the controller skips entirely.
code
dart · 31 linesimport 'package:flutter/material.dart';
String productHeroTag(String section, String productId) =>
'$section-product-$productId';
class RecommendedStrip extends StatelessWidget {
const RecommendedStrip({super.key, required this.productIds});
final List<String> productIds;
@override
Widget build(BuildContext context) {
// This strip never flies, so its heroes are excluded from collection
// and cannot clash with the grid's tags.
return HeroMode(
enabled: false,
child: SizedBox(
height: 96,
child: ListView(
scrollDirection: Axis.horizontal,
children: <Widget>[
for (final String id in productIds)
Hero(
tag: productHeroTag('recommended', id),
child: const SizedBox(width: 96, height: 96),
),
],
),
),
);
}
}go deeper
Recall that every Hero on one route needs its own tag, and that the error names the tag that was duplicated.
Explain that the check runs when a transition collects heroes, why the page renders fine until then, and what HeroMode does to collection.
Design slot-based tags, pass the tapped tile's tag to the destination, and apply HeroMode narrowly so it does not remove flights the product wants.
Set a tag convention for shared components that appear in several sections, so no two teams can produce colliding tags on one screen.
## Where the error comes from A **hero flight** starts when a `PageRoute` is pushed or popped. At that moment the app's `HeroController` visits every element in each route's subtree and collects `Hero` widgets into a map keyed by **tag**. Before inserting a hero, a debug-mode check looks for a tag that is already present. If it finds one, it throws a `FlutterError` with the summary: > There are multiple heroes that share the same tag within a subtree. The description adds that *within each subtree for which heroes are to be animated (i.e. a PageRoute subtree), each Hero must have a unique non-null tag*, prints the offending tag, and shows the subtree of one of the heroes. Two consequences follow from *where* the check lives: - It fires **when navigation starts**, not when the page first renders. A page with duplicate tags displays normally and fails on the first push or pop. - It is a **debug** assertion. Release builds skip the check, and one hero silently overwrites the other in the map, so the flight may leave from the wrong tile. ## How duplicates arise in practice 1. **The same item in two sections.** A furniture store home page shows an armchair in *Recommended* and again in *All chairs*. Both tiles tag with `'product-${product.id}'`. 2. **A list plus a pinned header.** The selected product appears in a sticky summary and in the list below. 3. **A carousel with duplicated pages** for infinite scrolling, where neighbouring items repeat. 4. **Kept-alive tab pages.** Several tabs in one route each contain the same product; tabs kept alive offstage are still in the route's subtree. ## Fix 1: tags that name the slot A tag should identify *this* on-screen instance, not only the data behind it: ```dart String productHeroTag(String section, String productId) => '$section-product-$productId'; // Home page Hero(tag: productHeroTag('recommended', p.id), child: thumb) Hero(tag: productHeroTag('grid', p.id), child: thumb) // Push the detail page with the tag of the tile that was tapped ProductDetailPage(product: p, heroTag: productHeroTag('grid', p.id)) ``` The detail page receives the tag rather than recomputing it, so the flight always leaves from the tile the user touched. ## Fix 2: HeroMode **`HeroMode`** is a widget with one flag, `enabled` (default `true`). When `enabled` is `false`, the controller's visitor **stops descending** into that subtree, so none of the heroes inside it are collected - they neither fly nor count as duplicates. | Situation | Better fix | |---|---| | Both copies should be able to fly | unique, slot-based tags | | One section should never fly (a thumbnail strip, a mini-cart) | `HeroMode(enabled: false)` around it | | Flights should pause temporarily, such as during edit mode | toggle `HeroMode.enabled` | ## Related loud errors - A `Hero` placed inside another `Hero` fails an assertion at build time: *A Hero widget cannot be the descendant of another Hero widget.* - Two Navigators sharing one `HeroController` report that *a HeroController can not be shared by multiple Navigators* - a different problem, solved with a scope per Navigator. ## What interviewers listen for - Knowing the error appears on navigation, not on first render. - Designing tags around the on-screen slot, and passing the tapped tile's tag to the destination. - Using `HeroMode` deliberately rather than as a blanket silencer, since it also removes flights you may want. ## Testing for it Because the check runs only during a transition, a widget test that pumps the home page proves nothing. The test has to tap a tile, pump a frame so the push starts, and let any `FlutterError` surface through the test framework. One such test per screen that mixes sections of the same items - home, search results, wish list - catches collisions before a user does.
- Why does the duplicate-tag page render fine and only fail when the user navigates?The uniqueness check is part of collecting heroes for a transition. Building the page never runs it; only a push or pop does, when the `HeroController` walks each route's subtree and finds a tag already in its map. Tests that only pump the page will not catch it - a test has to navigate.
- What happens to duplicate tags in a release build?The check is an assertion, so release builds skip it. The map simply keeps the last hero collected for that tag, and the flight may start from a different tile than the one the user tapped. That is why duplicates must be fixed rather than tolerated because production shows no error.
saying these in an interview costs you the question
- Duplicate hero tags throw as soon as the page first renders.
- Duplicate tags are fine as long as the two heroes look identical.
- HeroMode(enabled: false) still lets its heroes fly but hides the error.
- Release builds also throw the duplicate-tag error.
- Using the product id alone as the tag is always unique enough.