React Hooks Performance
SkillMediaReact render performance for Trezor Suite — memoization under React Compiler, referentially stable hook dependencies, minimal dependency arrays, and telling a wasted memo from a render loop. Use when adding useMemo, useCallback or memo, when writing a dependency array, or when a component re-renders or refetches more than it should.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the React Hooks Performance skill
What this skill tells your AI
The instructions your AI receives, as published by trezor/trezor-suite in skills/performance-react-hooks/SKILL.md and read by ahel’s review.
Values that change identity on every render, and the memos, effects and requests that fire because of it.
Check a re-render claim before and after: DebugView plus
the dev-utils rerender-count toggle on mobile, the React DevTools Profiler on web. React Compiler doesn't
run in native jest (@swc/jest), so render-count assertions there say nothing about production.
Check which app you are in before adding or removing a memo
- Mobile (
suite-native) is compiled.experiments.reactCompiler: trueinapp.config.tsauto-memoizes every component and hook, so don't add new manual memoization. A bail-out is worse than a missing memo — it silently drops auto-memoization for the whole component;react-hook-form'swatch()causes one, useuseWatch(). - Web and desktop (
packages/suite) are not compiled. Manual memoization is the only mechanism at runtime.suite-common/*andpackages/componentsship to both, so memoize for the web consumer. - The compiler's lint rules apply everywhere, but only on CI.
reactConfig.mjsswitches off ten of them —preserve-manual-memoization,immutability,purity,incompatible-library,globals,error-boundaries,set-state-in-render,unsupported-syntax,config,gating— unlessESLINT_RUN_EXPENSIVE_CHECKS=true, which CI sets and your localyarn lint:jsdoes not. The same flag gatesreportUnusedDisableDirectives(index.mjs:52), so a suppression that has stopped being necessary is also only reported on CI. Reproduce a green-locally, red-on-CI run withESLINT_RUN_EXPENSIVE_CHECKS=true yarn lint:js.rules-of-hooksandexhaustive-depsareerroreverywhere, always.
Relocate render-body work before memoizing it, and memoize only what pays
A bare .find / .filter / .sort over accounts, account.tokens, transactions or
availableVaults in a component body re-runs on every render, and moving it is the preferred fix: an
existing memoized selector, a named hook (useEnabledNetworkOptions), or a child component. Build new
selectors with createWeakMapSelector
(selectorsUtils.ts). What stays in the component earns a useMemo
only if the work is genuinely expensive over a real list, or a downstream component or hook needs the
result's identity to be stable — O(1) arithmetic and formatting belong in a plain util. Redundant memos
get flagged about as often as missing ones:
(#25937,
#22136).
Keep hook dependencies referentially stable
?? [], {}, a default parameter, an inline arrow and every .filter() result produce a new reference
per render, so each memo, callback and effect below them re-runs. exhaustive-deps stays silent because
the dependency is listed, and two shapes are provably invisible to it: one derived from a call
expression (const filtered = items.filter(...)), and one that crosses the hook boundary
(useThing({ list: maybe ?? [] }) feeding a useMemo inside useThing). Fix with a module-level
constant, or returnStableArrayIfEmpty
(selectorsUtils.ts, 115 call sites) in a selector.
// bad - useAccounts.ts:9 - ethereum, solana, ripple, stellar and tron have no `addresses`, so both
// defaults are a fresh array and the useMemo that lists them never hits
const { unused = [], used = [] } = addresses ?? {};
// good - one reference for the life of the module
const EMPTY_ADDRESSES: readonly AccountAddress[] = [];
const { unused = EMPTY_ADDRESSES, used = EMPTY_ADDRESSES } = addresses ?? {};
Minimal required dependencies
Narrower than the containing object, never wider than the closure: accountKey, not account;
payload.amount, not payload (#23523).
Distinguish a wasted memo from a render loop
The failure modes are nothing alike, and only one of them is a loop:
- An unstable dependency on
useMemooruseCallbackrecomputes every render. Memoization is gone, the render count stays bounded. Wasteful, terminates — and it is this repo's actual recurring pain. - The same dependency on a
useEffectthat fires a request refires it every render. The render count still doesn't grow, so this looks like the mild case, and it is the worst one: the request count is unbounded, and if the response writes to state the next render fires the next request. That cycle is paced by network latency rather than by React, so it never trips "Maximum update depth exceeded" — that guard only counts synchronous nested updates. It fails silently against the backend instead of loudly in dev. - The same dependency on a
useEffectthat stores a fresh reference is a synchronous cycle: render mints a new array, the effect runs,setStaterenders again, "Maximum update depth exceeded" on about the fiftieth nested update. Storing a primitive from it (setState(claimable.length)) terminates, because React bails out onObject.is— an unstable dependency is necessary but not sufficient.
// silent and unbounded - `account` is a new object after every blockchain update, so this refetches
// every transaction again, and the fetch writes back to the account. Shipped in the Ethereum staking
// dashboard from 2024-08 until #23523 narrowed it 15 months later.
useEffect(() => {
dispatch(fetchAllTransactionsForAccountThunk({ accountKey, noLoading: true }));
}, [account, accountKey, dispatch]);
// loud and immediate - a fresh array each render, stored by the effect that re-renders to mint the next
const claimable = rewards.filter(reward => reward.isClaimable);
useEffect(() => setClaimableRewards(claimable), [claimable]);
// good - AdaStakingDashboard.tsx:52 - the effect depends on stable primitives only
useEffect(() => {
dispatch(fetchAllTransactionsForAccountThunk({ accountKey, noLoading: true }));
}, [accountKey, dispatch]);
// good - derived state is not state; there is no effect left to cycle
const claimable = useMemo(() => rewards.filter(reward => reward.isClaimable), [rewards]);
State that can be computed from what you already have is not state; derive it during render and there is
no cycle to have. When an effect really must fetch, depend on the identifier and not the record —
accountKey, never account. react-hooks/set-state-in-effect is off here, so nothing warns you
about the third case, and nothing warns you about the second one at all.
Never add a new eslint-disable for exhaustive-deps
"Please, let's never use this comment. It leads to bugs and mem leaks." The failure mode is a lying
dependency array on a memo whose callback reads through a ref. Restructure instead: read imperatively
(getValues()), convert the memo to useState + useEffect, or hold the value in a ref — but check
which one. When the value is genuinely read through a stable callback and the linter therefore cannot see
it, keep the dependency listed and reference it with a void statement so the rule stays live rather than
suppressed — useTradingBuyFormDefaultValues.ts:45 does exactly that with void coins;, and carries no
eslint-disable at all.
useFreshRef assigns during render, so .current
is always the newest value; it is the only correct choice when the ref is read in render or inside a
useMemo. useCurrentRef assigns in an effect,
so during render .current still holds the last committed value. Neither tracks the previous value — for
that, assign a plain useRef at the end of the effect. And confirm the dependency is genuinely unstable
at its declaration first: a useCallback(…, []) handler is already stable and belongs in the array
(#26319,
#27384).
Related skills
-
Components — hook order, pass the narrow prop, and don’t optimise until you can point at the cost.
-
Redux — one
useSelectorper value. TheuseSelectorexported by@suite-common/redux-utils -
Asymptotic complexity — indexing, sorting and reducing over collections that grow.
-
DOM and CSS — forced layout, observers, compositor-only animation.
-
Long and non-essential tasks — yielding long tasks, deferring background work.
Signals
- GitHub stars
- 1k
- Forks
- 375
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
performance-react-hooks- Source
- github.com/trezor/trezor-suite