Flutter Language Switch Only Works After Restart: Fix It
You wired up a language picker, MaterialApp.locale flips from en to fr, and half the screen changes. The other half, the AppBar title, a formatted date, the text inside a dialog you opened earlier, stays English until you kill and relaunch the app.
Almost nobody's bug is the MaterialApp rebuild. If any string changed, the rebuild fired. The stale parts are stale for three specific reasons: strings resolved once and stored, Intl.defaultLocale never updated so DateFormat/NumberFormat keep formatting in the old locale, and State objects that survive the rebuild holding onto old values. Here's a screen that reproduces all three, then the fixes.
The screen that breaks
class OrderSummary extends StatefulWidget {
const OrderSummary({super.key});
@override
State<OrderSummary> createState() => _OrderSummaryState();
}
class _OrderSummaryState extends State<OrderSummary> {
late String _title; // resolved once
late String _formattedDate; // formatted once, no locale passed
int _count = 0;
@override
void initState() {
super.initState();
_title = AppLocalizations.of(context)!.orderSummary; // BUG 1
_formattedDate = DateFormat.yMMMMd().format(DateTime.now()); // BUG 2
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_title)),
body: Column(children: [
Text(_formattedDate),
Text(AppLocalizations.of(context)!.itemCount(_count)),
]),
);
}
}
Switch to French and only the last Text updates. _title and _formattedDate were computed in initState, which runs exactly once for the lifetime of this State. A locale change rebuilds the widget; it does not re-run initState.
Cause 1: strings resolved outside build
AppLocalizations.of(context) is a Localizations.of lookup. It reads whatever locale data is in the tree at the moment you call it. Call it once in initState and you have captured a snapshot.
Two safe places to call it:
In build — simplest, and the right default. The lookup is an InheritedWidget read, it is cheap, and it registers the dependency so a locale change rebuilds you automatically.
In didChangeDependencies — if you genuinely want a field. Unlike initState, this runs again whenever an inherited dependency changes, including the locale.
class _OrderSummaryState extends State<OrderSummary> {
late AppLocalizations _l10n;
@override
void didChangeDependencies() {
super.didChangeDependencies();
_l10n = AppLocalizations.of(context)!; // re-runs on locale change
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text(_l10n.orderSummary)),
body: Text(_l10n.itemCount(_count)),
);
}
}
Note that AppLocalizations.of returns a nullable type by default. Set nullable-getter: false in l10n.yaml to drop the ! everywhere.
The same rule kills the static-field version of this bug:
// Never do this. Computed at class-load time, never again.
class Labels {
static final months = List.generate(
12, (i) => DateFormat.MMMM().format(DateTime(2026, i + 1)),
);
}
Anything static or top-level final that holds a translated or formatted string is frozen for the process lifetime. Make it a function that takes a BuildContext or a locale.
Cause 2: Intl.defaultLocale is still the old language
This is the one that produces the classic "text is French, the date is still July 14" screenshot. DateFormat and NumberFormat know nothing about your widget tree. When you construct them without a locale argument, they resolve the locale from Intl.defaultLocale, falling back to Intl.systemLocale. Changing MaterialApp.locale does not touch either.
You have two fixes and you should use both.
Pass the locale explicitly at every call site. This is the bulletproof version, because it reads the locale from the tree:
final locale = Localizations.localeOf(context).toString(); // 'fr' or 'fr_CA'
Text(DateFormat.yMMMMd(locale).format(order.placedAt));
Text(NumberFormat.currency(locale: locale, symbol: '€').format(order.total));
And keep Intl.defaultLocale in sync for the code you can't easily thread a context into (repositories, formatters in a service layer, log output):
import 'package:intl/intl.dart';
import 'package:intl/date_symbol_data_local.dart';
class LocaleController extends ChangeNotifier {
Locale _locale = const Locale('en');
Locale get locale => _locale;
Future<void> setLocale(Locale locale) async {
final name = Intl.canonicalizedLocale(locale.toString());
await initializeDateFormatting(name, null); // load symbols before use
Intl.defaultLocale = name;
_locale = locale;
notifyListeners();
}
}
initializeDateFormatting comes from package:intl/date_symbol_data_local.dart. Skip it and formatting a locale whose data was never loaded throws LocaleDataException. Call it before assigning Intl.defaultLocale so you never have a window where the default points at unloaded data. Current intl is 0.20.3, and this API has been stable for years.
If you also want to react to the user changing the language in iOS or Android settings, implement WidgetsBindingObserver.didChangeLocales and run the same setLocale path. More patterns in Flutter DateTime localization.
Cause 3: the State survived the rebuild
Even with the fixes above, some screens stay stale because Flutter is doing its job well. When MaterialApp rebuilds with a new locale, the element tree is reused where widget types and keys match, so your State objects are not recreated. Anything derived and cached in those State objects, a TextEditingController seeded with a translated hint, a list of dropdown labels built in initState, a formatted total, keeps the old value.
The blunt, reliable fix is to key the subtree on the locale so a language change forces fresh State:
MaterialApp(
locale: controller.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
builder: (context, child) => KeyedSubtree(
key: ValueKey(Localizations.localeOf(context).toString()),
child: child!,
),
)
This tears down and rebuilds everything below on each language change. It is a hammer: transient state like scroll position and half-filled forms resets, so use it on a settings screen or apply the key to the one stubborn subtree rather than the whole app. It is the correct tool when you're fixing a legacy screen you can't refactor today.
One more variant of this: a dialog whose text was captured before it opened.
// Stale: message resolved in the caller, before the dialog exists.
final msg = AppLocalizations.of(context)!.confirmDelete;
showDialog(context: context, builder: (_) => AlertDialog(content: Text(msg)));
// Fresh: resolved inside the builder, against the dialog's own context.
showDialog(
context: context,
builder: (context) => AlertDialog(
content: Text(AppLocalizations.of(context)!.confirmDelete),
),
);
The "setLocale does nothing at all" case
If nothing changes, the usual cause is placement: your ChangeNotifierProvider / InheritedWidget sits below MaterialApp (inside home:), so MaterialApp.locale is read from a widget that never rebuilds. The locale holder has to be an ancestor of MaterialApp, not a descendant. The full wiring is in Flutter change language at runtime.
A widget test that catches the regression
This is worth twenty minutes once. It fails the moment someone reintroduces an initState lookup.
testWidgets('locale switch updates strings and dates without restart',
(tester) async {
final controller = LocaleController();
await tester.pumpWidget(
ChangeNotifierProvider.value(
value: controller,
child: Consumer<LocaleController>(
builder: (context, c, _) => MaterialApp(
locale: c.locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const OrderSummary(),
),
),
),
);
expect(find.text('Order summary'), findsOneWidget);
expect(find.text('July 14, 2026'), findsOneWidget);
await controller.setLocale(const Locale('fr'));
await tester.pumpAndSettle();
expect(find.text('Récapitulatif de commande'), findsOneWidget);
expect(find.text('14 juillet 2026'), findsOneWidget);
expect(find.text('Order summary'), findsNothing);
});
The findsNothing assertion at the end is the important one. Without it, a screen that renders both the old and new string in different places still passes. Freeze the clock with a fixed DateTime in the widget under test so the date assertion isn't flaky. More on this in the localization testing guide.
Checklist
- No
AppLocalizations.of(context)ininitState, in field initialisers, or instatic/top-levelfinal. - Every
DateFormatandNumberFormateither takes a locale argument or runs afterIntl.defaultLocalewas updated. initializeDateFormatting(name, null)before the first format in a newly selected locale.- Dialog and bottom-sheet strings resolved inside the
builder. - One widget test asserting the old string is gone after the switch.
Keep the ARB side honest too
Runtime staleness is half the problem; the other half is the app_fr.arb that quietly lost a key or dropped a plural category, so the switch "works" but shows an English fallback. The FlutterLocalisation ARB editor edits your app_<locale>.arb files in a UI instead of raw JSON, tracks every locale side by side, and validates ICU plural syntax, flagging locales missing a category the language actually needs, like a dropped few or many for Polish, Russian or Arabic.
Try FlutterLocalisation free and stop shipping locale bugs you only find after a restart.