← Back to Blog

Flutter InheritedWidget Localization: Custom Delegates and Advanced Architecture Patterns

flutterinheritedwidgetarchitecturelocalizationdelegatesadvanced

Flutter InheritedWidget Localization: Custom Delegates and Advanced Architecture Patterns

InheritedWidget is the foundation of Flutter's localization system. Understanding how to create custom localization delegates and leverage InheritedWidget patterns gives you complete control over translation loading, caching, and context-free access. This guide covers advanced localization architecture using InheritedWidget.

Understanding InheritedWidget in Localization

InheritedWidget enables:

  • Efficient data propagation down the widget tree
  • Automatic rebuilds when localization data changes
  • Custom localization delegates for remote translations
  • Context-free translation access through static methods
  • Lazy loading of language resources
  • Fine-grained control over when widgets rebuild

How Flutter's Built-in Localization Works

Before creating custom solutions, understand the existing system:

// Flutter's localization is built on InheritedWidget
// The Localizations widget provides this hierarchy:

// 1. Localizations (StatefulWidget)
//    └── _LocalizationsScope (InheritedWidget)
//        └── Your app widgets

// Access translations via:
AppLocalizations.of(context)!.someTranslation;

// Which internally does:
// Localizations.of<AppLocalizations>(context, AppLocalizations)

Creating a Custom Localization InheritedWidget

Build your own localization system:

import 'package:flutter/material.dart';

// 1. Define your translations model
class AppTranslations {
  final String locale;
  final Map<String, String> _translations;

  AppTranslations({
    required this.locale,
    required Map<String, String> translations,
  }) : _translations = translations;

  String translate(String key) {
    return _translations[key] ?? key;
  }

  // Convenience getters for type-safe access
  String get appTitle => translate('appTitle');
  String get welcomeMessage => translate('welcomeMessage');
  String get loginButton => translate('loginButton');
  String get logoutButton => translate('logoutButton');

  // Parameterized translations
  String greeting(String name) {
    return translate('greeting').replaceAll('{name}', name);
  }

  String itemCount(int count) {
    if (count == 0) return translate('noItems');
    if (count == 1) return translate('oneItem');
    return translate('multipleItems').replaceAll('{count}', count.toString());
  }
}

// 2. Create the InheritedWidget
class LocalizationProvider extends InheritedWidget {
  final AppTranslations translations;
  final Locale currentLocale;
  final void Function(Locale) setLocale;

  const LocalizationProvider({
    super.key,
    required this.translations,
    required this.currentLocale,
    required this.setLocale,
    required super.child,
  });

  // Static accessor for convenience
  static LocalizationProvider of(BuildContext context) {
    final provider =
        context.dependOnInheritedWidgetOfExactType<LocalizationProvider>();
    if (provider == null) {
      throw FlutterError(
        'LocalizationProvider not found in context. '
        'Make sure to wrap your app with LocalizationScope.',
      );
    }
    return provider;
  }

  // Get translations without context dependency (won't rebuild on changes)
  static LocalizationProvider? maybeOf(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<LocalizationProvider>();
  }

  @override
  bool updateShouldNotify(LocalizationProvider oldWidget) {
    return translations.locale != oldWidget.translations.locale;
  }
}

// 3. Create a StatefulWidget to manage locale state
class LocalizationScope extends StatefulWidget {
  final Widget child;
  final Locale initialLocale;
  final Future<Map<String, String>> Function(String locale) loadTranslations;

  const LocalizationScope({
    super.key,
    required this.child,
    required this.initialLocale,
    required this.loadTranslations,
  });

  @override
  State<LocalizationScope> createState() => _LocalizationScopeState();
}

class _LocalizationScopeState extends State<LocalizationScope> {
  late Locale _currentLocale;
  AppTranslations? _translations;
  bool _isLoading = true;

  @override
  void initState() {
    super.initState();
    _currentLocale = widget.initialLocale;
    _loadTranslations(_currentLocale.languageCode);
  }

  Future<void> _loadTranslations(String locale) async {
    setState(() => _isLoading = true);

    try {
      final translations = await widget.loadTranslations(locale);
      setState(() {
        _translations = AppTranslations(
          locale: locale,
          translations: translations,
        );
        _isLoading = false;
      });
    } catch (e) {
      // Fall back to empty translations
      setState(() {
        _translations = AppTranslations(
          locale: locale,
          translations: {},
        );
        _isLoading = false;
      });
    }
  }

  void _setLocale(Locale locale) {
    if (locale != _currentLocale) {
      _currentLocale = locale;
      _loadTranslations(locale.languageCode);
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading || _translations == null) {
      return const MaterialApp(
        home: Scaffold(
          body: Center(child: CircularProgressIndicator()),
        ),
      );
    }

    return LocalizationProvider(
      translations: _translations!,
      currentLocale: _currentLocale,
      setLocale: _setLocale,
      child: widget.child,
    );
  }
}

Loading Remote Translations

Implement a translation loader that fetches from an API:

import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';

class RemoteTranslationLoader {
  final String baseUrl;
  final Duration cacheExpiry;

  RemoteTranslationLoader({
    required this.baseUrl,
    this.cacheExpiry = const Duration(hours: 24),
  });

  Future<Map<String, String>> loadTranslations(String locale) async {
    // Try cache first
    final cached = await _getCachedTranslations(locale);
    if (cached != null) {
      return cached;
    }

    // Fetch from remote
    try {
      final response = await http.get(
        Uri.parse('$baseUrl/translations/$locale.json'),
      );

      if (response.statusCode == 200) {
        final Map<String, dynamic> data = json.decode(response.body);
        final translations = data.map(
          (key, value) => MapEntry(key, value.toString()),
        );

        // Cache the result
        await _cacheTranslations(locale, translations);

        return translations;
      }
    } catch (e) {
      debugPrint('Failed to load remote translations: $e');
    }

    // Fall back to bundled translations
    return _loadBundledTranslations(locale);
  }

  Future<Map<String, String>?> _getCachedTranslations(String locale) async {
    final prefs = await SharedPreferences.getInstance();
    final cacheKey = 'translations_$locale';
    final timestampKey = 'translations_timestamp_$locale';

    final cached = prefs.getString(cacheKey);
    final timestamp = prefs.getInt(timestampKey);

    if (cached == null || timestamp == null) return null;

    final cacheTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
    if (DateTime.now().difference(cacheTime) > cacheExpiry) {
      return null; // Cache expired
    }

    final Map<String, dynamic> data = json.decode(cached);
    return data.map((key, value) => MapEntry(key, value.toString()));
  }

  Future<void> _cacheTranslations(
    String locale,
    Map<String, String> translations,
  ) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString(
      'translations_$locale',
      json.encode(translations),
    );
    await prefs.setInt(
      'translations_timestamp_$locale',
      DateTime.now().millisecondsSinceEpoch,
    );
  }

  Future<Map<String, String>> _loadBundledTranslations(String locale) async {
    // Load from assets as fallback
    // This would typically use rootBundle.loadString
    return {
      'appTitle': 'My App',
      'welcomeMessage': 'Welcome',
      'loginButton': 'Log In',
      'logoutButton': 'Log Out',
    };
  }
}

Context-Free Translation Access

Access translations without BuildContext using a singleton:

import 'package:flutter/material.dart';

class L10n {
  static final L10n _instance = L10n._internal();
  factory L10n() => _instance;
  L10n._internal();

  // Store the current translations
  AppTranslations? _translations;
  BuildContext? _context;

  // Initialize with context (call from app root)
  void init(BuildContext context) {
    _context = context;
    _updateTranslations();
  }

  void _updateTranslations() {
    if (_context != null) {
      final provider = LocalizationProvider.maybeOf(_context!);
      _translations = provider?.translations;
    }
  }

  // Get current translations
  AppTranslations get translations {
    _updateTranslations();
    if (_translations == null) {
      throw StateError(
        'L10n not initialized. Call L10n().init(context) first.',
      );
    }
    return _translations!;
  }

  // Convenience static accessors
  static String get appTitle => L10n().translations.appTitle;
  static String get welcomeMessage => L10n().translations.welcomeMessage;
  static String greeting(String name) => L10n().translations.greeting(name);
}

// Usage in services (no context needed):
class AuthService {
  Future<void> logout() async {
    // Access translations without context
    final message = L10n.translations.logoutButton;
    debugPrint('Logging out: $message');
  }
}

// Initialize in your app:
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return LocalizationScope(
      initialLocale: const Locale('en'),
      loadTranslations: RemoteTranslationLoader(
        baseUrl: 'https://api.example.com',
      ).loadTranslations,
      child: Builder(
        builder: (context) {
          // Initialize L10n singleton
          L10n().init(context);

          return MaterialApp(
            home: const HomeScreen(),
          );
        },
      ),
    );
  }
}

Creating a Custom LocalizationsDelegate

Integrate with Flutter's official localization system:

import 'package:flutter/material.dart';

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

  CustomAppLocalizations(this.locale, this._translations);

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

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

  // Type-safe getters
  String get appTitle => translate('appTitle');
  String get welcomeMessage => translate('welcomeMessage');
}

class CustomLocalizationsDelegate
    extends LocalizationsDelegate<CustomAppLocalizations> {
  final Future<Map<String, String>> Function(String locale) loadTranslations;
  final List<Locale> supportedLocales;

  const CustomLocalizationsDelegate({
    required this.loadTranslations,
    required this.supportedLocales,
  });

  @override
  bool isSupported(Locale locale) {
    return supportedLocales.any(
      (supported) => supported.languageCode == locale.languageCode,
    );
  }

  @override
  Future<CustomAppLocalizations> load(Locale locale) async {
    final translations = await loadTranslations(locale.languageCode);
    return CustomAppLocalizations(locale, translations);
  }

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

// Usage in MaterialApp:
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final loader = RemoteTranslationLoader(
      baseUrl: 'https://api.example.com',
    );

    return MaterialApp(
      localizationsDelegates: [
        CustomLocalizationsDelegate(
          loadTranslations: loader.loadTranslations,
          supportedLocales: const [
            Locale('en'),
            Locale('es'),
            Locale('de'),
            Locale('fr'),
          ],
        ),
        GlobalMaterialLocalizations.delegate,
        GlobalWidgetsLocalizations.delegate,
        GlobalCupertinoLocalizations.delegate,
      ],
      supportedLocales: const [
        Locale('en'),
        Locale('es'),
        Locale('de'),
        Locale('fr'),
      ],
      home: const HomeScreen(),
    );
  }
}

Selective Rebuilding with InheritedModel

Use InheritedModel for fine-grained rebuilds:

import 'package:flutter/material.dart';

enum LocalizationAspect {
  locale,
  translations,
  all,
}

class LocalizationModel extends InheritedModel<LocalizationAspect> {
  final Locale locale;
  final AppTranslations translations;
  final void Function(Locale) setLocale;

  const LocalizationModel({
    super.key,
    required this.locale,
    required this.translations,
    required this.setLocale,
    required super.child,
  });

  static LocalizationModel of(
    BuildContext context, {
    LocalizationAspect aspect = LocalizationAspect.all,
  }) {
    return InheritedModel.inheritFrom<LocalizationModel>(
      context,
      aspect: aspect,
    )!;
  }

  // Only get translations (won't rebuild on locale change alone)
  static AppTranslations translationsOf(BuildContext context) {
    return of(context, aspect: LocalizationAspect.translations).translations;
  }

  // Only get locale (won't rebuild on translation changes)
  static Locale localeOf(BuildContext context) {
    return of(context, aspect: LocalizationAspect.locale).locale;
  }

  @override
  bool updateShouldNotify(LocalizationModel oldWidget) {
    return locale != oldWidget.locale ||
        translations.locale != oldWidget.translations.locale;
  }

  @override
  bool updateShouldNotifyDependent(
    LocalizationModel oldWidget,
    Set<LocalizationAspect> dependencies,
  ) {
    if (dependencies.contains(LocalizationAspect.all)) {
      return locale != oldWidget.locale ||
          translations.locale != oldWidget.translations.locale;
    }

    if (dependencies.contains(LocalizationAspect.locale)) {
      if (locale != oldWidget.locale) return true;
    }

    if (dependencies.contains(LocalizationAspect.translations)) {
      if (translations.locale != oldWidget.translations.locale) return true;
    }

    return false;
  }
}

// Usage - only rebuilds when translations change:
class TranslatedText extends StatelessWidget {
  final String translationKey;

  const TranslatedText(this.translationKey, {super.key});

  @override
  Widget build(BuildContext context) {
    // Only depends on translations aspect
    final translations = LocalizationModel.translationsOf(context);
    return Text(translations.translate(translationKey));
  }
}

// Usage - only rebuilds when locale changes:
class LocaleDisplay extends StatelessWidget {
  const LocaleDisplay({super.key});

  @override
  Widget build(BuildContext context) {
    // Only depends on locale aspect
    final locale = LocalizationModel.localeOf(context);
    return Text('Current: ${locale.languageCode}');
  }
}

Lazy Loading Translations per Route

Load translations only when needed:

import 'package:flutter/material.dart';

class RouteTranslations extends InheritedWidget {
  final Map<String, String> translations;
  final String routeName;

  const RouteTranslations({
    super.key,
    required this.translations,
    required this.routeName,
    required super.child,
  });

  static RouteTranslations? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<RouteTranslations>();
  }

  String translate(String key) => translations[key] ?? key;

  @override
  bool updateShouldNotify(RouteTranslations oldWidget) {
    return routeName != oldWidget.routeName;
  }
}

class LazyLocalizedRoute extends StatefulWidget {
  final String routeName;
  final Widget Function(BuildContext context) builder;
  final Future<Map<String, String>> Function(String route, String locale)
      loadRouteTranslations;

  const LazyLocalizedRoute({
    super.key,
    required this.routeName,
    required this.builder,
    required this.loadRouteTranslations,
  });

  @override
  State<LazyLocalizedRoute> createState() => _LazyLocalizedRouteState();
}

class _LazyLocalizedRouteState extends State<LazyLocalizedRoute> {
  Map<String, String>? _translations;
  bool _isLoading = true;

  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    _loadTranslations();
  }

  Future<void> _loadTranslations() async {
    final locale = Localizations.localeOf(context).languageCode;

    final translations = await widget.loadRouteTranslations(
      widget.routeName,
      locale,
    );

    if (mounted) {
      setState(() {
        _translations = translations;
        _isLoading = false;
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    if (_isLoading) {
      return const Center(child: CircularProgressIndicator());
    }

    return RouteTranslations(
      routeName: widget.routeName,
      translations: _translations ?? {},
      child: Builder(builder: widget.builder),
    );
  }
}

// Usage in routes:
class SettingsScreen extends StatelessWidget {
  const SettingsScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return LazyLocalizedRoute(
      routeName: 'settings',
      loadRouteTranslations: (route, locale) async {
        // Load only settings translations
        final response = await http.get(
          Uri.parse('https://api.example.com/translations/$locale/$route.json'),
        );
        return Map<String, String>.from(json.decode(response.body));
      },
      builder: (context) {
        final routeL10n = RouteTranslations.of(context)!;

        return Scaffold(
          appBar: AppBar(
            title: Text(routeL10n.translate('settingsTitle')),
          ),
          body: ListView(
            children: [
              ListTile(
                title: Text(routeL10n.translate('accountSettings')),
              ),
              ListTile(
                title: Text(routeL10n.translate('privacySettings')),
              ),
            ],
          ),
        );
      },
    );
  }
}

Testing Custom Localization

Write comprehensive tests:

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('LocalizationProvider', () {
    testWidgets('provides translations to descendants', (tester) async {
      final translations = AppTranslations(
        locale: 'en',
        translations: {'greeting': 'Hello'},
      );

      await tester.pumpWidget(
        LocalizationProvider(
          translations: translations,
          currentLocale: const Locale('en'),
          setLocale: (_) {},
          child: Builder(
            builder: (context) {
              final provider = LocalizationProvider.of(context);
              return Text(provider.translations.translate('greeting'));
            },
          ),
        ),
      );

      expect(find.text('Hello'), findsOneWidget);
    });

    testWidgets('rebuilds when locale changes', (tester) async {
      late void Function(Locale) setLocale;

      await tester.pumpWidget(
        MaterialApp(
          home: StatefulBuilder(
            builder: (context, setState) {
              var translations = AppTranslations(
                locale: 'en',
                translations: {'greeting': 'Hello'},
              );
              var locale = const Locale('en');

              setLocale = (newLocale) {
                setState(() {
                  locale = newLocale;
                  translations = AppTranslations(
                    locale: newLocale.languageCode,
                    translations: {'greeting': 'Hola'},
                  );
                });
              };

              return LocalizationProvider(
                translations: translations,
                currentLocale: locale,
                setLocale: setLocale,
                child: Builder(
                  builder: (context) {
                    final provider = LocalizationProvider.of(context);
                    return Text(provider.translations.translate('greeting'));
                  },
                ),
              );
            },
          ),
        ),
      );

      expect(find.text('Hello'), findsOneWidget);

      setLocale(const Locale('es'));
      await tester.pumpAndSettle();

      expect(find.text('Hola'), findsOneWidget);
    });

    testWidgets('throws error when not in tree', (tester) async {
      await tester.pumpWidget(
        MaterialApp(
          home: Builder(
            builder: (context) {
              expect(
                () => LocalizationProvider.of(context),
                throwsA(isA<FlutterError>()),
              );
              return const SizedBox();
            },
          ),
        ),
      );
    });
  });

  group('CustomLocalizationsDelegate', () {
    testWidgets('loads translations for supported locale', (tester) async {
      final delegate = CustomLocalizationsDelegate(
        loadTranslations: (locale) async {
          return {'appTitle': 'Test App ($locale)'};
        },
        supportedLocales: const [Locale('en'), Locale('es')],
      );

      expect(delegate.isSupported(const Locale('en')), isTrue);
      expect(delegate.isSupported(const Locale('es')), isTrue);
      expect(delegate.isSupported(const Locale('fr')), isFalse);

      final localizations = await delegate.load(const Locale('en'));
      expect(localizations.appTitle, equals('Test App (en)'));
    });
  });
}

Summary

Using InheritedWidget for Flutter localization provides:

  1. Custom translation loading from any source (remote API, database, etc.)
  2. Fine-grained rebuild control with InheritedModel
  3. Context-free access through singletons or static methods
  4. Lazy loading of translations per route or feature
  5. Caching strategies with expiration policies
  6. Fallback mechanisms for offline or failed loads
  7. Complete testing of localization infrastructure

Understanding InheritedWidget patterns gives you the flexibility to build localization systems tailored to your app's specific needs while maintaining the efficiency and developer experience of Flutter's widget model.