skip to content

Why does a Flutter tip-summary StatefulWidget that copies widget.bill into a field in initState keep showing the old total after the parent passes a new bill, and how do you fix it?

level: seniorimportance: should knowfreq 40%

answer

  1. initState runs once per State
  2. the State survives the parent's rebuild
  3. State.widget already holds the new value
  4. derive in build, do not duplicate
  5. didUpdateWidget when a copy is intended

basics

~20 s

The State outlives each widget instance: initState ran once, so the copied field keeps the first bill while State.widget already holds the new one. Read widget.bill in build, or reconcile the copy in didUpdateWidget when a local copy is genuinely needed.

solid answer

~50 s

When the parent rebuilds with a new `TipSummary(bill: ...)`, the framework reuses the existing element and `State`: it assigns the new widget to `State.widget`, calls `didUpdateWidget`, and runs `build`. It does not call `initState` again, because that runs once per State. So `widget.bill` is correct on every build, but the `_bill` field copied in `initState` still holds the first value, and a `build` that reads `_bill` shows a stale total. The fix is to stop duplicating: compute the total from `widget.bill` inside `build`. Keep a local copy only when the prop is a seed the user can then edit, such as an initial tip percent, and in that case decide in `didUpdateWidget`, comparing `oldWidget` with `widget`, whether a new seed overwrites the user's change. A key that changes with the bill forces a fresh State, but it also resets everything else.

code

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

class TipSummary extends StatefulWidget {
  const TipSummary({super.key, required this.bill});

  final double bill;

  @override
  State<TipSummary> createState() => _TipSummaryState();
}

class _TipSummaryState extends State<TipSummary> {
  double _tipPercent = 15;
  // Bug: late final double _bill; assigned from widget.bill in initState,
  // then read in build, keeps the first bill forever.

  @override
  Widget build(BuildContext context) {
    final total = widget.bill * (1 + _tipPercent / 100); // always current
    return Column(
      children: [
        Slider(
          value: _tipPercent,
          max: 30,
          onChanged: (v) => setState(() => _tipPercent = v),
        ),
        Text('Total: ${total.toStringAsFixed(2)}'),
      ],
    );
  }
}

go deeper

for a junior

Recall that initState runs once and that widget.bill inside build always gives the newest value the parent passed.

for a middle

Explain how the element update swaps State.widget and calls didUpdateWidget without re-running initState, which leaves copied fields stale.

for a senior

Diagnose this in production code: spot fields mirroring props, choose between deriving, reconciling in didUpdateWidget and a resetting key, and cover it with a two-pump widget test.

for a principal

Establish conventions that make ownership explicit, such as initial-prefixed seed props, so teams do not reintroduce silent stale-state bugs across many screens.

## The bug The tip calculator screen passes the bill to a `TipSummary` stateful widget, which also owns a tip slider. The first version copies the bill in `initState` and reads the copy in `build`. The first bill shows correctly; when the user edits the bill on the parent screen, the parent rebuilds, but the summary keeps showing totals for the old amount. No error is raised, which makes this a classic production bug that survives review. ## Why it happens: the State outlives the widget A `StatefulWidget` instance is immutable configuration and is replaced on every parent build. The `State` belongs to the element and is kept as long as the new widget at that position has the same `runtimeType` and `key`. On such an update the framework, in `StatefulElement.update`: 1. remembers the old widget; 2. sets the State's `widget` to the new instance; 3. calls `didUpdateWidget(oldWidget)`; 4. rebuilds, which calls `build`. `initState` is not in that list; it ran once when the State was created. Anything computed from `widget` in `initState` is therefore a **snapshot of the first configuration**, while `widget.bill` itself is always current. ## Fix 1: derive, do not duplicate If the value only comes from the parent, do not store it. Read `widget.bill` in `build` and compute the total there. The State keeps only what it truly owns, here the slider's tip percent. This removes the bug class entirely, because there is no second copy to fall out of sync. ## Fix 2: a copy that is intended Sometimes a local copy is the point: the prop is an **initial** value the user then edits, for example `initialTipPercent` seeding a slider. Then the State owns the value, and you must decide what a changed seed means. The place for that decision is `didUpdateWidget`, which receives the previous widget: - If `oldWidget.initialTipPercent != widget.initialTipPercent`, either overwrite the user's edit or keep it, deliberately. - Naming the prop `initial...` tells readers that later changes are not followed automatically. The callback's other rules belong to the lifecycle topic; here it matters only as the hook that sees both configurations. ## Fix 3: throw the State away Giving `TipSummary` a key derived from the input, such as `ValueKey(bill)`, makes the old and new widgets fail the type-and-key match whenever the bill changes, so the framework discards the element and calls `createState` again. That does refresh the copy, but it also resets the slider and anything else the State held and repeats all setup work. It is a tool for intentional resets, not a routine fix; the key mechanics belong to the keys topic. ## Choosing the fix | Situation | Fix | |---|---| | Value only comes from the parent | Read `widget.x` in `build`; store nothing | | Prop seeds a value the user edits | Keep a copy; reconcile in `didUpdateWidget` | | A new input should reset all local state | A key derived from the input | ## Finding it in a real codebase - Search `initState` bodies for assignments from `widget.`; each one is either a seed, which should be named as such and reconciled, or a bug. - Treat any State field that mirrors a constructor argument as suspect. - Reproduce it in a widget test by pumping the parent twice with different bills and asserting the displayed total; the test fails on the stale version and passes after Fix 1. - Remember the same snapshot problem applies to anything created from `widget` in `initState`, such as a controller configured with a prop.

  • When is copying a constructor argument into State legitimate, and what must you do then?
    When the argument is a seed the user edits afterwards, such as an initial tip percent for a slider. The State then owns the value, so decide explicitly what a new seed means: override `didUpdateWidget`, compare `oldWidget.initialTipPercent` with `widget.initialTipPercent`, and either overwrite the user's edit or keep it. Naming the prop `initial...` documents that choice.
  • Could you force a fresh State instead of reconciling the copy?
    Yes. A key derived from the input, such as `ValueKey(bill)`, makes the framework discard the element whenever the bill changes and call `createState` again, re-running `initState`. That also resets the slider and every other field and repeats setup work, so it suits a deliberate reset rather than keeping one derived value current.

saying these in an interview costs you the question

  • initState runs again whenever the parent passes new arguments
  • State.widget always refers to the widget that first created the State
  • Calling setState in the parent refreshes the child's copied field
  • Every constructor argument should be copied into State fields in initState
  • Converting the child to a StatelessWidget is the only possible fix