← Back to Blog

AppLocalizations Without a BuildContext in Flutter

flutterlocalizationl10nblocriverpod

AppLocalizations Without a BuildContext in Flutter

You moved your business logic out of widgets — into BLoCs, Riverpod notifiers, repositories, and service classes. Clean architecture, testable, no Text widgets in sight. Then a requirement lands: a repository needs to build a localized error message, or a background isolate has to compose a notification title. Suddenly you need AppLocalizations, and there's no BuildContext for AppLocalizations.of(context).

The usual answer floating around Stack Overflow is to stash a GlobalKey<NavigatorState> and reach for navigatorKey.currentContext. Don't. That context is null before the first frame, null in a background isolate, and stale during navigation transitions — exactly the moments your business logic runs. There's a supported, synchronous way to get translations for flutter localization outside the widget tree, and it's already in your generated code.

The generated factory you already have: lookupAppLocalizations

When you run flutter gen-l10n, Flutter writes app_localizations.dart. Alongside the abstract AppLocalizations class, it emits a public top-level function:

// GENERATED into app_localizations.dart — do not edit
AppLocalizations lookupAppLocalizations(Locale locale) {
  switch (locale.languageCode) {
    case 'en': return AppLocalizationsEn();
    case 'es': return AppLocalizationsEs();
    case 'ar': return AppLocalizationsAr();
  }

  throw FlutterError(
    'AppLocalizations.delegate failed to load unsupported locale "$locale". '
    'This is likely an issue with the localizations generation tool. ...',
  );
}

This is the same factory AppLocalizations.delegate.load() calls internally — load just wraps it in a SynchronousFuture. It maps a Locale straight to the locale-specific implementation (AppLocalizationsEn, AppLocalizationsAr, …). No context, no widget tree, no navigator. Given a Locale, you get a fully-formed AppLocalizations instance you can call anywhere.

That's the whole trick for AppLocalizations without context: you don't need the context — you need the locale. The context was only ever a way to look up the current locale from Localizations.

Heads up: the import path changed

Since the first stable release of 2025, Flutter deprecated the synthetic package:flutter_gen package. Generated sources now land in your project. Set this in l10n.yaml:

arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
synthetic-package: false   # now the default; files generate into source
nullable-getter: false     # AppLocalizations.of(context) returns non-null

So the import is now:

import 'package:my_app/l10n/app_localizations.dart';

not the old package:flutter_gen/gen_l10n/.... If a tutorial still shows flutter_gen, it predates this change.

The missing piece: something that holds the current locale

lookupAppLocalizations needs a Locale. Your services shouldn't guess it — they should read the app's current locale, and get fresh translations automatically when the user switches languages. So keep the locale in one place your business logic can reach.

Riverpod: a locale provider + a derived AppLocalizations provider

import 'package:flutter/widgets.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:my_app/l10n/app_localizations.dart';

// Single source of truth for the active locale.
final localeProvider = StateProvider<Locale>((ref) => const Locale('en'));

// Derived: rebuilds every time the locale changes.
final l10nProvider = Provider<AppLocalizations>((ref) {
  final locale = ref.watch(localeProvider);
  return lookupAppLocalizations(locale);
});

Wire the locale into MaterialApp so the UI and your logic stay in sync:

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      locale: ref.watch(localeProvider),
      localizationsDelegates: AppLocalizations.localizationsDelegates,
      supportedLocales: AppLocalizations.supportedLocales,
      home: const HomePage(),
    );
  }
}

Now any provider, notifier, or service reads translations without a widget in sight:

class CheckoutService {
  CheckoutService(this.ref);
  final Ref ref;

  String cannotShipMessage() {
    final l10n = ref.read(l10nProvider); // no BuildContext
    return l10n.cannotShipToRegion;
  }
}

When the user switches languages, you update localeProvider, l10nProvider recomputes, and the next ref.read returns the correct language. That's flutter l10n in a service class with zero context plumbing.

BLoC / Cubit: inject a locale holder

BLoC favours explicit dependencies over ambient lookups, so inject a tiny locale holder rather than a global. This is the clean way to translate a string in a BLoC:

class LocaleHolder {
  Locale current = const Locale('en');
  AppLocalizations get l10n => lookupAppLocalizations(current);
}

class OrderCubit extends Cubit<OrderState> {
  OrderCubit(this._locale) : super(OrderInitial());
  final LocaleHolder _locale;

  void submit() {
    if (!_isValid) {
      // Translated string, emitted from business logic, no context.
      emit(OrderError(_locale.l10n.orderInvalidAddress));
    }
  }
}

Update LocaleHolder.current from the same place you set MaterialApp.locale (e.g. a LocaleBloc). Because lookupAppLocalizations is a pure lookup, l10n is always current — no caching bugs.

Background isolates and pre-runApp code

This pattern shines where there is provably no context: a workmanager callback, a flutter_local_notifications handler, or startup code before the first frame. Persist the user's locale (e.g. shared_preferences), then:

@pragma('vm:entry-point')
void notificationCallback() async {
  WidgetsFlutterBinding.ensureInitialized();
  final code = await loadSavedLanguageCode(); // 'ar', 'es', ...
  final l10n = lookupAppLocalizations(Locale(code));

  showNotification(
    title: l10n.reminderTitle,
    body: l10n.reminderBody,
  );
}

If you also need Material/Cupertino strings in the isolate, await AppLocalizations.delegate.load(locale) works too — it returns the same instance lookupAppLocalizations produces, just as a Future.

One caveat: only your ARB strings

lookupAppLocalizations covers strings from your ARB files. It does not load GlobalMaterialLocalizations (framework strings like "Cancel" or date formats) — those still need the delegates mounted in the widget tree. For business logic that's rarely a problem, since you're formatting your own messages. For dates and numbers, pass the locale explicitly to intl's DateFormat and NumberFormat.

Keep the ARB files themselves correct

All of this assumes your ARB files are complete and valid — lookupAppLocalizations will happily return AppLocalizationsAr(), but if app_ar.arb is missing a key or drops a plural category Arabic actually needs, users see a broken string no amount of clever context-free access will fix. That's the layer FlutterLocalisation handles: a dedicated ARB editor for app_<locale>.arb files so you edit translations in a real UI instead of raw JSON, translation management across every locale, and ICU plural-syntax validation that flags a locale missing a plural category the language requires (a dropped few/many for Arabic, Polish, or Russian). For more on the pattern here, see our companion guides on localization without context and the full l10n.yaml configuration.

Takeaways

  • Skip navigatorKey.currentContext — it's null or stale exactly when business logic runs.
  • The generated lookupAppLocalizations(Locale) factory turns a locale into an AppLocalizations instance with no context.
  • Hold the current locale in one place (a Riverpod provider or an injected holder) and derive AppLocalizations from it so language switches propagate everywhere.
  • Remember the 2025 import change: package:<app>/l10n/app_localizations.dart, not flutter_gen.

Your BLoCs, providers, and services can now speak every language your app supports — no widget tree required.

Try FlutterLocalisation free to manage your ARB files, catch missing plurals before release, and keep every locale in sync.