# React and React Native

Applies when: writing or reviewing React 18/19 with TypeScript, on web or React Native. The framework
is rarely the problem. The state model is.

**Measure before you optimise.** A `useMemo` added on a hunch is a permanent maintenance cost paid for
an imaginary win. If you cannot name the component that re-rendered and say why, you are guessing.

## Server state is not client state

Most React bugs are one mistake wearing different clothes: data owned by a server was copied into
`useState` and is now a second source of truth that begins rotting immediately. Server state is **owned
elsewhere, shared between clients, stale the moment you read it** — it needs caching, revalidation,
deduplication, retry. Client state is **owned by this session** — is the drawer open, what has the user
typed. It needs none of that. Conflating them produces the familiar symptoms: stale lists after a
mutation, duplicate in-flight requests, spinners that never clear, two components disagreeing about
one record.

```tsx
// Wrong — no cancellation, so a fast `status` change resolves out of order and
// renders the wrong list. Refetches every mount. Unshareable. No retry.
const [orders, setOrders] = useState<Order[]>([]);
useEffect(() => {
  fetch(`/api/orders?status=${status}`).then(r => r.json()).then(setOrders);
}, [status]);

// Right — a server-state cache owns it
const { data: orders, isPending } = useQuery({
  queryKey: ['orders', status],
  queryFn: ({ signal }) => fetchOrders(status, signal),
});
```

TanStack Query, SWR, or the framework's own loader/RSC layer. Not convenience — those libraries handle
the four things the handwritten version got wrong.

### `useEffect` is not a data fetcher

`useEffect` synchronises with something **outside React**: a subscription, an event listener, an
imperative widget, a timer. Fetching is a side effect of rendering a route, not of a component
appearing. Preference order: framework loader or server component → server-state library → hand-rolled
effect with `AbortController` and an out-of-order guard. The tell that an effect is wrong: it exists to
copy one piece of state into another.

### Derived state that should never have been state

```tsx
// Wrong — renders once with a stale total, then again correctly, and breaks the
// day someone updates items down a path that skips the effect.
const [total, setTotal] = useState(0);
useEffect(() => { setTotal(items.reduce((s, i) => s + i.price, 0)); }, [items]);

// Right
const total = items.reduce((s, i) => s + i.price, 0);
```

Calculate during render. If profiling proves the calculation expensive — and `reduce` over a few hundred
items is not — wrap it in `useMemo` **then**, with a note on what you measured. Same for props:
`useState(props.value)` only ever means "initial value".

### When a global store is genuinely needed

Justified when state is **client-owned, needed by distant components, and changes often enough that
lifting it causes real re-render problems**: undo/redo stacks, editor selection, canvas viewport.
Cargo-culted when used for server data (the query cache is already a global store with better
semantics), for anything one subtree needs, or for form state. Prop drilling three levels is not worth
a dependency; prop drilling seven levels is a component boundary problem, and a store hides it rather
than fixing it. Context is not a state manager either — every consumer re-renders on every value
change, so split context by update frequency (stable `dispatch` apart from volatile value) or you have
built a global re-render broadcaster.

## Security

### `dangerouslySetInnerHTML`

React escapes by default; this opts out. The name is the documentation.

```tsx
<div dangerouslySetInnerHTML={{ __html: post.body }} />                     // stored XSS
<div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(post.body) }} /> // right
```

Better still: sanitise on write and store safe HTML, or render Markdown to a constrained node tree.
Also injection sinks: `href` accepting `javascript:`, prop-spreading into a DOM element, any
`eval`-adjacent template evaluation. React Native has no `innerHTML`, but `WebView` is the same hole —
never inject untrusted content into `injectedJavaScript` or `source.html`. Set a Content-Security-Policy:
it does not replace sanitisation, it limits what a miss costs.

### Auth tokens in `localStorage` — the honest trade-off

The dogma is "localStorage is insecure, use httpOnly cookies". The accurate version:

- `localStorage` is readable by **any JavaScript on the origin**, including a compromised npm dependency
  or an injected analytics script. A token there is exfiltrable in one line.
- `httpOnly` cookies remove that exfiltration path. They do **not** make you XSS-proof — an attacker with
  script execution still makes authenticated requests as the user from the page. They also require CSRF
  defence (`SameSite`, plus a token for cross-site flows) that `localStorage` does not.

So: `httpOnly` + `Secure` + `SameSite` cookies, short-lived access tokens, server-side revocation — that
narrows the blast radius from "attacker keeps the token forever" to "attacker acts while the page is
open". A meaningful reduction, not immunity. Where cookies genuinely do not fit (a native app, a
cross-domain API you do not control), in-memory storage plus a refresh token in secure device storage is
next best; in React Native, `AsyncStorage` is unencrypted plaintext on disk, so use Keychain/Keystore.
Short token lifetimes and working revocation buy more real safety than the storage argument does.

### Client-side authorization is UX, not security

```tsx
{user.role === 'admin' && <DeleteButton />}
```

That hides a button. The endpoint must authorize independently, every time, because the client is a
suggestion the user can edit. The follow-on mistake is shipping data the user cannot see and hiding it
in the component — if it reached the bundle or the JSON payload, it is disclosed. Filter server-side.

### Secrets in the bundle

Anything prefixed `NEXT_PUBLIC_`, `VITE_`, `REACT_APP_`, or `EXPO_PUBLIC_` is **compiled into JavaScript
your users download**. There is no private client-side env var — not in React Native either, where the
JS bundle is trivially extracted from the IPA or APK.

```ts
const stripe = new Stripe(process.env.VITE_STRIPE_SECRET_KEY); // now public, forever
```

Anything with a secret half belongs behind your own endpoint. Grep the built output for known secret
prefixes in CI — it costs minutes and catches the mistake before users do. Rotate anything that has ever
been built into a bundle; reverting the commit does not un-ship it.

### Dependency supply chain

Each transitive dependency runs with full access to your build and, once shipped, the same origin as
your tokens.

- Commit the lockfile; install with `npm ci` in CI, never `npm install`.
- Pin exact versions in the build pipeline. `^` on a build plugin is arbitrary code execution on a
  schedule you do not control.
- Treat a new direct dependency as a decision: maintenance status, install scripts, transitive weight.
  A 40-line utility is not worth a package.
- Run audits, but do not confuse advisory count with risk — most React advisories are dev-only or
  unreachable. Triage by whether the code path ships. Prefer `--ignore-scripts`; postinstall is the
  standard delivery mechanism for compromised packages.

## Comments and documentation

### The contract is the prop types

Types are the documentation that cannot go stale. Make illegal states unrepresentable rather than
describing them in prose.

```tsx
// Wrong — three booleans permit eight states, five of them nonsense.
type Props = { isLoading: boolean; isError: boolean; isEmpty: boolean; data?: Item[] };

// Right — the type says which states exist.
type Props =
  | { status: 'loading' }
  | { status: 'error'; error: Error }
  | { status: 'ready'; items: Item[] };
```

A non-null assertion on network data is a lie the type checker cannot catch. Parse at the boundary (Zod
or equivalent) so the type reflects what arrived, not what the API docs claimed.

### Comment what looks like a mistake

The code that needs a comment is the code that looks wrong and is not, because the next developer will
"fix" it:

```tsx
// Deliberately omits `onSelect` from deps. The parent recreates it every render;
// including it re-subscribes the socket on every keystroke. The ref keeps the
// latest callback without retriggering the effect.
useEffect(() => {
  const socket = subscribe(roomId, (msg) => onSelectRef.current(msg));
  return () => socket.close();
}, [roomId]);
```

Same for a `useMemo` justified by a measurement, a `ref` used instead of state on purpose, a `key` that
is not the obvious identifier, a `flushSync`, a deliberate double-render workaround. State the reason,
not the mechanism. Do **not** comment what the code says (`// set loading to true`), leave commented-out
code, or write JSDoc that restates parameter names — a stale comment is worse than none, so prefer
expressing intent in names and types.

## Structure and performance

### Component boundaries

Split by **what changes together**, not by line count. A 300-line component with one responsibility is
fine; three 60-line components threading five props through a wrapper is worse. Real signals for a
split: state nothing else reads, a different update frequency, an independent data dependency.

Push state down. A component re-renders its whole subtree when its state changes, so state held one
level too high is the most common self-inflicted performance problem in React.

```tsx
// Wrong — every keystroke re-renders the page including <HugeTable />.
const [query, setQuery] = useState('');
return <><SearchBox value={query} onChange={setQuery} /><HugeTable /></>;
```

Move `query` into the component that uses it, or pass `<HugeTable />` in as `children` so it keeps its
identity across the parent's re-renders.

### Measuring re-renders

Guessing does not work here. Use the React DevTools Profiler: record the interaction and read "why did
this render" — it names the cause (props, state, parent, context). Add `<Profiler>` in code when you
need a number for the commit message.

`React.memo`, `useMemo`, and `useCallback` are not free — each adds a comparison and holds a reference.
Apply them after the Profiler names the component. Memoising a component whose props include an inline
object or arrow function does nothing at all, and is the most common wasted `memo` in any codebase.
Where the React Compiler is enabled, let it handle mechanical memoisation and delete the hand-written
hooks it makes redundant; it does not fix misplaced state or a broken data layer.

### List virtualisation

Rough thresholds, not laws. Under ~100 rows of simple content, render them all — virtualisation adds
scroll jank, breaks Ctrl-F, and complicates accessibility. Hundreds to thousands, or rows with images
and charts: virtualise (`@tanstack/virtual`, `react-window`). Check the cheaper wins first: is the row
memoised, is the `key` a stable id, is an expensive computation running per row that could be hoisted?

Index keys are a correctness bug, not a performance one — reorder or delete and React reuses the wrong
DOM node, so input values and animation state attach to the wrong row.

### React Native, where it differs

- **The JSI boundary is the budget.** Frequent small JS→native calls cost more than the equivalent web
  work. Batch them.
- **`FlatList`/`FlashList` are not optional.** `ScrollView` renders every child eagerly and will exhaust
  memory on real datasets. Set `keyExtractor`; measure `getItemLayout` for fixed-height rows.
- **Animations belong off the JS thread.** Reanimated worklets or `useNativeDriver: true`; a JS-thread
  animation drops frames whenever a render lands.
- **Re-renders cost more.** Native view updates are more expensive than DOM diffs, so the memoisation
  threshold is lower — still measured, via Hermes profiling or the Perf Monitor.
- **Test on a low-end physical device.** The simulator runs on desktop silicon and hides every
  performance problem you have.

## What to hand over

If you changed something for performance, say which interaction improved, by how much, and how it was
measured. "Added `useCallback`" is not a result. "Typing in the filter went from 14 re-renders of the
table to 1, Profiler-verified" is.

---
MIT licensed. Written by Smit Desai — <https://laravel.org.in>
