React Native conventions
Rules distilled from the code review history of two production apps, where 90 of them are identical in both. That is the filter: what survived in two different products, not what sounded right once.
The complete catalogue, with stable IDs, is in references/rules.md. Read it when a
check fires and you need the reasoning, or before a review, to scan the area you are touching.
0. How to use it
git diff --name-only HEAD | grep -E '\.tsx?$' # or --cached, before committing
Review by area touched, not rule by rule: a change to a list needs §6, one to an animation §7. The areas below carry the rules that are broken most often; the catalogue has the rest.
1. Components: the design system, never the platform primitive
Use the project's shared components — text, button, pressable, icon, image — never the bare React Native ones or a direct icon-library import. They carry theming, accessibility and the press feedback the platform expects, and bypassing one of them re-implements it worse.
expo-image, neverImagefrom react-native.- Ripple/press feedback comes from the shared component: never a manual
active:opacity-*on top of it. - Prefer a shared component's defaults to a local override; an inconsistent override is a bug in disguise.
- One component per file, always. No
native/orscreen/subfolders insidescreens/— flat. - No IIFE
(() => {…})()inside JSX: extract a memoised component. - A
Pressable(or any View-based component) is never a child of<Text>.
2. Performance: memo, callbacks, complete dependencies
- Every exported component is wrapped in
memo()and hasdisplayNameset right after. useCallbackon every function passed to a child or used as an event handler;useMemoon derived computations — but never on a module-level constant.- Dependency arrays are complete. The ones people forget: theme colours,
router, thetof i18n, refs, values read out of a store selector, props. - In a dependency array use the root object, never an optional path (
obj?.field). - Memoise string transformations done in render.
3. Store: selectors and reactivity
- Always use a shallow-comparison selector when reading store properties; prefer the array form for multiple values.
- A new store field is registered in the schema — a field that is not in the schema is not persisted, and nobody notices until a restart.
setState()called outside a component is not reactive and has no action name: use it only in pure functions, never expecting a re-render from it.- Prefer the store to direct key-value storage for system data.
4. Data: React Query
- Query keys contain every parameter the query function reads. A missing one serves another entry's data.
- Never type
useQuerymanually and never rebuild the returned object by hand: both throw away inference. - Invalid parameters →
enabled, never fake data, never nullable request params, never a key that changes shape with validity. - Use the shared key builders; no hand-written key arrays.
- Never write a backend response into the cache without checking its shape, and never dereference a response array in render without a tolerant selector.
- A skeleton gated only on
isLoadingis wrong for a conditionallyenabledquery — gate on!datatoo.
See §9 for the feedback-loop trap, which is the expensive one.
5. Rendering and lifecycle
- Never
setStateor mutate a ref during render — that belongs in an effect. - Never call navigation (
replace/back) during render. - An effect registering a subscription always returns its cleanup.
- A side effect gated on condition X must not sit after an early return on an unrelated condition Y.
- Gate rendering on nullable derived data while it loads; render the error state before the empty state.
- Clamp any index into an array that can shrink at runtime.
6. Lists
- An infinite-scroll list is the primary scroll container — never nested in another scroll view.
- Never a list inside a slot of another list (item, header, footer, empty). Object props passed to a list are stable references.
- Do not enable a query at mount inside a component rendered as a list item: it fires once per visible row.
- On a horizontal list, cells with intrinsic width must switch off the default layout animation — otherwise every recycle animates the width and the chips overlap.
- Horizontal list with paging or snapping: use the project's snapping props preset.
- No
overrideItemLayouttogether with paging.
7. Animation
The one that looks fine and is not (§9 has the worked case): withTiming / withSpring return
immediately and animate on the UI thread. In the instant after assigning one, the shared value still holds
the previous value.
scale.set(withTiming(DOUBLE_TAP_SCALE));
syncZoomState(); // ❌ reads the OLD value — gates and callbacks stay wrong
scale.set(withTiming(DOUBLE_TAP_SCALE));
syncZoomState(true); // ✅ pass the known target explicitly
Only synchronous assignments (sv.value = x, as in a pinch handler) are readable right after. Otherwise
derive from the completion callback, or pass the target you already know.
Also: use .get()/.set() on shared values, never direct .value access or mutation. And a layout animation
(layout/entering/exiting) is a mount cost across the whole surface, not a local flourish — never on
a constant-frame wrapper, never in a dense mount window.
8. Navigation and layout
- Route groups: a section that is not a tab in every app is reached by its own path, never through an explicit tab-group href.
- A cross-cutting HOC (gate, guard, wrapper) is applied once, on the screen definition, never repeated per route file.
- Never an error boundary of your own: use the router's route-level one.
- Never derive per-instance state from a global routing hook in components that stay mounted under tabs — anchor the scope to the route that owns the subtree.
- Structural layout choices use the layout hooks, not CSS breakpoints; breakpoints are for styling. The layout class is measured on the window and reactively, never from a module constant, the device idiom or the aspect ratio.
- Font scaling applies to hero titles only, never to body text or UI chrome.
9. The three that cost the most
A query key parameterised on state that a consumer of that query writes is an oscillator. If an effect
consuming the query writes the parameter, changing it switches to a different cache entry — a snapshot taken
at another time. Two disagreeing snapshots ping-pong synchronously until Maximum update depth exceeded, and
with persistence the loop survives an app restart. The shape: a countries query keyed per locale, consumed by
an effect that writes the locale; removing and re-adding a language on the backend leaves two entries holding
different pictures. Fix: one key without the parameter plus explicit invalidation when it changes (a
sanctioned exception to the key rule, documented in a comment), with a single root-level owner of the
invalidation listener — never per-instance, or N mounted consumers fire N invalidations. Plus a ping-pong
detector on the automatic writer, which must record the context of the decision: an inversion caused by
the user genuinely changing context is legitimate and must not be blocked.
Reading a shared value right after withTiming — §7. The animation still plays, so it looks correct; only
the derived state is wrong.
A literal colour next to a theme token that inverts. Theme tokens swap between light and dark, so
bg-primary with text-white is correct in one theme and unreadable in the other: use the token's
-foreground counterpart. The signal to look for first: a dark: override block that repeats the same
colour classes as the light one is almost always a bug — the two blocks exist precisely to differ.
10. i18n
Keys added in every supported language, a new locale bundle registered in the locales tuple, plural
families always carrying a base key next to the suffixed ones, Spanish ¿/¡ closed, and new account
route headers given their key. Never build a locale-sensitive formatter per call: construct one object per
(locale, options) and reuse it.
11. TypeScript hygiene
- Never
asto assert something already validated or already inferred; if a package's types are missing, fix the package. - No pass-through re-exports or alias types: update the imports at the point of use.
- Barrel files use
export *, never hand-maintained named exports. - Destructure custom props before spreading onto a native component.
12. Logging
No console.*; full request/response at debug, never info; never pass a raw Error to the logger — wrap
it with the project's serializer. The reasoning and the per-stack mechanism live in
padosoft-logging-discipline.
Checklist before committing
- Every exported component
memo()+displayName; handlers inuseCallback - Dependency arrays complete, root objects not optional paths
- Store reads through a shallow selector; new fields registered in the schema
- Query keys complete and built with the shared builders; no manual typing; invalid params via
enabled - No
setState, ref mutation or navigation during render; subscriptions return cleanup - No list inside a list slot; horizontal lists with the right animation and snapping props
- No shared value read right after
withTiming/withSpring - No literal colour beside an inverting token; no
dark:block identical to the light one - i18n keys in every language, base key for plurals
- No
ason something already validated or inferred - Linter and type check clean
Final report
Change: <what> · files: <n>
Areas touched: components | performance | store | query | render | lists | animation | navigation | i18n
Violations: <n> fixed, <n> left with a reason
Sanctioned exceptions: <rule, why, where it is documented>
Linter/types: PASS | FAIL <detail>