Changing a locale preference is easy. Making every locale-dependent value update without losing navigation, form input, loaded records, or pending work is harder. The common failure is a mixed-language screen: resource-backed labels refresh, but cached headings, formatted dates, validation errors, and accessibility descriptions remain in the previous language. Restarting the process may hide the bug, but it also masks the missing dependency and disrupts the user. To change app language without restart, treat the effective locale as an input to presentation state, invalidate only locale-derived values, and prove that every long-lived state holder reacts to it.
Why a correct preference still produces stale text
A language switch crosses more than the string-resource layer. It changes formatting rules, layout direction, plural selection, accessibility copy, and any message assembled from localized templates. Android explicitly classifies locale as a runtime configuration change in its guide to handling configuration changes. A screen can therefore receive a new resource configuration while another object survives and keeps values produced under the old one.
Consider a screen with a ViewModel that stores this state:
RepositoryScreenState
title: String
lastUpdated: String
retryMessage: String?
repositories: List<Repository>
selectedSort: SortOrder
Only the last two fields are language-neutral. The first three depend on the effective locale. If the ViewModel survives activity recreation, the framework can rebuild the view while the stored title, lastUpdated, and retryMessage remain unchanged. Reading a new string resource in one composable fixes only one label. It does not repair derived state elsewhere.
Two issue reports show how the defect appears in working apps. A GitHubApp engineering issue describes mapper caches and surviving ViewModels exposing text from the previous locale. Its inventory includes visible labels, formatted text, errors, and accessibility descriptions. A Mergin Maps report reproduces an incorrect language after switching back to English on both Android and iOS. These are practitioner reports, not framework guarantees. In both reports, locale-sensitive output outlives the locale that created it.
Reloading everything causes a different problem. A locale change normally does not invalidate repository records, account data, navigation IDs, or a user's unsaved input. Fetching them again adds latency and failure paths without fixing the dependency model.
Define one effective locale contract
Before wiring refresh behavior, define what language the app should render. A useful precedence rule is:
- A validated in-app or platform per-app choice.
- A supported match from the operating system language list.
- The app's declared default locale.
Keep the result as a canonical language tag, such as fr-CA, not a translated display name. The selection layer owns this value. Individual screens should not independently inspect device settings or invent fallback rules.
On Android, use the platform or AndroidX application-locale boundary described in the per-app language preferences guide. That keeps the app's language picker and system settings from becoming competing sources of truth. A cross-platform framework still needs the same contract. Flutter's internationalization documentation explains locale overrides, supported locales, resource loading, and the Localizations widget. In either framework, one resolved locale must drive every localized presentation dependency.
Represent the contract as both a value and a revision. The revision changes whenever the effective locale changes, even if the underlying business data does not.
LocaleSnapshot
tag: "fr-CA"
revision: 17
source: USER_CHOICE
The revision is not a translation-file version. It is a process-local invalidation signal. It gives state pipelines a simple reason to recompute presentation data after a language change.
Separate domain state from localized presentation
Do not store translated sentences as durable domain data. Store stable facts and resolve language-dependent output near the presentation boundary.
Bad durable state looks like this:
PaymentFailure
message: "Your card was declined"
Better state keeps a stable reason and typed arguments:
PaymentFailure
reason: CARD_DECLINED
issuerName: "Example Bank"
A mapper can combine that state with the current locale snapshot to produce the visible message. The same rule applies to dates, quantities, section names, empty-state copy, and accessibility descriptions.
The dependency graph should be explicit:
Domain state + locale snapshot
-> presentation mapper
-> localized screen state
-> rendered UI
When the locale revision changes, the mapper runs again. It reuses domain state, so the app does not repeat a network request or discard a form. It replaces only the derived presentation values.
This model makes cache errors easier to spot. A cache containing resolved strings must either include the locale tag in its key or be invalidated when the locale revision changes. A process-wide staticData object containing translated labels is wrong unless it has a locale-aware lifecycle. Immutable IDs and raw records can remain cached.
Implement a locale change transaction
Treat a user-confirmed language change as a short transaction rather than a settings write followed by hope.
Validate and persist the choice
Reject unsupported or malformed tags before changing UI state. Persist the canonical selection through the platform-supported app-locale API where available. Do not maintain a second preference unless it has a defined migration and synchronization role.
Publish the new snapshot
After persistence succeeds, publish a locale snapshot through observable state. Every presentation pipeline that resolves strings or formats values must depend on it. The signal should fire even when business data, search queries, loading state, and navigation have not changed.
In reactive pseudocode:
combine(domainState, localeSnapshots) { data, locale ->
screenMapper.map(data, locale)
}
Do not pass a platform Context, resource bundle, or configuration object into domain modules. Put platform observation in an adapter and expose a small locale abstraction. This keeps the dependency testable and prevents old configuration objects from leaking into long-lived state.
Recompute locale-derived output
Rebuild translated labels, formatted values, plural messages, relative times, validation copy, content descriptions, hints, and queued one-shot messages that have not yet been shown. Preserve language-neutral state such as entity IDs, loaded records, scroll anchors, selected filters, route arguments, and draft input.
One-shot events need a deliberate rule. If an error has already been displayed, do not show it again merely because the locale changed. If it is still pending, store a stable error code and resolve the message at display time. Storing a finished sentence in an event queue makes it stale before the view consumes it.
Let the framework redraw normally
On Android, activity recreation is a valid default response to a configuration change, but it is not proof that all state refreshed. The Android configuration guide warns that retained state can survive recreation and shows that Compose must read configuration-backed state for locale-sensitive output to recompose. If an app opts out of locale recreation, it takes direct responsibility for updating every affected resource and layout dependency.
In Flutter, the application locale and localized-resource delegates drive rebuilding below the localization boundary. A service or state object that cached finished strings still sits outside that automatic mechanism. Replace those strings from the current localization context or make the locale revision part of the state transformation.
Cover the values teams usually miss
Visible button labels are the easiest part. Audit every value produced from language, region, or writing direction:
- Date, time, number, currency, and measurement formatting.
- Plural and selection messages with runtime arguments.
- Validation, permission rationale, and offline error copy.
- Search placeholders, sort labels, and empty states.
- Accessibility labels, hints, values, and announcements.
- Cached section headings and mapper-owned static data.
- Dialogs, snackbars, toasts, and pending action errors.
- Images or illustrations with embedded text.
- Right-to-left layout direction and directional icons.
- Remote content and map labels whose cache keys include locale.
Do not refresh these through one giant global callback. Make the locale dependency visible where each presentation value is built. Global callbacks become order-dependent and are difficult to test. A state graph with a locale input is deterministic.
Test repeated switches, not one happy path
A launch test proves only initial resolution. The failure appears when objects survive. Build a matrix that keeps the same screen and state holder alive while the language changes.
Start with these transitions:
- Default language to a second language.
- Second language back to the default.
- Second language to a third language.
- Repeated rapid changes before a screen is dismissed.
- System per-app language change while the app is backgrounded.
- Process restart after the new selection is persisted.
Run each transition in loaded, loading, empty, validation-error, and network-error states. Include a form with unsaved input and a scrolled list with loaded data. The expected result is translated presentation with preserved neutral state.
Unit tests should hold the same ViewModel or presenter instance, emit a new locale snapshot, and assert that localized fields change while record IDs, query text, sort choice, and navigation data remain equal. Add formatter fixtures so dates and quantities prove that more than resource labels refreshed. Assert accessibility output separately.
An integration test should verify the platform boundary: choose a language, leave and return to the app, inspect the platform per-app language setting where supported, then switch back to the default. The Mergin Maps report is a useful regression shape because the failure appeared specifically on the return to English rather than on the first switch.
Avoid restart-based fixes
Killing or relaunching the app can be an emergency workaround for a framework limitation, but it should not be the default architecture. A forced restart can drop navigation context, interrupt uploads, clear transient errors, and lose unsaved form state. It also leaves tests unable to distinguish correct invalidation from process initialization.
If a full restart is genuinely required for one subsystem, isolate that constraint. Save restorable state, explain the restart before applying it, and verify recovery. Do not use restart as a blanket substitute for locating stale localized values.
Also avoid attaching locale to every domain entity. Locale belongs in the presentation dependency graph unless the underlying content itself has language-specific variants. Mixing the two makes ordinary business records appear stale after every language change.
Verification checklist
Before release, verify all of the following:
- One canonical effective locale controls the app.
- Locale changes emit an observable revision.
- Presentation mappers depend on that revision.
- Localized string caches are removed, keyed by locale, or invalidated.
- Business data does not reload solely because language changed.
- Navigation, input, selection, and scroll state survive.
- Formatting, errors, dialogs, and accessibility copy refresh.
- Default to second language and back to default both pass.
- Background, recreation, and process-restart paths agree.
- Widget, map, remote-content, and notification surfaces have separate tests where applicable.
Inventory every finished string and formatted value stored outside the view tree. Mark the locale that produced each value, then add the effective locale revision to its mapper input. Write the repeated language-switch regression test before removing any restart workaround.
References
- Android per-app language preferences supports the platform and AndroidX locale-selection boundary.
- Android configuration changes supports the runtime locale lifecycle, recreation behavior, and configuration-backed Compose updates.
- Flutter internationalization supports locale overrides, supported locales, localization resources, and the localization widget lifecycle.
- GitHubApp runtime locale issue supplies a practitioner report about mapper caches, surviving ViewModels, accessibility text, and stale errors.
- Mergin Maps language-switch issue supplies a practitioner report about an incorrect language after switching back to English on Android and iOS.
