← Back to Blog

Flutter Riverpod Localization: Complete Guide to i18n with Riverpod 2.0

flutterriverpodlocalizationi18nstate-managementreactive

Flutter Riverpod Localization: Complete Guide to i18n with Riverpod 2.0

Riverpod has become the go-to state management solution for Flutter. But how do you handle localization with Riverpod's reactive architecture? This guide covers everything from basic setup to advanced patterns for building truly reactive multilingual apps.

Why Riverpod for Localization?

Riverpod offers unique advantages for localization:

  • Reactive Updates: Locale changes propagate automatically
  • No Context Required: Access translations anywhere with ref
  • Testability: Easy to mock translations in tests
  • Code Generation: Type-safe locale providers
  • Scoping: Override translations per widget tree

Basic Setup: Locale Provider

Step 1: Create the Locale Provider

// lib/providers/locale_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';

final localeProvider = StateNotifierProvider<LocaleNotifier, Locale>((ref) {
  return LocaleNotifier();
});

class LocaleNotifier extends StateNotifier<Locale> {
  LocaleNotifier() : super(const Locale('en')) {
    _loadSavedLocale();
  }

  static const _localeKey = 'app_locale';

  Future<void> _loadSavedLocale() async {
    final prefs = await SharedPreferences.getInstance();
    final savedLocale = prefs.getString(_localeKey);
    if (savedLocale != null) {
      state = Locale(savedLocale);
    }
  }

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

  void setLocaleFromCode(String languageCode) {
    setLocale(Locale(languageCode));
  }
}

Step 2: Create Localization Provider

// lib/providers/localization_provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'locale_provider.dart';

// This provider requires a BuildContext to work
final localizationsProvider = Provider<AppLocalizations?>((ref) {
  // This will be overridden in the widget tree
  return null;
});

// Alternative: Create a provider that holds translations by locale
final translationsProvider = FutureProvider.family<AppLocalizations, Locale>(
  (ref, locale) async {
    return await AppLocalizations.delegate.load(locale);
  },
);

Step 3: Configure MaterialApp

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'providers/locale_provider.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

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

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

    return MaterialApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const HomeScreen(),
    );
  }
}

Accessing Translations in Widgets

Using Context (Standard Approach)

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final l10n = AppLocalizations.of(context)!;

    return Scaffold(
      appBar: AppBar(title: Text(l10n.home_title)),
      body: Center(
        child: Text(l10n.welcome_message),
      ),
    );
  }
}

Without Context (Using Provider Override)

For accessing translations without context, use a provider override:

// lib/main.dart
class MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final locale = ref.watch(localeProvider);

    return MaterialApp(
      locale: locale,
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      builder: (context, child) {
        // Override the localizationsProvider with actual value
        return ProviderScope(
          overrides: [
            localizationsProvider.overrideWithValue(
              AppLocalizations.of(context),
            ),
          ],
          child: child!,
        );
      },
      home: const HomeScreen(),
    );
  }
}

// Now use in any provider
final greetingProvider = Provider<String>((ref) {
  final l10n = ref.watch(localizationsProvider);
  return l10n?.welcome_message ?? 'Welcome';
});

Advanced Patterns

Pattern 1: Locale-Aware Data Fetching

final productsProvider = FutureProvider<List<Product>>((ref) async {
  final locale = ref.watch(localeProvider);
  final api = ref.read(apiClientProvider);

  // Fetch products with localized content
  return api.getProducts(locale: locale.languageCode);
});

// Products automatically refetch when locale changes!

Pattern 2: Formatted Values Provider

final formattedPriceProvider = Provider.family<String, double>((ref, amount) {
  final locale = ref.watch(localeProvider);
  final format = NumberFormat.currency(
    locale: locale.toString(),
    symbol: _getCurrencySymbol(locale),
  );
  return format.format(amount);
});

// Usage in widget
Text(ref.watch(formattedPriceProvider(99.99)))  // "$99.99" or "99,99 €"

Pattern 3: Date Formatting Provider

final dateFormatProvider = Provider.family<String, DateTime>((ref, date) {
  final locale = ref.watch(localeProvider);
  final format = DateFormat.yMMMMd(locale.toString());
  return format.format(date);
});

final relativeDateProvider = Provider.family<String, DateTime>((ref, date) {
  final locale = ref.watch(localeProvider);
  final l10n = ref.watch(localizationsProvider);

  final now = DateTime.now();
  final difference = now.difference(date);

  if (difference.inDays == 0) {
    return l10n?.today ?? 'Today';
  } else if (difference.inDays == 1) {
    return l10n?.yesterday ?? 'Yesterday';
  } else if (difference.inDays < 7) {
    return l10n?.days_ago(difference.inDays) ?? '${difference.inDays} days ago';
  } else {
    return DateFormat.yMd(locale.toString()).format(date);
  }
});

Pattern 4: Validation Messages Provider

final validationMessagesProvider = Provider<ValidationMessages>((ref) {
  final l10n = ref.watch(localizationsProvider);
  return ValidationMessages(
    required: l10n?.validation_required ?? 'This field is required',
    email: l10n?.validation_email ?? 'Enter a valid email',
    minLength: (int min) =>
        l10n?.validation_min_length(min) ?? 'Minimum $min characters',
    maxLength: (int max) =>
        l10n?.validation_max_length(max) ?? 'Maximum $max characters',
  );
});

class ValidationMessages {
  final String required;
  final String email;
  final String Function(int) minLength;
  final String Function(int) maxLength;

  ValidationMessages({
    required this.required,
    required this.email,
    required this.minLength,
    required this.maxLength,
  });
}

// Usage in form validation
final validator = ref.read(validationMessagesProvider);
if (value.isEmpty) return validator.required;
if (value.length < 3) return validator.minLength(3);

Pattern 5: Async Translation Loading

final asyncLocalizationsProvider = FutureProvider<AppLocalizations>((ref) async {
  final locale = ref.watch(localeProvider);

  // Simulate loading translations from remote
  await Future.delayed(const Duration(milliseconds: 100));

  return await AppLocalizations.delegate.load(locale);
});

// Usage with loading state
class MyWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final asyncL10n = ref.watch(asyncLocalizationsProvider);

    return asyncL10n.when(
      data: (l10n) => Text(l10n.welcome_message),
      loading: () => const CircularProgressIndicator(),
      error: (err, stack) => Text('Error: $err'),
    );
  }
}

Language Switcher with Riverpod

Simple Dropdown Switcher

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

  static const supportedLocales = [
    Locale('en', 'US'),
    Locale('es', 'ES'),
    Locale('fr', 'FR'),
    Locale('de', 'DE'),
  ];

  String _getLanguageName(Locale locale) {
    switch (locale.languageCode) {
      case 'en': return 'English';
      case 'es': return 'Español';
      case 'fr': return 'Français';
      case 'de': return 'Deutsch';
      default: return locale.languageCode;
    }
  }

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

    return DropdownButton<Locale>(
      value: currentLocale,
      items: supportedLocales.map((locale) {
        return DropdownMenuItem(
          value: locale,
          child: Text(_getLanguageName(locale)),
        );
      }).toList(),
      onChanged: (locale) {
        if (locale != null) {
          ref.read(localeProvider.notifier).setLocale(locale);
        }
      },
    );
  }
}

Animated Language Switcher

class AnimatedLanguageSwitcher extends ConsumerStatefulWidget {
  const AnimatedLanguageSwitcher({super.key});

  @override
  ConsumerState<AnimatedLanguageSwitcher> createState() =>
      _AnimatedLanguageSwitcherState();
}

class _AnimatedLanguageSwitcherState
    extends ConsumerState<AnimatedLanguageSwitcher> {
  bool _isExpanded = false;

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

    return AnimatedContainer(
      duration: const Duration(milliseconds: 200),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          GestureDetector(
            onTap: () => setState(() => _isExpanded = !_isExpanded),
            child: Container(
              padding: const EdgeInsets.all(12),
              decoration: BoxDecoration(
                color: Theme.of(context).primaryColor,
                borderRadius: BorderRadius.circular(8),
              ),
              child: Row(
                mainAxisSize: MainAxisSize.min,
                children: [
                  Text(
                    _getFlagEmoji(currentLocale),
                    style: const TextStyle(fontSize: 24),
                  ),
                  const SizedBox(width: 8),
                  Icon(
                    _isExpanded
                        ? Icons.expand_less
                        : Icons.expand_more,
                    color: Colors.white,
                  ),
                ],
              ),
            ),
          ),
          if (_isExpanded) ...[
            const SizedBox(height: 8),
            ...AppLocalizations.supportedLocales
                .where((l) => l != currentLocale)
                .map((locale) => _buildLocaleOption(locale)),
          ],
        ],
      ),
    );
  }

  Widget _buildLocaleOption(Locale locale) {
    return GestureDetector(
      onTap: () {
        ref.read(localeProvider.notifier).setLocale(locale);
        setState(() => _isExpanded = false);
      },
      child: Container(
        padding: const EdgeInsets.all(8),
        margin: const EdgeInsets.only(bottom: 4),
        decoration: BoxDecoration(
          color: Colors.grey[200],
          borderRadius: BorderRadius.circular(8),
        ),
        child: Text(
          _getFlagEmoji(locale),
          style: const TextStyle(fontSize: 24),
        ),
      ),
    );
  }

  String _getFlagEmoji(Locale locale) {
    switch (locale.languageCode) {
      case 'en': return '🇺🇸';
      case 'es': return '🇪🇸';
      case 'fr': return '🇫🇷';
      case 'de': return '🇩🇪';
      default: return '🌐';
    }
  }
}

Testing Localization with Riverpod

Unit Testing Providers

void main() {
  group('LocaleProvider', () {
    test('initial locale is English', () {
      final container = ProviderContainer();
      addTearDown(container.dispose);

      expect(
        container.read(localeProvider),
        equals(const Locale('en')),
      );
    });

    test('setLocale updates state', () async {
      final container = ProviderContainer();
      addTearDown(container.dispose);

      container.read(localeProvider.notifier).setLocale(const Locale('es'));

      expect(
        container.read(localeProvider),
        equals(const Locale('es')),
      );
    });
  });

  group('FormattedPriceProvider', () {
    test('formats price for English locale', () {
      final container = ProviderContainer(
        overrides: [
          localeProvider.overrideWith((ref) => LocaleNotifier()..state = const Locale('en', 'US')),
        ],
      );
      addTearDown(container.dispose);

      final formatted = container.read(formattedPriceProvider(99.99));
      expect(formatted, contains('99.99'));
    });

    test('formats price for German locale', () {
      final container = ProviderContainer(
        overrides: [
          localeProvider.overrideWith((ref) => LocaleNotifier()..state = const Locale('de', 'DE')),
        ],
      );
      addTearDown(container.dispose);

      final formatted = container.read(formattedPriceProvider(99.99));
      expect(formatted, contains('99,99'));
    });
  });
}

Widget Testing

void main() {
  testWidgets('displays localized welcome message', (tester) async {
    await tester.pumpWidget(
      ProviderScope(
        overrides: [
          localizationsProvider.overrideWithValue(
            MockAppLocalizations(
              welcomeMessage: 'Bienvenue!',
            ),
          ),
        ],
        child: const MaterialApp(home: HomeScreen()),
      ),
    );

    expect(find.text('Bienvenue!'), findsOneWidget);
  });

  testWidgets('language switcher changes locale', (tester) async {
    await tester.pumpWidget(
      const ProviderScope(
        child: MaterialApp(
          localizationsDelegates: AppLocalizations.localizationsDelegates,
          supportedLocales: AppLocalizations.supportedLocales,
          home: LanguageSwitcher(),
        ),
      ),
    );

    // Open dropdown
    await tester.tap(find.byType(DropdownButton<Locale>));
    await tester.pumpAndSettle();

    // Select Spanish
    await tester.tap(find.text('Español').last);
    await tester.pumpAndSettle();

    // Verify locale changed (would need to check provider state)
  });
}

Best Practices Summary

  1. Use StateNotifier for locale management - Handles persistence and state updates cleanly

  2. Watch locale in dependent providers - Ensures automatic refetch when language changes

  3. Override localizationsProvider in widget tree - Enables context-free translation access

  4. Create specialized formatting providers - Numbers, dates, and currencies locale-aware

  5. Test with ProviderContainer overrides - Easy mocking for unit tests

  6. Use family providers for parameterized translations - Clean API for formatted strings

Conclusion

Riverpod's reactive architecture makes localization elegant and maintainable. Key takeaways:

  • Locale changes automatically propagate through the widget tree
  • Providers can access translations without BuildContext
  • Testing is straightforward with provider overrides
  • Formatting providers keep UI code clean

Combine Riverpod with FlutterLocalisation for a complete solution that handles both state management and translation management seamlessly.


Managing translations for your Riverpod app? Try FlutterLocalisation free with Git sync and team collaboration.