The screenshot looked fine. On the device, it wasn’t.
A block of Hebrew had rendered with the words running backwards: right-aligned correctly, and completely unreadable. It got through review because in the diff it was one harmless-looking style line, textAlign: 'right'. Nothing about that line looks wrong unless you know what it does on iOS. After hitting that class of bug a few too many times, I stopped trying to remember the rules and taught the linter to remember them for me — then packaged them so you don’t have to.
Why the linters you already have don’t catch this
The usual suspects don’t cover it. eslint-plugin-react-native has rules for inline styles, colour literals, unused styles, but nothing about text direction. The i18n plugins (eslint-plugin-i18next, @sanity/eslint-plugin-i18n) solve a different problem: they flag hard-coded strings that should be translated. None of them look at whether your layout survives a flip to RTL.
So the bugs sail through. A type-checker won’t flag them and a unit test rarely covers them. Each style object is valid on its own; together they’re wrong.
The five rules
Each came from a bug that actually shipped, so they’re gotchas rather than opinions. Code is React Native; the iOS notes are where most of the pain lives.
1. textAlign: 'right' needs writingDirection: 'rtl'
This is the one from the opening. The React Native docs list textAlign‘s default as auto, which maps to NSTextAlignment.natural on iOS. Internally they set two independent paragraph attributes: alignment, and base writing direction. Only the base direction decides the order of mixed Hebrew/English runs, so textAlign: 'right' on its own flushes the block right while leaving the words in LTR order. You need both:
// wrong — flush right, word order broken on iOS
const s = { textAlign: 'right' };
// right
const s = { textAlign: 'right', writingDirection: 'rtl' };
It’s mechanical to detect and to fix, so this rule autofixes: run ESLint with --fix and it inserts the missing writingDirection. (writingDirection is an iOS text prop; Android ignores it.)
2. Don’t hard-code direction: 'rtl'
direction is a real React Native style, but on native it’s unreliable and behaves differently across iOS, Android and web — sometimes silently ignored. Reach for flexDirection: 'row-reverse' on content, or lock the geometry LTR and let locale-aware children handle the order on navigation surfaces.
const s = { direction: 'rtl' }; // flagged
const s = { flexDirection: 'row-reverse' }; // fine
3. I18nManager.forceRTL() needs a native partner
This one cost me an afternoon, and I spent most of it blaming the wrong thing. I called I18nManager.forceRTL(true), reloaded, and most of the app flipped — but a strip of native chrome stayed left-to-right. I was sure it was a stale style cache: cleared the bundler, rebuilt, re-ran. Same result, and I18nManager.isRTL was reporting true the whole time.
In short: React Native flips the views it controls, but the system-wide default it doesn’t touch stays LTR. The detail is that forceRTL isn’t a live switch. It writes a flag that only takes effect on the next app start, and isRTL is read once at launch, so it read true because I’d already reloaded, not because everything had flipped. React Native force-sets semanticContentAttribute on the views it owns; the UIView.appearance() proxy it doesn’t touch stays at the default .unspecified, which just means “follow the app’s layout direction.” That direction was never actually changed, so it resolved to LTR, and the chrome stayed LTR until I set the proxy myself.
So the rule flags a forceRTL call with no semanticContentAttribute reference in the same file. If your native setup lives elsewhere, allowlist that file:
'rtlint/force-rtl-needs-semantic-pair': ['error', {
allowedFiles: ['src/native/rtl-setup.ts'],
}],
4. flexDirection: 'row-reverse' needs a reason
This one is openly opinionated, so it’s a warn, not an error. row-reverse is often reached for by reflex when reversing the data array plus justifyContent: 'flex-end' would keep the logical order intact and read better. The rule doesn’t ban it; it just makes you write down why you chose it.
const s = {
// reason: chronological feed shows newest leading
flexDirection: 'row-reverse',
};
If you disagree with the house style, leave it off. I keep it on because asking people to explain the reverse has caught more accidental mirrors than deliberate ones.
5. Don’t cross-wire locale and region
Language, timezone and region are independent. Someone reading your app in an RTL language might live anywhere, so setting a timezone or region inside a branch keyed on isRTL or locale === '<rtl>' couples things that should stay separate. I once shipped a build where reading in Hebrew quietly forced an Israeli timezone — wrong for every Hebrew reader outside the country.
if (isRTL) { setLocale('en'); } // flagged: cross-wiring
if (locale === 'he') { setUiCopy(rtlCopy); } // fine: UI only
Both lists are configurable: your forbidden tokens, your set of RTL locales. Because that’s project-specific, this rule ships off by default.
What lint can and can’t do here
Worth being honest about the ceiling. These rules catch shape: a missing property, an unpaired call, a style that’s unreliable. They won’t tell you whether the screen feels right in Arabic, whether an icon that should mirror actually does, or whether your date format makes sense in the locale. RTL correctness is still something you verify on a device, ideally with someone who reads the language.
What the rules buy you is a smaller surface to verify. They move the iOS writing-direction gotcha out of one person’s head and into the repo, so “did anyone remember that thing” becomes a red squiggle in the editor instead of a tribal-knowledge question.
Use it
I packaged the rules as eslint-plugin-rtlint:
npm install --save-dev eslint-plugin-rtlint
// eslint.config.js
import rtlint from 'eslint-plugin-rtlint';
export default [
rtlint.configs.recommended,
];
recommended turns on the three correctness rules as errors and the row-reverse nudge as a warning; the cross-wiring rule is opt-in. It’s MIT, has zero runtime dependencies, and is a few hundred lines you can read in one sitting.
The habit underneath it is worth stealing even if you never touch RTL. The third time you relearn the same gotcha, that’s the signal: stop relearning it and spend the twenty minutes turning it into a lint rule. It’s cheaper than the fourth time.
Further reading
eslint-plugin-react-native— the standard RN plugin (no RTL rules, hence this one).eslint-plugin-i18next— catches untranslated strings, a different RTL-adjacent problem.- React Native
I18nManager— theforceRTL/isRTLsurface behind rule 3 (note: changes take effect on next app start). eslint-plugin-rtlinton npm — install it, plus a rule-by-rule README.
Related reading on this blog: Guardrails over trust — the bigger idea these five rules are an instance of: encode the check instead of trusting code review to catch the bug.
Comments & Reactions
Got a thought, a war story, or a “well, actually”? Sign in with GitHub and jump in.
Loading comments…