Every component and hook in the app follows one set of React rules: function components with read-only props, a thin route over a pure view and one model hook, effects only for systems outside React, React Compiler instead of hand-written memoization, pressables only in the UI layer, virtualized lists with stable keys, layouts that survive any window and both platforms' system bars, and error boundaries with one way out. A zero-dependency checker proves the rules lint cannot see, on iOS and Android code alike.
Rules that must hold
- Function components, one per file, with read-only props. A named export,
type XProps = { readonly … }withreadonly T[]for arrays, callbacksonX, local handlershandleX. The only class is an error boundary. Why: React Compiler optimizes functions only, and lint does not seeprops.items.push(); a read-only type turns it into atscerror. (class-component,props-readonly) - A screen is a thin route, a pure view and one model hook. The route file renders
const model = useTasksModel(); return <TasksScreen model={model} />; the view renders its props;use-<feature>-model.tsis the only code that reads stores, services, server state, the clock and navigation; rules and derived values are pure functions beside it. Why: each piece is tested alone, and architecture-and-boundaries checks the route (route-thin). - Effects only synchronise with a system outside React, and clean up. Four kinds: subscribe to an
OS or native event (return the unsubscribe), push React state into a native API, run work while a
screen is focused, measure. Everything else is computed during render or done in the handler that
caused it; no state setter runs synchronously in an effect body; a callback the effect must not
re-subscribe for goes through
useEffectEvent; an OS value the UI reads comes fromuseSyncExternalStore(with a server snapshot, or static web rendering throws) or an OS-mirror store, neveruseStateplus an effect. Why: a leaked listener keeps firing after unmount and after Fast Refresh, and copying state in effects renders twice and drifts. (effect-cleanup) - React Compiler memoizes: no
useMemo,useCallbackormemo. A measured exception carries// memo-exemption: <measurement>and a reviewed lint exemption;'use no memo'appears only with// use-no-memo-test: <title>naming the failing test that shows the compiler bug; Reanimated shared values use.get()and.set(), never.value. The rules apply while the app config setsexperiments.reactCompiler: true(the Expo template default). Why: the compiler silently skips any function that breaks a Rule of React, and a stray memo hides that the compiler is doing the work. (no-memo-apis,use-no-memo,shared-value-get-set) - Render is pure. No
Date.now(),new Date(),Math.random()orperformance.now()in a component, no ref read, no component declared inside another, no state set during render. Time and randomness come from ports in the model hook. Why: the compiler caches renders on the assumption that the same inputs give the same output. (render-impure) - Every store read passes a selector; a built value goes through
useShallow. The rule is state-management-zustand's, and its checks enforce it (check-stores[selector-missing], [selector-new-object]; lintstoreWithoutSelector); components and model hooks follow it. Why: a bareuseTasksStore()re-renders on every change, and in Zustand 5 a fresh array or object per call loops forever ("Maximum update depth exceeded"). - A hook lives in its own hook file.
use-<name>.tsexportsuseName(a store file exports itsuseXStore, a context file itsuseX), takes at most three parameters, returns one value or an object with named fields, and never JSX. Why: the hooks and compiler lint find hooks by theuseprefix, and readers find them by file name. (hook-file) - Pressables live in
src/uiand take a requiredtestID. Screens compose the shared buttons and rows, which own the role, translated label, state and 44 pt box (accessibility-as-code); a reusable component derives its part ids from its one testID (${testID}.label); pressed feedback isstyle={(state) => [styles.base, state.pressed && styles.pressed]}. Why: accessibility and touch size are solved once, and E2E flows select by testID on both platforms. (banned-import,testid-required,pressed-destructure) - Lists that grow are a
FlatListwith stable keys.keyExtractorreturns the item's id; FlashList only when profiling shows the need; aScrollViewthat maps data only for a short list that can never grow, with// bounded-list: <bound>above it; never key by the array index, except a positional series built withArray.from({ length }). Why: an index key remounts the wrong rows when the list is filtered, and an unbounded ScrollView mounts every row at once. (list-key-index,scrollview-list) - Layout survives any window and both platforms' system bars. Never
Dimensions: size fromuseWindowDimensions(),useWindowClass()oronLayout, with layout maths in pure functions tested at the window table's sizes and atfontScale2. Every screen sits inScreenFrame(react-native-safe-area-context, physical edges) and names what sits around it through the requirededges:ALL_EDGES,BELOW_HEADER_EDGES(a header above),ABOVE_TAB_BAR_EDGES(a tab bar below),BETWEEN_HEADER_AND_TAB_BAR_EDGESorBELOW_HEADER_SCROLLING_EDGES(a header above, content padding the bottom inset as it scrolls under the home indicator). One exception: a list-detail screen renders adaptive-layout'sListDetailLayoutin react-native-safe-area-context'sSafeAreaViewwith one of these edge sets, because ScreenFrame's column is narrower than two panes. Insets never become start/end keys; style keys are logical; direction is never read fromisRTLoutside the direction module. Why: iPads, foldables and Android multi-window resize the app, Android draws edge to edge, a navigator header and the tab bar pad their own inset (an edge the screen shares with one is added twice), and the notch does not mirror in RTL. (banned-import,inset-direction,no-isrtl) - Text goes through the app's text primitive.
Textfrom react-native is imported only byAppText(design-system-structure owns it). Why: direction, font, the 200 % cap and the header role live in one place. (banned-import) - A failing part fails alone and offers one way out. A route fails into Expo Router's
ErrorBoundaryexport (error-handling-typed-results); a part of a screen that can fail on its own (a canvas, a chart, a WebView) sits inSubtreeErrorBoundary; a fallback never retries by itself; errors no boundary sees (handlers, promises, worklets) are caught where they happen, and an async handler startstask().catch(reportError). Why: an automatic retry of a render error that repeats is a crash loop.
Workflow
- Read references/rules-explained.md once per session before writing React code: every rule with its reason and what enforces it (lint, this checker, a test, or another skill's checker).
- First time in an app. Copy
templates/src/hooks/tosrc/hooks/andtemplates/src/ui/tosrc/ui/, each file with its test.screen-frame.tsximports design-system-structure'ssrc/theme/(makeStyles, tokens); the tests use unit-and-component-tests'@test/render-with-providers. Every template's first line names its repo path; files inexamples/are shapes, never copied. - A screen. Follow "A screen and its model hook" in
references/patterns-and-recipes.md: the route file, the pure
view in
src/features/<feature>/<feature>-screen.tsx, the model hook with itsrenderHooktest, pure logic beside it. Put the view inScreenFramewith the edges the "Safe areas" table gives for what sits above it (a header?) and below it (a tab bar?). Split a model hook that passes 40 lines intouse-<feature>-actions.tsand a pure<feature>-model-of.ts. - A component. Imitate examples/labelled-value.tsx for a
presentational
src/ui/component and examples/hide-for-premium.tsx for one that reads one store value; check the Pressable table in the patterns reference. - A hook or an effect. Imitate examples/use-step-pager.ts (state and
derived values, no effect),
templates/src/hooks/use-is-app-active.ts(an OS value throughuseSyncExternalStore) or ui-component-library'suseHoldToConfirm(a Reanimated timer, described under "Hooks without effects"). Before writinguseEffect, find the effect's kind in the four-kinds table; if it has none, compute in render or move the work into the handler. - A list. Read "Lists" in the patterns reference: a
FlatListwithkeyExtractor, FlashList only after a measurement, aScrollViewonly for a bounded list with its comment. - Tests first. RNTL 14 through
renderWithProviders/renderHookWithProviders(await them), or// no-providers: <why>for a unit that reads no context; query by role and name; give a hot store-reading component a<Profiler>re-render test like examples/hide-for-premium.test.tsx. Recipes are in the patterns reference. - Check. Run
npm run typecheck,npx eslint <changed paths> --max-warnings 0and the Jest tests, then from the repo rootnode ${CLAUDE_SKILL_DIR}/scripts/check-react-rules.mjs .. Fix everyFAILline (file:line, rule, fix) and rerun until it printsRESULT: PASS. For a monorepo, a moved source folder or a renamed text primitive, read "Checker configuration" in the patterns reference.
Definition of done
- Every new component is a function with a named export, read-only props (
readonly T[]arrays), translated strings only, a requiredtestIDwhen it is an interactivesrc/ui/component, and a test that queries it by role and name. - Every screen is a thin route, a pure view in
ScreenFramewhoseedgesmatch its header and tab bar (the "Safe areas" table in the patterns reference), and one model hook that alone reads stores, services and navigation, with arenderHooktest. - Every new hook sits in its
use-<name>.tswith a test; every effect is one of the four kinds and returns its cleanup when it subscribes or starts a timer. - No
useMemo,useCallback,memo,.value,Dimensions, index key or raw pressable outsidesrc/uiwas added; any'use no memo'names its failing test. - Every store read passes a selector and wraps a built value in
useShallow(state-management-zustand'scheck-storesand lintstoreWithoutSelectorprove it). - Lists that grow are
FlatLists keyed by id; each remaining mappedScrollViewstates its bound. - Layout functions are tested at the window table's sizes and at
fontScale2. - Hot store-reading components have a
<Profiler>re-render test. -
npm run typecheck, ESLint--max-warnings 0and Jest pass. -
node ${CLAUDE_SKILL_DIR}/scripts/check-react-rules.mjs .printsRESULT: PASS(it prints no SKIP lines; in an app whose config leaves React Compiler off it prints onenote:line and does not apply the two memo rules).
Anti-patterns
- Copying a prop into state with an effect. Compute it during render;
react-hooks/set-state-in-effectis right. Reset state on an identity change with akeyon the component instead. useCallback"to be safe". The compiler already memoizes, and the checker fails the import.useTasksStore((state) => state.tasks.filter(isOpen)). A fresh array each call loops in Zustand 5: wrap it inuseShallow(state-management-zustand) or select primitives.- A
PressableorTouchableOpacityin a screen, oronPresson aView. Compose thesrc/uiprimitives so role, label, state and size are right once. items.map((item, index) => <Row key={index} />)orkeyExtractor={(_, i) => String(i)}. Key by the item's id; the index moves when the list is filtered or sorted.- A
ScrollViewover the user's tasks. It mounts every row; use aFlatList, and FlashList when a profile shows the FlatList is the bottleneck. Dimensions.get('window')at module level or in a render. Windows resize; useuseWindowClass().paddingStart: insets.left. The camera cutout stays on the physical left in RTL; take both sides withpaddingInline: Math.max(gutter, insets.left, insets.right), or useSafeAreaViewedges (paddingLeftis swapped to the start side in RTL too).ScreenFramewith every edge under a header. The provider's top inset is added under the header again; passBELOW_HEADER_EDGES.ScreenFramewith the bottom edge on a tab screen. The tab bar already pads the home indicator or the Android navigation bar, so an empty band (34 pt on a home-indicator iPhone) sits above it; passABOVE_TAB_BAR_EDGES, orBETWEEN_HEADER_AND_TAB_BAR_EDGESwhen the tab shows a header.- An effect that adds a
BackHandler,AppStateorKeyboardlistener and returns nothing. Returnsubscription.remove(); on Android a leaked back handler swallows every later back press. - An error fallback that retries in an effect. It becomes a crash loop; offer one button.
Files in this skill
| File | What it is | Read/run when |
|---|---|---|
| references/rules-explained.md | The full rule catalogue (components, hooks and effects, React Compiler, stores, styles, text, RTL, pressables, lists, layout, motion, errors), each with its reason and enforcer, and the rules owned by other skills | Workflow step 1 |
| references/patterns-and-recipes.md | Where files go, a screen and its model hook, hooks without effects, the four effect kinds, store reads, styles, the Pressable table, lists and the measurement method, the window table, safe areas on iOS and Android, error boundaries, testing recipes, the React Navigation appendix, When multi-package, checker configuration, verified facts | Workflow steps 3-8 |
| examples/labelled-value.tsx | Example (shape only): a presentational src/ui/ component with derived part testIDs and a slot |
Workflow step 4 |
| examples/labelled-value.test.tsx | Example: its test | Workflow step 4 |
| examples/hide-for-premium.tsx | Example (shape only): a component that reads one store value with a primitive selector | Workflow step 4 |
| examples/hide-for-premium.test.tsx | Example: the <Profiler> re-render guard and store-driven tests |
Workflow steps 4 and 7 |
| examples/use-step-pager.ts | Example (shape only): a hook with state and derived values, no effect | Workflow step 5 |
| examples/use-step-pager.test.ts | Example: its renderHook tests |
Workflow step 5 |
templates/src/hooks/ |
Canonical shared hooks, owned here and synced from the library (edit the canonical copy, never this one): use-is-app-active.ts (foreground or not through useSyncExternalStore, with a server snapshot for static web rendering; iOS inactive, Android background) and use-window-class.ts (classifyWindow(), useWindowClass(): compact or regular at 600, landscape, large text at fontScale 1.25), each with its test |
Workflow steps 2, 3 and 5; copy to src/hooks/ |
templates/src/ui/ |
screen-frame.tsx (ScreenFrame with a required edges, and the four edge sets for a header above and a tab bar below), subtree-error-boundary.tsx (the one class, for a part of a screen that fails alone), each with its test (the press-and-hold hook use-hold-to-confirm.ts is ui-component-library's) |
Workflow steps 2, 3 and 5; copy to src/ui/ |
scripts/check-react-rules.mjs |
The checker for the rules lint does not fully cover (store reads are state-management-zustand's) | Workflow step 8, and at the end |
scripts/selftest.mjs |
Proves the checker passes the good fixture and catches each planted bug | After changing the checker |
scripts/check-lib.mjs |
Shared script helper (CLI, reporting, repo walk, layout, self-test), synced from the library (do not edit here) | Never by hand |
scripts/lib/object-literal.mjs |
Bracket-matching helper the checker uses, synced from the library (do not edit here) | Never by hand |
scripts/lib/source-scan.mjs |
Source lexer helpers (maskCode blanks comments and string contents), synced from the library (do not edit here) |
Never by hand |
assets/shared.json |
Declares the shared files this skill copies in | When adding a shared file |
tests/bases/app/ |
The small Tasks app the fixtures start from (the templates are laid over it) | When adding a rule to the checker |
tests/fixtures/ |
The overlay cases: good, planted bugs, passing variants (compiler off, conditional subscriptions, bounded scroll lists, whole-repo scan, monorepo) and bad input | When adding a rule to the checker |
Related skills
architecture-and-boundaries- where files go, thin routes,src/uistays presentational.naming-conventions- file, export, hook, handler and testID names.typescript-and-lint-rules- the ESLint config (react-hooks rules), size and complexity limits.design-system-structure- theme,makeStyles,AppText,PressableSurface, motion tokens.ui-component-library- the shared buttons, rows and dialogs built on these rules, and the press-and-hold hookuseHoldToConfirm.accessibility-as-code- roles, names, 44 pt targets,hitSlop, reduce motion, text scaling.state-management-zustand- stores, reducers, selectors anduseShallow; owns and checks the store-read rule.error-handling-typed-results- the routeErrorBoundary, the error net,Result.async-safety- async work in effects: abort per run, latest wins, handles released.unit-and-component-tests-renderWithProviders, Jest projects, RNTL conventions.navigation-and-routing- Expo Router layouts, tabs, params, back handling and predictive back.adaptive-layoutandperformance-budgets- two-pane layouts, list and re-render budgets.