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:
- Looks up the
Localizationswidget in the widget tree - Asks registered delegates for the requested type
- 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:
isSupported→load→shouldReload - 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.