← Articles

Dynamic language switching in Flutter at runtime

By Mark · 14 December 20250 views

Changing language at runtime — while the app is open, without restart — requires more than just updating the MaterialApp.locale. You need to update the locale, persist the choice, and rebuild the app. Here is how to do it cleanly.

Setup

If you haven't done basic i18n setup yet, start there. This article assumes you have ARB files and generated AppLocalizations.

dependencies:
  flutter_localizations:
    sdk: flutter
  intl: ^0.19.0
  shared_preferences: ^2.2.3

Locale provider with Riverpod

// Read persisted locale on app start
@riverpod
Future<Locale?> persistedLocale(PersistedLocaleRef ref) async {
  final prefs = await SharedPreferences.getInstance();
  final code = prefs.getString('locale');
  return code != null ? Locale(code) : null;
}

// Current locale: starts null (uses system locale), can be overridden
@riverpod
class LocaleNotifier extends _$LocaleNotifier {
  @override
  Locale? build() => null; // null = use system locale

  Future<void> setLocale(Locale locale) async {
    state = locale;
    // Persist
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('locale', locale.languageCode);
  }

  Future<void> clearLocale() async {
    state = null;
    final prefs = await SharedPreferences.getInstance();
    await prefs.remove('locale');
  }
}

Loading persisted locale at startup

class App extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Load persisted locale on startup
    final persistedLocale = ref.watch(persistedLocaleProvider);

    return persistedLocale.when(
      data: (locale) {
        // Initialize the notifier with the persisted locale
        if (locale != null) {
          WidgetsBinding.instance.addPostFrameCallback((_) {
            ref.read(localeNotifierProvider.notifier).setLocale(locale);
          });
        }
        return _AppWidget();
      },
      loading: () => const MaterialApp(home: Scaffold(body: Center(child: CircularProgressIndicator()))),
      error: (_, __) => _AppWidget(),
    );
  }
}

class _AppWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final locale = ref.watch(localeNotifierProvider);

    return MaterialApp(
      locale: locale, // null = use system locale
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      // ...
    );
  }
}

Language selector widget

class LanguageSelectorScreen extends ConsumerWidget {
  const LanguageSelectorScreen({super.key});

  static const _supportedLocales = [
    (locale: Locale('en'), name: 'English', nativeName: 'English'),
    (locale: Locale('fr'), name: 'French', nativeName: 'Français'),
    (locale: Locale('de'), name: 'German', nativeName: 'Deutsch'),
    (locale: Locale('ar'), name: 'Arabic', nativeName: 'العربية'),
    (locale: Locale('ja'), name: 'Japanese', nativeName: '日本語'),
  ];

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final currentLocale = ref.watch(localeNotifierProvider);

    return Scaffold(
      appBar: AppBar(title: const Text('Language')),
      body: Column(
        children: [
          // System default option
          ListTile(
            title: const Text('System default'),
            trailing: currentLocale == null
                ? const Icon(Icons.check, color: Colors.blue)
                : null,
            onTap: () => ref.read(localeNotifierProvider.notifier).clearLocale(),
          ),
          const Divider(),
          ..._supportedLocales.map((lang) => ListTile(
            title: Text(lang.nativeName),
            subtitle: Text(lang.name),
            trailing: currentLocale?.languageCode == lang.locale.languageCode
                ? const Icon(Icons.check, color: Colors.blue)
                : null,
            onTap: () => ref.read(localeNotifierProvider.notifier).setLocale(lang.locale),
          )),
        ],
      ),
    );
  }
}

RTL support

When switching to Arabic or Hebrew (RTL languages), Flutter automatically mirrors the layout if you've used EdgeInsetsDirectional and TextDirection-aware widgets.

Verify RTL by adding a quick test language in your debug builds:

// Debug build: force RTL
if (kDebugMode) {
  MaterialApp(
    locale: const Locale('ar'),
    // ...
  );
}

Date and number formatting

Date/number format changes when the locale changes — make sure to use locale-aware formatting:

final locale = Localizations.localeOf(context).toString();

// These automatically use the current locale:
final dateStr = DateFormat.yMMMd(locale).format(date);
final priceStr = NumberFormat.currency(locale: locale, symbol: '\$').format(price);

Testing locale switching

testWidgets('switches to French correctly', (tester) async {
  await tester.pumpWidget(
    ProviderScope(
      child: const MaterialApp(
        locale: Locale('fr'),
        localizationsDelegates: AppLocalizations.localizationsDelegates,
        supportedLocales: AppLocalizations.supportedLocales,
        home: HomeScreen(),
      ),
    ),
  );

  // Check French strings are shown
  expect(find.text('Bonjour'), findsOneWidget);
});

Common pitfalls

Not persisting the locale. If you set the locale via state but don't persist it, the user has to reselect their language every time they open the app.

Using Localizations.localeOf(context) in providers. Providers don't have a BuildContext. Use the locale from your localeNotifierProvider directly rather than from Localizations.localeOf.

Forgetting to update intl formatting. Switching the app locale changes AppLocalizations strings, but DateFormat and NumberFormat use whatever locale string you pass. Always read locale from context and pass it to format functions.

Sign in to like, dislike, or report.

Dynamic language switching in Flutter at runtime — ANN Tech