From 6c83154f49809da519758c39b4209ecfdde24650 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski <31442502+szymonzalarski98@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:12:14 +0100 Subject: [PATCH 1/8] Remove useDeepCompareRef hook --- src/hooks/useArchivedReportsIdSet.ts | 11 ++++---- src/hooks/useDeepCompareRef.ts | 27 ------------------- src/hooks/useSidebarOrderedReports.tsx | 14 +++------- src/pages/home/ReportScreen.tsx | 3 +-- .../step/IOURequestStepConfirmation.tsx | 9 +++---- ...paceWorkflowsApprovalsExpensesFromPage.tsx | 5 ++-- 6 files changed, 16 insertions(+), 53 deletions(-) delete mode 100644 src/hooks/useDeepCompareRef.ts diff --git a/src/hooks/useArchivedReportsIdSet.ts b/src/hooks/useArchivedReportsIdSet.ts index a78d05a047859..106aecf652463 100644 --- a/src/hooks/useArchivedReportsIdSet.ts +++ b/src/hooks/useArchivedReportsIdSet.ts @@ -1,22 +1,21 @@ import {archivedReportsIdSetSelector} from '@selectors/ReportNameValuePairs'; +import {useMemo} from 'react'; import type {ArchivedReportsIDSet} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import useDeepCompareRef from './useDeepCompareRef'; import useOnyx from './useOnyx'; +const EMPTY_SET = new Set(); + /** * Hook that returns a Set of archived report IDs */ function useArchivedReportsIdSet(): ArchivedReportsIDSet { - const [archivedReportsIdSet = new Set()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { + const [archivedReportsIdSet] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { canBeMissing: true, selector: archivedReportsIdSetSelector, }); - // useDeepCompareRef is used here to prevent unnecessary re-renders by maintaining referential equality - // when the Set contents are the same, even if it's a new Set instance. This is important for performance - // optimization since Sets are reference types and would normally cause re-renders even with same values - return useDeepCompareRef(archivedReportsIdSet) ?? new Set(); + return useMemo(() => archivedReportsIdSet ?? EMPTY_SET, [archivedReportsIdSet]); } export default useArchivedReportsIdSet; diff --git a/src/hooks/useDeepCompareRef.ts b/src/hooks/useDeepCompareRef.ts deleted file mode 100644 index 46318ab886752..0000000000000 --- a/src/hooks/useDeepCompareRef.ts +++ /dev/null @@ -1,27 +0,0 @@ -import {deepEqual} from 'fast-equals'; -import {useRef} from 'react'; - -/** - * This hook returns a reference to the provided value, - * but only updates that reference if a deep comparison indicates that the value has changed. - * - * This is useful when working with objects or arrays as dependencies to other hooks like `useEffect` or `useMemo`, - * where you want the hook to trigger not just on reference changes, but also when the contents of the object or array change. - * - * @example - * const myArray = // some array - * const deepComparedArray = useDeepCompareRef(myArray); - * useEffect(() => { - * // This will run not just when myArray is a new array, but also when its contents change. - * }, [deepComparedArray]); - */ -export default function useDeepCompareRef(value: T): T | undefined { - const ref = useRef(undefined); - // eslint-disable-next-line react-compiler/react-compiler - if (!deepEqual(value, ref.current)) { - // eslint-disable-next-line react-compiler/react-compiler - ref.current = value; - } - // eslint-disable-next-line react-compiler/react-compiler - return ref.current; -} diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 0b5e14b905081..d573acf18060a 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -11,7 +11,6 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; import useCurrentReportID from './useCurrentReportID'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; -import useDeepCompareRef from './useDeepCompareRef'; import useLocalize from './useLocalize'; import useOnyx from './useOnyx'; import usePrevious from './usePrevious'; @@ -182,19 +181,14 @@ function SidebarOrderedReportsContextProvider({ // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps }, [getUpdatedReports, chatReports, derivedCurrentReportID, priorityMode, betas, policies, transactionViolations, reportNameValuePairs, reportAttributes, reportsDrafts]); - const deepComparedReportsToDisplayInLHN = useDeepCompareRef(reportsToDisplayInLHN); - const deepComparedReportsDrafts = useDeepCompareRef(reportsDrafts); - useEffect(() => { setCurrentReportsToDisplay(reportsToDisplayInLHN); }, [reportsToDisplayInLHN]); - const getOrderedReportIDs = useCallback( - () => SidebarUtils.sortReportsToDisplayInLHN(deepComparedReportsToDisplayInLHN ?? {}, priorityMode, localeCompare, deepComparedReportsDrafts, reportNameValuePairs, reportAttributes), - // Rule disabled intentionally - reports should be sorted only when the reportsToDisplayInLHN changes - // eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps - [deepComparedReportsToDisplayInLHN, localeCompare, deepComparedReportsDrafts], - ); + const getOrderedReportIDs = useCallback(() => { + const result = SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplayInLHN, priorityMode, localeCompare, reportsDrafts, reportNameValuePairs, reportAttributes); + return result; + }, [reportsToDisplayInLHN, priorityMode, localeCompare, reportsDrafts, reportNameValuePairs, reportAttributes]); const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); diff --git a/src/pages/home/ReportScreen.tsx b/src/pages/home/ReportScreen.tsx index 65abac82624c5..c46b0ff581562 100644 --- a/src/pages/home/ReportScreen.tsx +++ b/src/pages/home/ReportScreen.tsx @@ -23,7 +23,6 @@ import ScrollView from '@components/ScrollView'; import useShowWideRHPVersion from '@components/WideRHPContextProvider/useShowWideRHPVersion'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; import useCurrentReportID from '@hooks/useCurrentReportID'; -import useDeepCompareRef from '@hooks/useDeepCompareRef'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -185,7 +184,7 @@ function ReportScreen({route, navigation}: ReportScreenProps) { const deletedParentAction = isDeletedParentAction(parentReportAction); const prevDeletedParentAction = usePrevious(deletedParentAction); - const permissions = useDeepCompareRef(reportOnyx?.permissions); + const permissions = useMemo(() => reportOnyx?.permissions ?? [], [reportOnyx?.permissions]); const isAnonymousUser = useIsAnonymousUser(); const [isLoadingReportData = true] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {canBeMissing: true}); diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index b5a8418bde76f..f9b4973501e7f 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -15,7 +15,6 @@ import PrevNextButtons from '@components/PrevNextButtons'; import ScreenWrapper from '@components/ScreenWrapper'; import useArchivedReportsIdSet from '@hooks/useArchivedReportsIdSet'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; -import useDeepCompareRef from '@hooks/useDeepCompareRef'; import useFetchRoute from '@hooks/useFetchRoute'; import useFilesValidation from '@hooks/useFilesValidation'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -152,12 +151,12 @@ function IOURequestStepConfirmation({ () => (!isLoadingCurrentTransaction ? (optimisticTransaction ?? existingTransaction) : undefined), [existingTransaction, optimisticTransaction, isLoadingCurrentTransaction], ); - const transactionsCategories = useDeepCompareRef( - transactions.map(({transactionID, category}) => ({ + const transactionsCategories = useMemo(() => { + return transactions.map(({transactionID, category}) => ({ transactionID, category, - })), - ); + })); + }, [transactions]); const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const isCreatingTrackExpense = action === CONST.IOU.ACTION.CREATE && iouType === CONST.IOU.TYPE.TRACK; const {policyForMovingExpenses, policyForMovingExpensesID} = usePolicyForMovingExpenses(); diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 3ba287aa62ac7..235a79fa19ac1 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -1,10 +1,9 @@ import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import type {SelectionListApprover} from '@components/ApproverSelectionList'; import ApproverSelectionList from '@components/ApproverSelectionList'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import Text from '@components/Text'; -import useDeepCompareRef from '@hooks/useDeepCompareRef'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; @@ -45,7 +44,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const shouldShowListEmptyContent = !isLoadingApprovalWorkflow && approvalWorkflow && approvalWorkflow.availableMembers.length === 0; const firstApprover = approvalWorkflow?.originalApprovers?.[0]?.email ?? ''; - const personalDetailLogins = useDeepCompareRef(Object.fromEntries(Object.entries(personalDetails ?? {}).map(([id, details]) => [id, details?.login]))); + const personalDetailLogins = useMemo(() => Object.fromEntries(Object.entries(personalDetails ?? {}).map(([id, details]) => [id, details?.login])), [personalDetails]); useEffect(() => { if (!approvalWorkflow?.members) { From 983a764c615d56841a11068ecf23bc192757e5d0 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski <31442502+szymonzalarski98@users.noreply.github.com> Date: Tue, 30 Dec 2025 14:04:42 +0100 Subject: [PATCH 2/8] PR fixes --- src/hooks/useSidebarOrderedReports.tsx | 8 ++++---- .../WorkspaceWorkflowsApprovalsExpensesFromPage.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index d573acf18060a..4c5a875b7ee83 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -185,10 +185,10 @@ function SidebarOrderedReportsContextProvider({ setCurrentReportsToDisplay(reportsToDisplayInLHN); }, [reportsToDisplayInLHN]); - const getOrderedReportIDs = useCallback(() => { - const result = SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplayInLHN, priorityMode, localeCompare, reportsDrafts, reportNameValuePairs, reportAttributes); - return result; - }, [reportsToDisplayInLHN, priorityMode, localeCompare, reportsDrafts, reportNameValuePairs, reportAttributes]); + const getOrderedReportIDs = useCallback( + () => SidebarUtils.sortReportsToDisplayInLHN(reportsToDisplayInLHN, priorityMode, localeCompare, reportsDrafts, reportNameValuePairs, reportAttributes), + [reportsToDisplayInLHN, priorityMode, localeCompare, reportsDrafts, reportNameValuePairs, reportAttributes], + ); const orderedReportIDs = useMemo(() => getOrderedReportIDs(), [getOrderedReportIDs]); diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 235a79fa19ac1..3e0f2caa37171 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -1,5 +1,5 @@ import {Str} from 'expensify-common'; -import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import type {SelectionListApprover} from '@components/ApproverSelectionList'; import ApproverSelectionList from '@components/ApproverSelectionList'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; From de46d6cca1fbc72f4d2481b062a7f8a0fa434288 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski <31442502+szymonzalarski98@users.noreply.github.com> Date: Sat, 17 Jan 2026 09:33:02 +0100 Subject: [PATCH 3/8] Revert useDeepCompareRef hook --- src/hooks/useDeepCompareRef.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 src/hooks/useDeepCompareRef.ts diff --git a/src/hooks/useDeepCompareRef.ts b/src/hooks/useDeepCompareRef.ts new file mode 100644 index 0000000000000..d5aa85d5398df --- /dev/null +++ b/src/hooks/useDeepCompareRef.ts @@ -0,0 +1,24 @@ +import {deepEqual} from 'fast-equals'; +import {useRef} from 'react'; + +/** + * This hook returns a reference to the provided value, + * but only updates that reference if a deep comparison indicates that the value has changed. + * + * This is useful when working with objects or arrays as dependencies to other hooks like `useEffect` or `useMemo`, + * where you want the hook to trigger not just on reference changes, but also when the contents of the object or array change. + * + * @example + * const myArray = // some array + * const deepComparedArray = useDeepCompareRef(myArray); + * useEffect(() => { + * // This will run not just when myArray is a new array, but also when its contents change. + * }, [deepComparedArray]); + */ +export default function useDeepCompareRef(value: T): T | undefined { + const ref = useRef(undefined); + if (!deepEqual(value, ref.current)) { + ref.current = value; + } + return ref.current; +} From e9665002af423646910a470e1eed7fb7174bad56 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Thu, 29 Jan 2026 13:37:54 +0100 Subject: [PATCH 4/8] PR fixes --- src/pages/inbox/ReportScreen.tsx | 8 +++----- .../iou/request/step/IOURequestStepConfirmation.tsx | 8 +------- .../WorkspaceWorkflowsApprovalsExpensesFromPage.tsx | 11 +++-------- 3 files changed, 7 insertions(+), 20 deletions(-) diff --git a/src/pages/inbox/ReportScreen.tsx b/src/pages/inbox/ReportScreen.tsx index d712cfbd498ae..6ef61499b0dca 100644 --- a/src/pages/inbox/ReportScreen.tsx +++ b/src/pages/inbox/ReportScreen.tsx @@ -23,7 +23,7 @@ import ScrollView from '@components/ScrollView'; import useShowWideRHPVersion from '@components/WideRHPContextProvider/useShowWideRHPVersion'; import WideRHPOverlayWrapper from '@components/WideRHPOverlayWrapper'; import useAppFocusEvent from '@hooks/useAppFocusEvent'; -import useCurrentReportID from '@hooks/useCurrentReportID'; +import {useCurrentReportIDState} from '@hooks/useCurrentReportID'; import useIsAnonymousUser from '@hooks/useIsAnonymousUser'; import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -191,8 +191,6 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr const deletedParentAction = isDeletedParentAction(parentReportAction); const prevDeletedParentAction = usePrevious(deletedParentAction); - const permissions = useMemo(() => reportOnyx?.permissions ?? [], [reportOnyx?.permissions]); - const isAnonymousUser = useIsAnonymousUser(); const [isLoadingReportData = true] = useOnyx(ONYXKEYS.IS_LOADING_REPORT_DATA, {canBeMissing: true}); const prevIsLoadingReportData = usePrevious(isLoadingReportData); @@ -286,12 +284,12 @@ function ReportScreen({route, navigation, isInSidePanel = false}: ReportScreenPr private_isArchived: reportNameValuePairsOnyx?.private_isArchived, lastMentionedTime: reportOnyx.lastMentionedTime, avatarUrl: reportOnyx.avatarUrl, - permissions, + permissions: reportOnyx?.permissions, invoiceReceiver: reportOnyx.invoiceReceiver, policyAvatar: reportOnyx.policyAvatar, nextStep: reportOnyx.nextStep, }, - [reportOnyx, reportNameValuePairsOnyx?.private_isArchived, permissions], + [reportOnyx, reportNameValuePairsOnyx?.private_isArchived], ); const reportID = report?.reportID; diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx index 6f3ac3592f4a5..7975230fe4bd9 100644 --- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx +++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx @@ -145,12 +145,6 @@ function IOURequestStepConfirmation({ () => (!isLoadingCurrentTransaction ? (optimisticTransaction ?? existingTransaction) : undefined), [existingTransaction, optimisticTransaction, isLoadingCurrentTransaction], ); - const transactionsCategories = useMemo(() => { - return transactions.map(({transactionID, category}) => ({ - transactionID, - category, - })); - }, [transactions]); const isUnreported = transaction?.reportID === CONST.REPORT.UNREPORTED_REPORT_ID; const isCreatingTrackExpense = action === CONST.IOU.ACTION.CREATE && iouType === CONST.IOU.TYPE.TRACK; const {policyForMovingExpenses, policyForMovingExpensesID} = usePolicyForMovingExpenses(); @@ -397,7 +391,7 @@ function IOURequestStepConfirmation({ } // We don't want to clear out category every time the transactions change // eslint-disable-next-line react-hooks/exhaustive-deps - }, [policy?.id, policyCategories, transactionsCategories]); + }, [policy?.id, policyCategories, transactions.length]); const policyDistance = Object.values(policy?.customUnits ?? {}).find((customUnit) => customUnit.name === CONST.CUSTOM_UNITS.NAME_DISTANCE); const defaultCategory = policyDistance?.defaultCategory ?? ''; diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index d55ed2b5fdd94..ddc44eaacd4d1 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -32,7 +32,6 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const styles = useThemeStyles(); const {translate} = useLocalize(); const [approvalWorkflow, approvalWorkflowResults] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW, {canBeMissing: true}); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar']); const isLoadingApprovalWorkflow = isLoadingOnyxValue(approvalWorkflowResults); @@ -44,8 +43,6 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const shouldShowListEmptyContent = !isLoadingApprovalWorkflow && approvalWorkflow && approvalWorkflow.availableMembers.length === 0; const firstApprover = approvalWorkflow?.originalApprovers?.[0]?.email ?? ''; - const personalDetailLogins = useMemo(() => Object.fromEntries(Object.entries(personalDetails ?? {}).map(([id, details]) => [id, details?.login])), [personalDetails]); - useEffect(() => { if (!approvalWorkflow?.members) { return; @@ -55,7 +52,6 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat approvalWorkflow.members.map((member) => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); const accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? ''); - const login = personalDetailLogins?.[accountID]; return { text: Str.removeSMSDomain(member.displayName), @@ -68,13 +64,13 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat ), }; }), ); - }, [approvalWorkflow?.members, policy?.employeeList, policy?.owner, personalDetailLogins, translate, icons.FallbackAvatar]); + }, [approvalWorkflow?.members, policy?.employeeList, policy?.owner, translate, icons.FallbackAvatar]); const approversEmail = approvalWorkflow?.approvers.map((member) => member?.email); const allApprovers: SelectionListApprover[] = [...selectedMembers]; @@ -84,7 +80,6 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat .map((member) => { const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(policy?.employeeList); const accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? ''); - const login = personalDetailLogins?.[accountID]; return { text: Str.removeSMSDomain(member.displayName), @@ -97,7 +92,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat ), }; From 8de6d8c7a109b822fb08599c82d1d77961b75914 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 30 Jan 2026 08:28:10 +0100 Subject: [PATCH 5/8] Update useDeepCompareRef docs --- src/hooks/useDeepCompareRef.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/hooks/useDeepCompareRef.ts b/src/hooks/useDeepCompareRef.ts index d5aa85d5398df..8cad47e31b80b 100644 --- a/src/hooks/useDeepCompareRef.ts +++ b/src/hooks/useDeepCompareRef.ts @@ -2,6 +2,8 @@ import {deepEqual} from 'fast-equals'; import {useRef} from 'react'; /** + * ⚠️ **WARNING: ANTI-PATTERN - AVOID IN NEW CODE** ⚠️ + * * This hook returns a reference to the provided value, * but only updates that reference if a deep comparison indicates that the value has changed. * @@ -14,6 +16,22 @@ import {useRef} from 'react'; * useEffect(() => { * // This will run not just when myArray is a new array, but also when its contents change. * }, [deepComparedArray]); + * + * **Why this is problematic:** + * - Violates React's exhaustive deps rule (can cause stale closures) + * - Performance overhead from deep equality checks on every render + * - Incompatible with React Compiler optimizations + * - Usually indicates a data flow problem that should be fixed at the source + * + * **Use instead:** + * - `useMemo` with primitive dependencies + * - Fix selectors/data sources to return stable references + * + * **Only use when ALL of these apply:** + * - Legacy infrastructure forces new references (e.g., Onyx collections) + * - Documented why it's necessary and what the risks are + * - No feasible alternative without major refactoring + * - Performance impact measured with tests (e.g., Reassure) */ export default function useDeepCompareRef(value: T): T | undefined { const ref = useRef(undefined); From 259948abff7c8a68d103992d498254c2fb6a7d7a Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 30 Jan 2026 08:29:53 +0100 Subject: [PATCH 6/8] Fix useArchivedReportsIdSet and add a comment --- src/hooks/useArchivedReportsIdSet.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/hooks/useArchivedReportsIdSet.ts b/src/hooks/useArchivedReportsIdSet.ts index 106aecf652463..a2458ae7ebf59 100644 --- a/src/hooks/useArchivedReportsIdSet.ts +++ b/src/hooks/useArchivedReportsIdSet.ts @@ -1,21 +1,23 @@ import {archivedReportsIdSetSelector} from '@selectors/ReportNameValuePairs'; -import {useMemo} from 'react'; import type {ArchivedReportsIDSet} from '@libs/SearchUIUtils'; import ONYXKEYS from '@src/ONYXKEYS'; +import useDeepCompareRef from './useDeepCompareRef'; import useOnyx from './useOnyx'; -const EMPTY_SET = new Set(); - /** * Hook that returns a Set of archived report IDs */ function useArchivedReportsIdSet(): ArchivedReportsIDSet { - const [archivedReportsIdSet] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { + const [archivedReportsIdSet = new Set()] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS, { canBeMissing: true, selector: archivedReportsIdSetSelector, }); - return useMemo(() => archivedReportsIdSet ?? EMPTY_SET, [archivedReportsIdSet]); + // useDeepCompareRef is used here to prevent unnecessary re-renders by maintaining referential equality + // when the Set contents are the same, even if it's a new Set instance. This is important for performance + // optimization since Sets are reference types and would normally cause re-renders even with same values + // Reassure test confirmed additional rerender when not using useDeepCompareRef + return useDeepCompareRef(archivedReportsIdSet) ?? new Set(); } export default useArchivedReportsIdSet; From 12191842ee9c53bbd83b960de284cfe0f49e3024 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 30 Jan 2026 08:43:12 +0100 Subject: [PATCH 7/8] Prettier fix --- src/hooks/useDeepCompareRef.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/useDeepCompareRef.ts b/src/hooks/useDeepCompareRef.ts index 8cad47e31b80b..5d188d508bb44 100644 --- a/src/hooks/useDeepCompareRef.ts +++ b/src/hooks/useDeepCompareRef.ts @@ -3,7 +3,7 @@ import {useRef} from 'react'; /** * ⚠️ **WARNING: ANTI-PATTERN - AVOID IN NEW CODE** ⚠️ - * + * * This hook returns a reference to the provided value, * but only updates that reference if a deep comparison indicates that the value has changed. * @@ -16,17 +16,17 @@ import {useRef} from 'react'; * useEffect(() => { * // This will run not just when myArray is a new array, but also when its contents change. * }, [deepComparedArray]); - * + * * **Why this is problematic:** * - Violates React's exhaustive deps rule (can cause stale closures) * - Performance overhead from deep equality checks on every render * - Incompatible with React Compiler optimizations * - Usually indicates a data flow problem that should be fixed at the source - * + * * **Use instead:** * - `useMemo` with primitive dependencies * - Fix selectors/data sources to return stable references - * + * * **Only use when ALL of these apply:** * - Legacy infrastructure forces new references (e.g., Onyx collections) * - Documented why it's necessary and what the risks are From 088efc9b505180979222af1ce1b71bb5ce8aaf12 Mon Sep 17 00:00:00 2001 From: Szymon Zalarski Date: Fri, 6 Feb 2026 10:59:14 +0100 Subject: [PATCH 8/8] Add comments explained useDeepCompareRef usage --- src/hooks/usePrivateIsArchivedMap.ts | 2 ++ src/hooks/useSidebarOrderedReports.tsx | 2 ++ 2 files changed, 4 insertions(+) diff --git a/src/hooks/usePrivateIsArchivedMap.ts b/src/hooks/usePrivateIsArchivedMap.ts index 2a325ad474ca0..dbc28b90dae0a 100644 --- a/src/hooks/usePrivateIsArchivedMap.ts +++ b/src/hooks/usePrivateIsArchivedMap.ts @@ -14,6 +14,8 @@ function usePrivateIsArchivedMap(): PrivateIsArchivedMap { selector: privateIsArchivedMapSelector, }); + // useDeepCompareRef prevents unnecessary rerenders when the object has same content but different reference. + // The selector always returns a new object instance, and useMemo cannot compare by value. return useDeepCompareRef(privateIsArchivedMap) ?? {}; } diff --git a/src/hooks/useSidebarOrderedReports.tsx b/src/hooks/useSidebarOrderedReports.tsx index 26d707ce24a52..c9e1059801334 100644 --- a/src/hooks/useSidebarOrderedReports.tsx +++ b/src/hooks/useSidebarOrderedReports.tsx @@ -222,6 +222,8 @@ function SidebarOrderedReportsContextProvider({ clearCacheDummyCounter, ]); + // useDeepCompareRef prevents unnecessary useCallback recreations when these have same content but different reference. + // Without this, getOrderedReportIDs would be recreated on every render, causing expensive orderedReportIDs recalculation. const deepComparedReportsToDisplayInLHN = useDeepCompareRef(reportsToDisplayInLHN); const deepComparedReportsDrafts = useDeepCompareRef(reportsDrafts);