DEV Community

Ahmed Ashour
Ahmed Ashour

Posted on

What I learned shipping a fully RTL Arabic app with React Native and Expo

I build BetterMuslim, a free Islamic habit app, with React Native and Expo. It's in six languages, and one of them is Arabic with the whole layout mirrored. About 1 in 4 people who tick a habit in it use it in Arabic, so RTL isn't an extra for me.

RTL in React Native looks like one flag. It isn't. These are the bugs I shipped and how I fixed them.

1. One source of truth: I18nManager.forceRTL

The language is picked in the app, not taken from the phone. Arabic sets I18nManager.forceRTL(true), and that only takes effect after a full reload. So the app reloads itself once when the language changes, and once at startup if the saved native flag doesn't match the app language. A key in storage makes sure it can never restart in a loop:

const syncNativeRTLOnStartup = async (shouldBeRTL: boolean) => {
  if (I18nManager.isRTL === shouldBeRTL) {
    await AsyncStorage.removeItem(RTL_RELOAD_GUARD_KEY);
    return;
  }
  I18nManager.allowRTL(shouldBeRTL);
  I18nManager.forceRTL(shouldBeRTL);

  const attemptedFor = await AsyncStorage.getItem(RTL_RELOAD_GUARD_KEY);
  if (attemptedFor === String(shouldBeRTL)) return; // already tried, no restart loop
  await AsyncStorage.setItem(RTL_RELOAD_GUARD_KEY, String(shouldBeRTL));
  await reloadAppForRTL(); // Updates.reloadAsync() on iOS, react-native-restart on Android
};
Enter fullscreen mode Exit fullscreen mode

2. A config key that undid it on every launch

For a while the Arabic UI on iOS was half mirrored. The cause: with supportsRTL in the Expo config, expo-localization's iOS module rewrote the RTL flag from the device language on every launch. Arabic in the app on an English iPhone got overwritten each time. I removed the key from the config, Info.plist and Android strings. It's a native change, so it needs a new build, an OTA update won't fix it.

3. Stop flipping styles yourself

Once the native mirroring actually worked, every isRTL ? 'right' : 'left' in the code flipped things a second time and put them back on the wrong side.

With forceRTL on, the engine already mirrors flexDirection: 'row', start/end, left/right, alignSelf and textAlign (swapLeftAndRightInRTL is on by default). So now every style is written LTR first: plain row, marginStart, textAlign: 'left' meaning "start". The only places that still read isRTL are the things the engine doesn't mirror: icon transforms, the Arabic font and size, and Arabic numerals.

4. Always set textAlign

With no textAlign, Fabric uses "natural" alignment, and natural follows the phone's language, not your app's. Arabic text was left aligned on English iPhones. The app's base text component now always sets textAlign: 'left' (which is "start" and gets mirrored). For Arabic scripture that must sit on the right in both app languages, I use writingDirection: 'rtl' and no textAlign.

5. Modals don't inherit the direction

On the new architecture a Modal's content lays out as its own LTR island. Everything inside was left to right in Arabic. I wrap Modal in a small component that sets an explicit direction on its content.

6. Physical coordinates must opt out

The Quran reader shows the mushaf as page images: 604 pages in the printed Madani layout people know, with the same line breaks and page ends. To tap and highlight an ayah, an overlay draws rectangles from the page image's own pixel grid.

Under forceRTL, Fabric rewrites every left in the tree to start, which in Arabic is the physical right. Every highlight moved onto the neighbouring ayah. Touches were fine because locationX is physical, so the right ayah was selected and the wrong place was painted. Tests that only checked the selection passed.

The fix is direction: 'ltr' on anything measured against an image, a canvas or a pixel grid (plus a dir="ltr" prop for react-native-web, which reads the prop, not the style). Same for the khatmah ring of 30 beads, which otherwise put juz 1 where juz 30 belongs.

About the page images: React Native's Image only keeps a small memory cache, so every page turn was a new download, even going back one page. Now each page is saved to the documents folder once, 4 pages ahead and 2 behind are fetched while you read, and the whole mushaf can be saved for offline reading (about 75 MB).

7. Arabic typography traps

The missing hamza. I went through the app looking for Arabic spelling mistakes and found أنجزت showing as انجزت and القرآن as القران. The strings were right. The font (Cairo) was being clipped: its baseline sits 0.57em above the bottom of the line, so with a line height of about 1.3em only 0.73em is left above it, and the hamza on أ reaches 0.96em. ؤ and إ survived, which was the tell. Arabic text now gets a line height of at least 1.6 times the font size. Before you fix Arabic "spelling", compare the source string with the screen and render it with hb-view.

Letter spacing. Negative letterSpacing breaks Arabic shaping on Android. Words stacked one per line. Arabic gets letterSpacing: 0.

Numbers inside Arabic lines. React Native on iOS ignores bidi isolate characters. An ayah reference like 37:100-107 in an Arabic line came out as ١٠٧-٣٧:١٠٠. Arabic references are now written as surah name and range: سورة الصافات ١٠٠-١٠٧.

Quran text and the font. The Amiri Quran font is drawn for the Tanzil Uthmani text. Text from another source used a different character for the small yeh, and إبراهيم came out wrong. Ayahs are now copied by verse key from Tanzil Uthmani, and every quoted ayah gets rendered and checked before it ships.

Plurals. Arabic day counts need different words (يومين، أيام، يومًا). Pick the plural from the number before you format it into Arabic digits, not after.

8. Smaller ones

  • Never put scaleX: -1 directly on an SVG icon (lucide). On Android under RTL it disappears, because the SVG root scales around its top left corner and the glyph leaves its box. Put the transform on a wrapping View.
  • Padding belongs on a ScrollView's contentContainerStyle, not its style. Under RTL, padding on style rendered the content off centre and clipped at the screen edge.

9. You can't test RTL in a browser

react-native-web hard-codes I18nManager.isRTL to false, and its forceRTL() does nothing. Two of the bugs above passed a full Playwright suite and shipped. Anything that depends on direction needs a device in Arabic. On the iOS simulator, switching to RTL needs a cold restart.

If you want to see how it ended up, the app is BetterMuslim, and the site has an Arabic version too. If you've hit an RTL bug that isn't here, I'd like to hear it.

Top comments (0)