Fix Flutter Locale Change Restarting Your go_router App
You tap a language button, the UI flips to French โ and your app snaps back to the home screen. The counter you incremented is gone, the three screens you pushed onto the stack vanished, scroll positions reset. If you're on MaterialApp.router with go_router, this is one of the most reported pain points in the ecosystem (flutter/flutter #138396, #141315).
The good news: flutter locale change restarts app is not a framework limitation you have to live with. It's almost always caused by one line โ rebuilding the GoRouter instance. Here's the exact fix.
Why the whole app restarts on a locale change
When you switch languages at runtime, you call setState (or notify a provider) to hand MaterialApp a new locale. That's fine. The problem is where your GoRouter lives.
// ๐จ BROKEN: a brand-new GoRouter is created on every rebuild
class _MyAppState extends State<MyApp> {
Locale _locale = const Locale('en');
void setLocale(Locale locale) => setState(() => _locale = locale);
@override
Widget build(BuildContext context) {
final router = GoRouter( // rebuilt on every setState!
routes: appRoutes,
);
return MaterialApp.router(
locale: _locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: router,
);
}
}
Every setState re-runs build(). A new GoRouter means a new RouterDelegate and a new RouteInformationParser. MaterialApp.router sees a different routerConfig object, tears down the old Router, and rebuilds from your initial route. That is the go_router locale change reset everyone runs into โ the navigation history and every State object below it are thrown away.
This is also why people report flutter setLocale not updating instantly: they wire setLocale at a layer that either doesn't sit above Localizations, or that nukes the router so aggressively the change looks like a cold restart rather than an in-place refresh.
The fix: one stable router + locale hoisted above it
Two rules solve this completely:
- Create the
GoRouterexactly once โ never insidebuild(). - Hoist the locale into a
ValueNotifieraboveMaterialApp.router, and pass the same router instance on every rebuild.
// A single, app-lifetime router instance.
final _router = GoRouter(
routes: appRoutes,
);
// Locale lives above the Router in a Listenable.
final localeNotifier = ValueNotifier<Locale>(const Locale('en'));
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return ValueListenableBuilder<Locale>(
valueListenable: localeNotifier,
builder: (context, locale, _) {
return MaterialApp.router(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: _router, // SAME instance on every rebuild
);
},
);
}
}
Switching language anywhere in the app is now a one-liner:
localeNotifier.value = const Locale('fr'); // instant, no restart
Why this preserves state
ValueListenableBuilder rebuilds only the MaterialApp.router subtree, and it passes the same _router object each time. Because routerConfig is identical by reference, MaterialApp does not recreate the underlying Router, RouterDelegate, or Navigator. The only thing that actually changes is Localizations, which re-resolves your ARB strings. Result: text re-renders, the route stack survives, and every StatefulWidget below keeps its State.
Using an InheritedNotifier instead of a global gives you the same guarantee with proper scoping โ the notifier sits above the Router, so notifying it rebuilds text without disturbing the navigation config.
Prove it: the counter and the stack survive
Put a counter on a screen you've navigated to, then switch languages:
class CounterScreen extends StatefulWidget {
const CounterScreen({super.key});
@override
State<CounterScreen> createState() => _CounterScreenState();
}
class _CounterScreenState extends State<CounterScreen> {
int _count = 0;
@override
Widget build(BuildContext context) {
final t = AppLocalizations.of(context)!;
return Scaffold(
appBar: AppBar(title: Text(t.counterTitle)),
body: Center(child: Text('${t.value}: $_count')),
floatingActionButton: Row(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
onPressed: () => setState(() => _count++),
child: const Icon(Icons.add),
),
const SizedBox(width: 12),
// Flip the language โ no navigation, no rebuild of the router.
FloatingActionButton.extended(
onPressed: () => localeNotifier.value =
localeNotifier.value.languageCode == 'en'
? const Locale('fr')
: const Locale('en'),
label: Text(t.switchLanguage),
),
],
),
);
}
}
Before the fix: increment to 5, push two detail screens, switch language โ you're back on the initial route with the counter at 0.
After the fix: increment to 5, push two detail screens, switch language โ the labels translate, the counter still reads 5, and both pushed screens are still on the stack. That's flutter change language without restart working as intended.
The Riverpod version
Same principle, expressed with providers. The critical detail is that the router provider builds GoRouter once โ Riverpod caches it โ while the locale is a separate, cheap piece of state.
final localeProvider = StateProvider<Locale>((ref) => const Locale('en'));
// Built once and cached. Do NOT depend on localeProvider here.
final routerProvider = Provider<GoRouter>((ref) {
return GoRouter(routes: appRoutes);
});
class MyApp extends ConsumerWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final locale = ref.watch(localeProvider);
return MaterialApp.router(
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
routerConfig: ref.watch(routerProvider),
);
}
}
Change the language with ref.read(localeProvider.notifier).state = const Locale('fr');. If you ref.watch(localeProvider) inside routerProvider, you'll rebuild the router on every switch and reintroduce the exact reset you're trying to kill โ so keep that dependency out.
Also following the system language? Use didChangeLocales
If you want the app to react when the user changes their device language, don't let the framework's rebuild cascade restart you. Listen with WidgetsBindingObserver.didChangeLocales(List<Locale>? locales) and route the change through the same notifier โ a targeted flutter didChangeLocales rebuild that only touches text:
class _MyAppState extends State<MyApp> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeLocales(List<Locale>? locales) {
if (locales != null && locales.isNotEmpty) {
localeNotifier.value = locales.first; // router untouched
}
}
}
Because the update flows into localeNotifier (not a fresh router), only Localizations rebuilds. Note: iOS may still terminate an app on a system language change, so test the in-app switch path โ that's where the win is.
Keep your translations honest
The fix above keeps state alive, but instant switching also exposes missing or stale strings the moment a user flips languages. A clean ARB workflow matters more once translations are visible in one tap. FlutterLocalisation's ARB editor keeps every locale's keys in sync so a language switch never surfaces an empty label, and pricing scales from solo apps to full teams.
Try FlutterLocalisation free and ship runtime language switching that never loses your users' place.