How to Read Localized Strings Outside the Widgets using Riverpod

Source code on GitHub

If you want to deploy your Flutter app to users that speak another language, you'll need to localize it. And the Flutter documentation already has a detailed internationalization guide covering this topic in depth.

To make life easier, I have previously written a step-by-step guide explaining how to generate the AppLocalizations class and access it inside our widgets using a BuildContext extension:

But if we have some business logic that lives outside our widgets, how can we read the localized strings?

This article shows how I tackle localization using the Riverpod package in my own apps.

This article is based on the official flutter_localization package, which supports over 78 locales. The Flutter Gems website lists many other localization packages here.

App Localization Requirements

Before we dive into the code, let's figure out what we want to do:

  • access localized strings outside our widgets (without using BuildContext)
  • ensure that any listeners (providers and widgets) rebuild when the locale changes

To satisfy these requirements, we need to combine two things:

  • a Provider<AppLocalizations> that we can use anywhere in the app
  • a LocaleObserver class that we can use to track locale changes

Here's how to implement them. 👇

1. Creating the AppLocalizations Provider

To get the right AppLocalizations object for the current locale, we can use the lookupAppLocalizations method, which is generated when we run the flutter gen-l10n command.

So the first step is to create a provider that uses it:

import 'package:flutter_riverpod/flutter_riverpod.dart'; // lookupAppLocalizations is defined here 👇 import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'dart:ui' as ui; /// provider used to access the AppLocalizations object for the current locale final appLocalizationsProvider = Provider<AppLocalizations>((ref) { return lookupAppLocalizations(ui.window.locale); });

But this is not enough because our provider won't rebuild and notify its listeners when the locale changes.

2. Adding a Locale Observer

To keep track of locale changes, we can create a subclass of WidgetsBindingObserver and override the didChangeLocales method:

/// observer used to notify the caller when the locale changes class _LocaleObserver extends WidgetsBindingObserver { _LocaleObserver(this._didChangeLocales); final void Function(List<Locale>? locales) _didChangeLocales; @override void didChangeLocales(List<Locale>? locales) { _didChangeLocales(locales); } }

And now that we have this, we can update our provider code:

/// provider used to access the AppLocalizations object for the current locale final appLocalizationsProvider = Provider<AppLocalizations>((ref) { // 1. initialize from the initial locale ref.state = lookupAppLocalizations(ui.window.locale); // 2. create an observer to update the state final observer = _LocaleObserver((locales) { ref.state = lookupAppLocalizations(ui.window.locale); }); // 3. register the observer and dispose it when no longer needed final binding = WidgetsBinding.instance; binding.addObserver(observer); ref.onDispose(() => binding.removeObserver(observer)); // 4. return the state return ref.state; });

This ensures that any other providers or widgets that depend on appLocalizationsProvider will rebuild when the locale changes.

Next, let's see how to use our provider.

3. Using the AppLocalizations Provider

The main idea is that we can read the appLocalizationsProvider using a ref object:

// get the AppLocalizations object (read once) final loc = ref.read(appLocalizationsProvider); // read a property defined in the *.arb file final error = loc.addToCartFailed;

For example, consider this CartService class that is used to update a shopping cart:

class CartService { CartService(this.ref); // declare ref as a property final Ref ref; Future<void> addItem(Item item) async { try { // fetch the cart final cart = ref.read(cartRepositoryProvider).fetchCart(); // return a copy with the updated data final updated = cart.addItem(item); // set the cart with the updated data await ref.read(cartRepositoryProvider).setCart(updated); } catch (e) { // get the localized error message final errorMessage = ref.read(appLocalizationsProvider).addToCartFailed; // throw it as an exception throw Exception(errorMessage); } } } // the corresponding provider final cartServiceProvider = Provider<CartService>((ref) { return CartService(ref); });

This CartService class takes a Ref argument, which is used to call ref.read(appLocalizationsProvider) inside the catch block - thus making AppLocalizations an implicit dependency.

In this case, we should read (not watch) the appLocalizationsProvider since we need to retrieve the error message once when the addItem method is called.

But if we want to make the AppLocalizations dependency more explicit, we can do this instead:

class CartService { CartService({required this.cartRepository, required this.loc}); // declare dependencies as explicit properties final CartRepository cartRepository; final AppLocalizations loc; Future<void> addItem(Item item) async { try { // fetch the cart final cart = cartRepository.fetchCart(); // return a copy with the updated data final updated = cart.addItem(item); // set the cart with the updated data await cartRepository.setCart(updated); } catch (e) { // get the localized error message final errorMessage = loc.addToCartFailed; // throw it as an exception throw Exception(errorMessage); } } } // the corresponding provider final cartServiceProvider = Provider<CartService>((ref) { return CartService( // pass the dependencies explicitly (using watch) cartRepository: ref.watch(cartRepositoryProvider), loc: ref.watch(appLocalizationsProvider), ); });

In this case, we should watch (not read) the appLocalizationsProvider in order to rebuild the CartService returned by the cartServiceProvider when the locale changes.

Either way, we can now access the AppLocalizations anywhere inside our providers without using the BuildContext.

Bonus: Using AppLocalizations for Error Handling

If we use freezed, we can define our own domain-specific exception class:

@freezed class AppException with _$AppException { const factory AppException.permissionDenied() = PermissionDenied; const factory AppException.paymentFailed() = PaymentFailed; // add other error types here }

And if we want to map each error to a localized error message (that we will show in the UI), we can create this simple extension:

extension AppExceptionMessage on AppException { String message(AppLocalizations loc) { return when( permissionDenied: () => loc.permissionDeniedMessage, paymentFailed: () => loc.paymentFailedMessage, // and so on... ); } }

Then, we can set an error state with a localized string inside a StateNotifier<AsyncValue> subclass that our widgets can listen to:

// inside a StateNotifier subclass final exception = AppException.permissionDenied(); final loc = ref.read(appLocalizationsProvider); state = AsyncError(exception.message(loc));

Once again, no BuildContext is needed to read localized strings outside our widgets.

Conclusion

We've now figured out how to access localized strings in our app:

  • inside the widgets → get the AppLocalizations object from the BuildContext (as explained in the previous article)
  • outside the widgets → get the AppLocalizations object from the appLocalizationsProvider (explained here)

Either way, our widgets and providers will rebuild if the locale changes.

For instructions about how to test the locale change on iOS and Android, see this example project on GitHub:

And feel free to reuse this code in your apps if you want:

import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'dart:ui' as ui; /// provider used to access the AppLocalizations object for the current locale final appLocalizationsProvider = Provider<AppLocalizations>((ref) { // 1. initialize from the initial locale ref.state = lookupAppLocalizations(ui.window.locale); // 2. create an observer to update the state final observer = _LocaleObserver((locales) { ref.state = lookupAppLocalizations(ui.window.locale); }); // 3. register the observer and dispose it when no longer needed final binding = WidgetsBinding.instance; binding.addObserver(observer); ref.onDispose(() => binding.removeObserver(observer)); // 4. return the state return ref.state; }); /// observer used to notify the caller when the locale changes class _LocaleObserver extends WidgetsBindingObserver { _LocaleObserver(this._didChangeLocales); final void Function(List<Locale>? locales) _didChangeLocales; @override void didChangeLocales(List<Locale>? locales) { _didChangeLocales(locales); } }

Happy coding!

Want More?

Invest in yourself with my high-quality Flutter courses.

Flutter In Production

Flutter In Production

Learn about flavors, environments, error monitoring, analytics, release management, CI/CD, and finally ship your Flutter apps to the stores. 🚀

Flutter Foundations Course

Flutter Foundations Course

Learn about State Management, App Architecture, Navigation, Testing, and much more by building a Flutter eCommerce app on iOS, Android, and web.

Flutter & Firebase Masterclass

Flutter & Firebase Masterclass

Learn about Firebase Auth, Cloud Firestore, Cloud Functions, Stripe payments, and much more by building a full-stack eCommerce app with Flutter & Firebase.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Last updated to Dart 2.15.

Flutter Animations Masterclass

Flutter Animations Masterclass

Master Flutter animations and build a completely custom habit tracking application.