← Back to Blog

Flutter Localization Delegates Explained: A Deep Dive into How l10n Works Under the Hood

flutterlocalizationdelegatesl10ndeep-diveadvanced

Flutter Localization Delegates Explained: A Deep Dive into How l10n Works Under the Hood

Understanding localization delegates is key to mastering Flutter localization. This guide explains what delegates are, how they work, and how to create custom delegates for advanced use cases.

What Are Localization Delegates?

Localization delegates are the bridge between your app and translated content. When Flutter needs a translated string, it asks delegates to provide it.

The delegation pattern:

Your Widget → Localizations.of(context) → LocalizationDelegate → Your Translations

Every time you call AppLocalizations.of(context)!.hello, Flutter:

  1. Looks up the Localizations widget in the widget tree
  2. Asks registered delegates for the requested type
  3. Returns the loaded translations

The Three Essential Delegates

Every Flutter app with localization needs three delegates:

MaterialApp(
  localizationsDelegates: [
    AppLocalizations.delegate,           // Your app's translations
    GlobalMaterialLocalizations.delegate, // Material component translations
    GlobalWidgetsLocalizations.delegate,  // Basic widget translations
  ],
  supportedLocales: [
    Locale('en'),
    Locale('es'),
    Locale('fr'),
  ],
)

1. GlobalMaterialLocalizations

Handles Material Design components:

// What it provides:
// - Dialog buttons (OK, Cancel, etc.)
// - Date picker labels
// - Time picker labels
// - Text field hints
// - Pagination text
// - And 70+ more Material strings

Without this delegate:

⚠️ A MaterialLocalizations was not found.
⚠️ The default MaterialLocalizations delegate is not being used.

2. GlobalWidgetsLocalizations

Handles basic widget behavior:

// What it provides:
// - Text direction (LTR/RTL)
// - Default text rendering
// - Accessibility labels

This is required for RTL support to work correctly.

3. Your App Delegate (AppLocalizations.delegate)

Generated from your ARB files:

// What it provides:
// - All your custom translations
// - Pluralization rules
// - Placeholder substitutions
// - Gender-aware messages

How Delegates Work Internally

The LocalizationsDelegate Class

Every delegate extends LocalizationsDelegate<T>:

abstract class LocalizationsDelegate<T> {
  const LocalizationsDelegate();

  // Can this delegate provide translations for this locale?
  bool isSupported(Locale locale);

  // Load the translations
  Future<T> load(Locale locale);

  // Should translations reload when locale changes?
  bool shouldReload(covariant LocalizationsDelegate<T> old);
}

Generated Delegate Example

When Flutter generates your AppLocalizations, it also creates a delegate:

class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
  const _AppLocalizationsDelegate();

  @override
  bool isSupported(Locale locale) {
    return ['en', 'es', 'fr', 'de'].contains(locale.languageCode);
  }

  @override
  Future<AppLocalizations> load(Locale locale) {
    return SynchronousFuture<AppLocalizations>(
      lookupAppLocalizations(locale)
    );
  }

  @override
  bool shouldReload(_AppLocalizationsDelegate old) => false;
}

// The lookup function that returns the right translations
AppLocalizations lookupAppLocalizations(Locale locale) {
  switch (locale.languageCode) {
    case 'en': return AppLocalizationsEn();
    case 'es': return AppLocalizationsEs();
    case 'fr': return AppLocalizationsFr();
    case 'de': return AppLocalizationsDe();
  }
  throw FlutterError('AppLocalizations not found for locale: $locale');
}

The Loading Process

When your app starts or locale changes:

1. MaterialApp detects locale change
     ↓
2. Localizations widget rebuilds
     ↓
3. Each delegate's isSupported() is called
     ↓
4. Supported delegates' load() is called
     ↓
5. Translations are stored in InheritedWidget
     ↓
6. Widgets rebuild with new translations

Creating Custom Delegates

Sometimes you need translations beyond what gen-l10n generates.

Use Case 1: Remote Translations

Load translations from a server:

class RemoteLocalizationsDelegate extends LocalizationsDelegate<RemoteLocalizations> {
  final ApiClient apiClient;

  const RemoteLocalizationsDelegate(this.apiClient);

  @override
  bool isSupported(Locale locale) => true; // Server handles all locales

  @override
  Future<RemoteLocalizations> load(Locale locale) async {
    // Fetch translations from API
    final translations = await apiClient.getTranslations(locale.languageCode);
    return RemoteLocalizations(translations);
  }

  @override
  bool shouldReload(RemoteLocalizationsDelegate old) {
    // Reload if API client changed
    return apiClient != old.apiClient;
  }
}

class RemoteLocalizations {
  final Map<String, String> _translations;

  RemoteLocalizations(this._translations);

  String get(String key) => _translations[key] ?? key;

  static RemoteLocalizations of(BuildContext context) {
    return Localizations.of<RemoteLocalizations>(context, RemoteLocalizations)!;
  }
}

Usage:

MaterialApp(
  localizationsDelegates: [
    RemoteLocalizationsDelegate(apiClient),
    AppLocalizations.delegate,
    GlobalMaterialLocalizations.delegate,
    GlobalWidgetsLocalizations.delegate,
  ],
)

// In widgets:
Text(RemoteLocalizations.of(context).get('dynamic_promo_text'))

Use Case 2: Module-Specific Translations

Large apps often have separate translation files per feature:

// auth_localizations_delegate.dart
class AuthLocalizationsDelegate extends LocalizationsDelegate<AuthLocalizations> {
  @override
  bool isSupported(Locale locale) {
    return ['en', 'es', 'fr'].contains(locale.languageCode);
  }

  @override
  Future<AuthLocalizations> load(Locale locale) async {
    final jsonString = await rootBundle.loadString(
      'assets/l10n/auth_${locale.languageCode}.json'
    );
    final Map<String, dynamic> jsonMap = json.decode(jsonString);
    return AuthLocalizations.fromJson(jsonMap);
  }

  @override
  bool shouldReload(AuthLocalizationsDelegate old) => false;
}

class AuthLocalizations {
  final Map<String, String> _strings;

  AuthLocalizations.fromJson(Map<String, dynamic> json)
      : _strings = json.map((k, v) => MapEntry(k, v.toString()));

  String get loginButton => _strings['loginButton'] ?? 'Sign In';
  String get logoutButton => _strings['logoutButton'] ?? 'Sign Out';
  String get welcomeBack => _strings['welcomeBack'] ?? 'Welcome back!';

  static AuthLocalizations of(BuildContext context) {
    return Localizations.of<AuthLocalizations>(context, AuthLocalizations)!;
  }
}

Use Case 3: Fallback Delegate

Handle missing translations gracefully:

class FallbackLocalizationsDelegate extends LocalizationsDelegate<FallbackLocalizations> {
  final Locale fallbackLocale;

  const FallbackLocalizationsDelegate({
    this.fallbackLocale = const Locale('en'),
  });

  @override
  bool isSupported(Locale locale) => true;

  @override
  Future<FallbackLocalizations> load(Locale locale) async {
    // Try to load requested locale
    try {
      return await _loadLocale(locale);
    } catch (e) {
      // Fall back to default
      print('Falling back to ${fallbackLocale.languageCode} for $locale');
      return await _loadLocale(fallbackLocale);
    }
  }

  Future<FallbackLocalizations> _loadLocale(Locale locale) async {
    // Your loading logic
  }

  @override
  bool shouldReload(FallbackLocalizationsDelegate old) => false;
}

Delegate Resolution Order

When multiple delegates can provide the same type, order matters:

localizationsDelegates: [
  CustomAppLocalizations.delegate,  // Checked first
  AppLocalizations.delegate,        // Checked second
  GlobalMaterialLocalizations.delegate,
  GlobalWidgetsLocalizations.delegate,
],

The first delegate that returns isSupported(locale) == true and successfully loads wins.

Override pattern:

// CustomAppLocalizations overrides some strings from AppLocalizations
class CustomAppLocalizations extends AppLocalizations {
  @override
  String get appTitle => 'My Custom App Title';

  // Other strings fall through to parent
}

Common Delegate Patterns

Async Loading with Caching

class CachedLocalizationsDelegate extends LocalizationsDelegate<CachedLocalizations> {
  static final Map<Locale, CachedLocalizations> _cache = {};

  @override
  Future<CachedLocalizations> load(Locale locale) async {
    if (_cache.containsKey(locale)) {
      return _cache[locale]!;
    }

    final translations = await _loadFromSource(locale);
    _cache[locale] = translations;
    return translations;
  }

  Future<CachedLocalizations> _loadFromSource(Locale locale) async {
    // Load from file, network, etc.
  }

  @override
  bool shouldReload(CachedLocalizationsDelegate old) => false;
}

Hot Reload Support (Development)

class DebugLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
  @override
  bool shouldReload(DebugLocalizationsDelegate old) {
    // Always reload in debug mode
    return kDebugMode;
  }

  @override
  Future<AppLocalizations> load(Locale locale) async {
    if (kDebugMode) {
      // Clear any caches
      // Reload from source
    }
    return await _loadTranslations(locale);
  }
}

Region-Specific Override

class RegionalLocalizationsDelegate extends LocalizationsDelegate<RegionalLocalizations> {
  @override
  bool isSupported(Locale locale) {
    // Support regional variants
    return supportedLocales.any((supported) =>
      supported.languageCode == locale.languageCode
    );
  }

  @override
  Future<RegionalLocalizations> load(Locale locale) async {
    // Try full locale first (es_MX)
    try {
      return await _loadLocale('${locale.languageCode}_${locale.countryCode}');
    } catch (e) {
      // Fall back to language only (es)
      return await _loadLocale(locale.languageCode);
    }
  }
}

Debugging Delegate Issues

Check Delegate Registration

void main() {
  // Print all registered delegates
  WidgetsFlutterBinding.ensureInitialized();
  runApp(
    Builder(
      builder: (context) {
        final localizations = Localizations.of<AppLocalizations>(
          context,
          AppLocalizations,
        );
        print('AppLocalizations loaded: ${localizations != null}');
        return MyApp();
      },
    ),
  );
}

Common Errors and Fixes

Error: "No MaterialLocalizations found"

// Fix: Add GlobalMaterialLocalizations.delegate
MaterialApp(
  localizationsDelegates: [
    AppLocalizations.delegate,
    GlobalMaterialLocalizations.delegate, // Add this
    GlobalWidgetsLocalizations.delegate,
  ],
)

Error: "Localizations.of() called with a context that does not contain..."

// Fix: Make sure the widget is below MaterialApp in the tree
// Wrong:
MaterialApp(
  home: Builder(
    builder: (context) {
      // This context doesn't have localizations yet
      return Text(AppLocalizations.of(context)!.hello);
    },
  ),
)

// Correct:
MaterialApp(
  home: MyHomePage(), // Separate widget
)

class MyHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    // This context has localizations
    return Text(AppLocalizations.of(context)!.hello);
  }
}

Error: "isSupported returned false for locale"

// Fix: Add locale to supportedLocales
MaterialApp(
  supportedLocales: [
    Locale('en'),
    Locale('es'),
    Locale('fr'), // Make sure your locale is here
  ],
)

Testing Delegates

Unit Test Custom Delegate

void main() {
  group('RemoteLocalizationsDelegate', () {
    late MockApiClient mockApi;
    late RemoteLocalizationsDelegate delegate;

    setUp(() {
      mockApi = MockApiClient();
      delegate = RemoteLocalizationsDelegate(mockApi);
    });

    test('isSupported returns true for any locale', () {
      expect(delegate.isSupported(Locale('en')), true);
      expect(delegate.isSupported(Locale('zh')), true);
    });

    test('load fetches translations from API', () async {
      when(mockApi.getTranslations('es'))
          .thenAnswer((_) async => {'hello': 'Hola'});

      final localizations = await delegate.load(Locale('es'));

      expect(localizations.get('hello'), 'Hola');
      verify(mockApi.getTranslations('es')).called(1);
    });

    test('shouldReload returns true when API client changes', () {
      final newDelegate = RemoteLocalizationsDelegate(MockApiClient());
      expect(delegate.shouldReload(newDelegate), true);
    });
  });
}

Widget Test with Custom Delegate

testWidgets('displays remote translation', (tester) async {
  final mockApi = MockApiClient();
  when(mockApi.getTranslations('en'))
      .thenAnswer((_) async => {'greeting': 'Hello from server!'});

  await tester.pumpWidget(
    MaterialApp(
      localizationsDelegates: [
        RemoteLocalizationsDelegate(mockApi),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
      ],
      supportedLocales: [Locale('en')],
      home: Builder(
        builder: (context) {
          return Text(RemoteLocalizations.of(context).get('greeting'));
        },
      ),
    ),
  );

  await tester.pumpAndSettle();
  expect(find.text('Hello from server!'), findsOneWidget);
});

Performance Considerations

Lazy Loading

Load translations only when needed:

class LazyLocalizationsDelegate extends LocalizationsDelegate<LazyLocalizations> {
  @override
  Future<LazyLocalizations> load(Locale locale) {
    // Return a wrapper that loads on first access
    return SynchronousFuture(LazyLocalizations(locale));
  }
}

class LazyLocalizations {
  final Locale locale;
  Map<String, String>? _translations;

  LazyLocalizations(this.locale);

  Future<String> get(String key) async {
    _translations ??= await _loadTranslations();
    return _translations![key] ?? key;
  }

  Future<Map<String, String>> _loadTranslations() async {
    // Load from file or network
  }
}

Minimize Reloads

@override
bool shouldReload(MyLocalizationsDelegate old) {
  // Only reload when actually necessary
  return _version != old._version;
}

Summary

Localization delegates are the foundation of Flutter's l10n system:

  • Three essential delegates: App, Material, and Widgets
  • Delegate lifecycle: isSupportedloadshouldReload
  • Custom delegates for remote, modular, or cached translations
  • Order matters when multiple delegates exist
  • Test thoroughly to ensure reliability

Understanding delegates gives you the power to implement advanced localization patterns like remote loading, feature-specific translations, and graceful fallbacks.


Need help managing your Flutter localizations? FlutterLocalisation simplifies ARB file management with AI-powered translation and team collaboration. Try it free.