← Back to Blog

Flutter BLoC Localization: Complete Guide to i18n with flutter_bloc

flutterbloclocalizationi18nstate-managementcubit

Flutter BLoC Localization: Complete Guide to i18n with flutter_bloc

BLoC (Business Logic Component) is one of the most popular state management solutions for Flutter, known for its separation of concerns and testability. When building multilingual apps, integrating localization with BLoC requires careful architecture decisions. This guide covers everything from basic setup to advanced patterns.

Why BLoC for Localization?

BLoC offers several advantages for managing localization:

  • Predictable state changes - Language switches follow a clear event → state flow
  • Testability - Easy to unit test locale switching logic
  • Separation of concerns - Locale management stays out of UI code
  • Stream-based - Reactive updates when locale changes
  • Scalability - Works well in large applications with multiple features

Basic Setup

First, add the required dependencies:

# pubspec.yaml
dependencies:
  flutter:
    sdk: flutter
  flutter_localizations:
    sdk: flutter
  flutter_bloc: ^8.1.3
  hydrated_bloc: ^9.1.2  # For persistence
  equatable: ^2.0.5
  intl: ^0.18.1

Enable localization generation:

# pubspec.yaml
flutter:
  generate: true

Configure l10n.yaml:

# l10n.yaml
arb-dir: lib/l10n
template-arb-file: app_en.arb
output-localization-file: app_localizations.dart
output-class: AppLocalizations
nullable-getter: false

Creating the Locale BLoC

Events

// lib/bloc/locale/locale_event.dart
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';

abstract class LocaleEvent extends Equatable {
  const LocaleEvent();

  @override
  List<Object?> get props => [];
}

class LocaleChanged extends LocaleEvent {
  final Locale locale;

  const LocaleChanged(this.locale);

  @override
  List<Object> get props => [locale];
}

class LocaleSystemRequested extends LocaleEvent {
  const LocaleSystemRequested();
}

class LocaleInitialized extends LocaleEvent {
  const LocaleInitialized();
}

State

// lib/bloc/locale/locale_state.dart
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';

enum LocaleStatus { initial, loading, loaded, error }

class LocaleState extends Equatable {
  final Locale locale;
  final LocaleStatus status;
  final bool isSystemLocale;

  const LocaleState({
    required this.locale,
    this.status = LocaleStatus.initial,
    this.isSystemLocale = false,
  });

  factory LocaleState.initial() {
    return const LocaleState(
      locale: Locale('en'),
      status: LocaleStatus.initial,
      isSystemLocale: true,
    );
  }

  LocaleState copyWith({
    Locale? locale,
    LocaleStatus? status,
    bool? isSystemLocale,
  }) {
    return LocaleState(
      locale: locale ?? this.locale,
      status: status ?? this.status,
      isSystemLocale: isSystemLocale ?? this.isSystemLocale,
    );
  }

  @override
  List<Object> get props => [locale, status, isSystemLocale];
}

BLoC Implementation

// lib/bloc/locale/locale_bloc.dart
import 'dart:ui';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'locale_event.dart';
import 'locale_state.dart';

class LocaleBloc extends HydratedBloc<LocaleEvent, LocaleState> {
  static const supportedLocales = [
    Locale('en'),
    Locale('es'),
    Locale('fr'),
    Locale('de'),
    Locale('ar'),
    Locale('ja'),
    Locale('zh'),
  ];

  LocaleBloc() : super(LocaleState.initial()) {
    on<LocaleInitialized>(_onInitialized);
    on<LocaleChanged>(_onChanged);
    on<LocaleSystemRequested>(_onSystemRequested);
  }

  Future<void> _onInitialized(
    LocaleInitialized event,
    Emitter<LocaleState> emit,
  ) async {
    emit(state.copyWith(status: LocaleStatus.loading));

    // If using system locale, detect it
    if (state.isSystemLocale) {
      final systemLocale = _getSystemLocale();
      emit(state.copyWith(
        locale: systemLocale,
        status: LocaleStatus.loaded,
      ));
    } else {
      emit(state.copyWith(status: LocaleStatus.loaded));
    }
  }

  void _onChanged(
    LocaleChanged event,
    Emitter<LocaleState> emit,
  ) {
    if (!_isSupported(event.locale)) {
      emit(state.copyWith(status: LocaleStatus.error));
      return;
    }

    emit(state.copyWith(
      locale: event.locale,
      status: LocaleStatus.loaded,
      isSystemLocale: false,
    ));
  }

  void _onSystemRequested(
    LocaleSystemRequested event,
    Emitter<LocaleState> emit,
  ) {
    final systemLocale = _getSystemLocale();
    emit(state.copyWith(
      locale: systemLocale,
      status: LocaleStatus.loaded,
      isSystemLocale: true,
    ));
  }

  Locale _getSystemLocale() {
    final systemLocale = PlatformDispatcher.instance.locale;
    return _findBestMatch(systemLocale);
  }

  Locale _findBestMatch(Locale locale) {
    // Exact match
    if (_isSupported(locale)) return locale;

    // Language-only match
    final languageMatch = supportedLocales.firstWhere(
      (supported) => supported.languageCode == locale.languageCode,
      orElse: () => supportedLocales.first,
    );

    return languageMatch;
  }

  bool _isSupported(Locale locale) {
    return supportedLocales.any(
      (supported) =>
          supported.languageCode == locale.languageCode &&
          (supported.countryCode == null ||
              supported.countryCode == locale.countryCode),
    );
  }

  // HydratedBloc persistence
  @override
  LocaleState? fromJson(Map<String, dynamic> json) {
    try {
      return LocaleState(
        locale: Locale(
          json['languageCode'] as String,
          json['countryCode'] as String?,
        ),
        status: LocaleStatus.loaded,
        isSystemLocale: json['isSystemLocale'] as bool? ?? false,
      );
    } catch (_) {
      return null;
    }
  }

  @override
  Map<String, dynamic>? toJson(LocaleState state) {
    return {
      'languageCode': state.locale.languageCode,
      'countryCode': state.locale.countryCode,
      'isSystemLocale': state.isSystemLocale,
    };
  }
}

App Integration

Main App Setup

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'bloc/locale/locale_bloc.dart';
import 'bloc/locale/locale_event.dart';
import 'bloc/locale/locale_state.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();

  // Initialize HydratedBloc storage
  HydratedBloc.storage = await HydratedStorage.build(
    storageDirectory: await getApplicationDocumentsDirectory(),
  );

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => LocaleBloc()..add(const LocaleInitialized()),
      child: BlocBuilder<LocaleBloc, LocaleState>(
        buildWhen: (previous, current) => previous.locale != current.locale,
        builder: (context, state) {
          return MaterialApp(
            title: 'BLoC Localization Demo',
            locale: state.locale,
            supportedLocales: LocaleBloc.supportedLocales,
            localizationsDelegates: const [
              AppLocalizations.delegate,
              GlobalMaterialLocalizations.delegate,
              GlobalWidgetsLocalizations.delegate,
              GlobalCupertinoLocalizations.delegate,
            ],
            home: const HomePage(),
          );
        },
      ),
    );
  }
}

Language Selector Widget

// lib/widgets/language_selector.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import '../bloc/locale/locale_bloc.dart';
import '../bloc/locale/locale_event.dart';
import '../bloc/locale/locale_state.dart';

class LanguageSelector extends StatelessWidget {
  const LanguageSelector({super.key});

  static const _languageNames = {
    'en': 'English',
    'es': 'Espanol',
    'fr': 'Francais',
    'de': 'Deutsch',
    'ar': 'العربية',
    'ja': '日本語',
    'zh': '中文',
  };

  @override
  Widget build(BuildContext context) {
    final l10n = AppLocalizations.of(context);

    return BlocBuilder<LocaleBloc, LocaleState>(
      builder: (context, state) {
        return Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              l10n.selectLanguage,
              style: Theme.of(context).textTheme.titleMedium,
            ),
            const SizedBox(height: 8),

            // System locale option
            RadioListTile<bool>(
              title: Text(l10n.systemLanguage),
              subtitle: state.isSystemLocale
                  ? Text(_languageNames[state.locale.languageCode] ?? '')
                  : null,
              value: true,
              groupValue: state.isSystemLocale,
              onChanged: (_) {
                context.read<LocaleBloc>().add(const LocaleSystemRequested());
              },
            ),

            const Divider(),

            // Manual locale options
            ...LocaleBloc.supportedLocales.map((locale) {
              final isSelected = !state.isSystemLocale &&
                  state.locale.languageCode == locale.languageCode;

              return RadioListTile<String>(
                title: Text(_languageNames[locale.languageCode] ?? ''),
                value: locale.languageCode,
                groupValue:
                    state.isSystemLocale ? null : state.locale.languageCode,
                onChanged: (_) {
                  context.read<LocaleBloc>().add(LocaleChanged(locale));
                },
              );
            }),
          ],
        );
      },
    );
  }
}

Using Cubit for Simpler Cases

For simpler locale management, Cubit offers less boilerplate:

// lib/cubit/locale_cubit.dart
import 'dart:ui';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';

class LocaleCubit extends HydratedCubit<Locale> {
  static const supportedLocales = [
    Locale('en'),
    Locale('es'),
    Locale('fr'),
  ];

  LocaleCubit() : super(const Locale('en'));

  void changeLocale(Locale locale) {
    if (supportedLocales.contains(locale)) {
      emit(locale);
    }
  }

  void useSystemLocale() {
    final systemLocale = PlatformDispatcher.instance.locale;
    final bestMatch = supportedLocales.firstWhere(
      (l) => l.languageCode == systemLocale.languageCode,
      orElse: () => supportedLocales.first,
    );
    emit(bestMatch);
  }

  @override
  Locale? fromJson(Map<String, dynamic> json) {
    return Locale(
      json['languageCode'] as String,
      json['countryCode'] as String?,
    );
  }

  @override
  Map<String, dynamic>? toJson(Locale state) {
    return {
      'languageCode': state.languageCode,
      'countryCode': state.countryCode,
    };
  }
}

Accessing Translations in BLoC

One challenge is accessing translations inside BLoC classes. Here are several approaches:

Approach 1: Pass Localized Strings as Event Parameters

// Events carry pre-localized strings
class ShowErrorEvent extends AppEvent {
  final String errorMessage;
  const ShowErrorEvent(this.errorMessage);
}

// In UI
context.read<AppBloc>().add(
  ShowErrorEvent(AppLocalizations.of(context).networkError),
);

Approach 2: Use Error Codes with UI Translation

// BLoC emits error codes
enum AppError { network, validation, unauthorized }

class AppState {
  final AppError? error;
  // ...
}

// UI translates based on code
BlocBuilder<AppBloc, AppState>(
  builder: (context, state) {
    if (state.error != null) {
      final message = switch (state.error!) {
        AppError.network => AppLocalizations.of(context).networkError,
        AppError.validation => AppLocalizations.of(context).validationError,
        AppError.unauthorized => AppLocalizations.of(context).unauthorizedError,
      };
      return ErrorWidget(message: message);
    }
    return const SizedBox.shrink();
  },
)

Approach 3: Translation Service with GetIt

// lib/services/translation_service.dart
import 'package:flutter/material.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';

class TranslationService {
  AppLocalizations? _localizations;

  void update(BuildContext context) {
    _localizations = AppLocalizations.of(context);
  }

  String get welcomeMessage =>
      _localizations?.welcomeMessage ?? 'Welcome';

  String itemCount(int count) =>
      _localizations?.itemCount(count) ?? '$count items';
}

// Setup in GetIt
final getIt = GetIt.instance;

void setupServices() {
  getIt.registerLazySingleton<TranslationService>(() => TranslationService());
}

// Update in main widget
class _AppState extends State<App> {
  @override
  void didChangeDependencies() {
    super.didChangeDependencies();
    getIt<TranslationService>().update(context);
  }
}

// Use in BLoC
class MyBloc extends Bloc<MyEvent, MyState> {
  final TranslationService _translations;

  MyBloc({TranslationService? translations})
      : _translations = translations ?? getIt<TranslationService>(),
        super(MyState.initial());
}

Multi-Feature Architecture

For large apps with multiple features, each feature can have its own localized BLoC:

// lib/features/auth/bloc/auth_bloc.dart
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  AuthBloc() : super(AuthState.initial()) {
    on<LoginRequested>(_onLoginRequested);
  }

  Future<void> _onLoginRequested(
    LoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(state.copyWith(status: AuthStatus.loading));

    try {
      await _authRepository.login(event.email, event.password);
      emit(state.copyWith(status: AuthStatus.authenticated));
    } catch (e) {
      // Emit error code, not translated string
      emit(state.copyWith(
        status: AuthStatus.error,
        errorCode: AuthErrorCode.invalidCredentials,
      ));
    }
  }
}

// lib/features/auth/view/login_page.dart
class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocConsumer<AuthBloc, AuthState>(
      listener: (context, state) {
        if (state.errorCode != null) {
          final l10n = AppLocalizations.of(context);
          final message = switch (state.errorCode!) {
            AuthErrorCode.invalidCredentials => l10n.invalidCredentials,
            AuthErrorCode.networkError => l10n.networkError,
            AuthErrorCode.serverError => l10n.serverError,
          };
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(message)),
          );
        }
      },
      builder: (context, state) {
        // Build UI
      },
    );
  }
}

Testing Localization with BLoC

Unit Testing the LocaleBloc

// test/bloc/locale_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:mocktail/mocktail.dart';

class MockStorage extends Mock implements Storage {}

void main() {
  late Storage storage;

  setUp(() {
    storage = MockStorage();
    when(() => storage.read(any())).thenReturn(null);
    when(() => storage.write(any(), any<dynamic>()))
        .thenAnswer((_) async {});
    when(() => storage.delete(any())).thenAnswer((_) async {});
    when(() => storage.clear()).thenAnswer((_) async {});
    HydratedBloc.storage = storage;
  });

  group('LocaleBloc', () {
    blocTest<LocaleBloc, LocaleState>(
      'emits loaded state when initialized',
      build: () => LocaleBloc(),
      act: (bloc) => bloc.add(const LocaleInitialized()),
      expect: () => [
        isA<LocaleState>()
            .having((s) => s.status, 'status', LocaleStatus.loading),
        isA<LocaleState>()
            .having((s) => s.status, 'status', LocaleStatus.loaded),
      ],
    );

    blocTest<LocaleBloc, LocaleState>(
      'changes locale when LocaleChanged is added',
      build: () => LocaleBloc(),
      act: (bloc) => bloc.add(const LocaleChanged(Locale('es'))),
      expect: () => [
        isA<LocaleState>()
            .having((s) => s.locale.languageCode, 'languageCode', 'es')
            .having((s) => s.isSystemLocale, 'isSystemLocale', false),
      ],
    );

    blocTest<LocaleBloc, LocaleState>(
      'emits error for unsupported locale',
      build: () => LocaleBloc(),
      act: (bloc) => bloc.add(const LocaleChanged(Locale('xx'))),
      expect: () => [
        isA<LocaleState>()
            .having((s) => s.status, 'status', LocaleStatus.error),
      ],
    );
  });
}

Widget Testing with Mocked Locale

// test/widgets/home_page_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';

class MockLocaleBloc extends MockBloc<LocaleEvent, LocaleState>
    implements LocaleBloc {}

void main() {
  late MockLocaleBloc mockLocaleBloc;

  setUp(() {
    mockLocaleBloc = MockLocaleBloc();
  });

  Widget createTestWidget({required Locale locale}) {
    when(() => mockLocaleBloc.state).thenReturn(
      LocaleState(locale: locale, status: LocaleStatus.loaded),
    );

    return BlocProvider<LocaleBloc>.value(
      value: mockLocaleBloc,
      child: MaterialApp(
        locale: locale,
        supportedLocales: LocaleBloc.supportedLocales,
        localizationsDelegates: const [
          AppLocalizations.delegate,
          GlobalMaterialLocalizations.delegate,
          GlobalWidgetsLocalizations.delegate,
          GlobalCupertinoLocalizations.delegate,
        ],
        home: const HomePage(),
      ),
    );
  }

  testWidgets('displays English text when locale is en', (tester) async {
    await tester.pumpWidget(createTestWidget(locale: const Locale('en')));
    await tester.pumpAndSettle();

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

  testWidgets('displays Spanish text when locale is es', (tester) async {
    await tester.pumpWidget(createTestWidget(locale: const Locale('es')));
    await tester.pumpAndSettle();

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

BlocObserver for Locale Debugging

// lib/bloc/app_bloc_observer.dart
import 'package:flutter_bloc/flutter_bloc.dart';

class AppBlocObserver extends BlocObserver {
  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);

    if (bloc is LocaleBloc) {
      final previousLocale = (change.currentState as LocaleState).locale;
      final newLocale = (change.nextState as LocaleState).locale;

      if (previousLocale != newLocale) {
        print('Locale changed: ${previousLocale.languageCode}${newLocale.languageCode}');
      }
    }
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    super.onError(bloc, error, stackTrace);
    print('Bloc error in ${bloc.runtimeType}: $error');
  }
}

// In main.dart
void main() {
  Bloc.observer = AppBlocObserver();
  runApp(const MyApp());
}

Best Practices

1. Keep Locale BLoC Separate

Don't mix locale management with other business logic:

// Good - dedicated locale bloc
class LocaleBloc extends Bloc<LocaleEvent, LocaleState> { ... }
class AuthBloc extends Bloc<AuthEvent, AuthState> { ... }

// Bad - mixing concerns
class AppBloc extends Bloc<AppEvent, AppState> {
  // Handles locale AND auth AND everything else
}

2. Use MultiBlocProvider for Clean Setup

MultiBlocProvider(
  providers: [
    BlocProvider(create: (_) => LocaleBloc()..add(const LocaleInitialized())),
    BlocProvider(create: (_) => AuthBloc()),
    BlocProvider(create: (_) => ThemeBloc()),
  ],
  child: const App(),
)

3. Emit Error Codes, Not Translated Strings

BLoC should remain framework-agnostic. Keep translations in the UI layer:

// Good
emit(state.copyWith(errorCode: ErrorCode.networkFailed));

// Bad
emit(state.copyWith(errorMessage: 'Network connection failed'));

4. Use buildWhen for Performance

Only rebuild when locale actually changes:

BlocBuilder<LocaleBloc, LocaleState>(
  buildWhen: (previous, current) =>
      previous.locale != current.locale,
  builder: (context, state) {
    return MaterialApp(
      locale: state.locale,
      // ...
    );
  },
)

Conclusion

BLoC provides a robust, testable approach to Flutter localization. By keeping locale state in a dedicated BLoC, using events for state changes, and leveraging HydratedBloc for persistence, you get a clean architecture that scales well.

Key takeaways:

  • Create a dedicated LocaleBloc for managing app locale
  • Use HydratedBloc for automatic persistence
  • Keep translations in the UI layer, emit error codes from BLoCs
  • Use buildWhen to optimize rebuilds
  • Test thoroughly with bloc_test and mocktail

For managing your ARB translation files across multiple languages, FlutterLocalisation provides a visual editor with AI-powered translations that integrates seamlessly with your BLoC-based Flutter app.