diff --git a/app/javascript/packages/document-capture/context/acuant.jsx b/app/javascript/packages/document-capture/context/acuant.jsx
index 02e2d50d6d8..cda06a77719 100644
--- a/app/javascript/packages/document-capture/context/acuant.jsx
+++ b/app/javascript/packages/document-capture/context/acuant.jsx
@@ -1,6 +1,7 @@
import { createContext, useContext, useMemo, useEffect, useState } from 'react';
import DeviceContext from './device';
import AnalyticsContext from './analytics';
+import useObjectMemo from '@18f/identity-react-hooks/use-object-memo';
/** @typedef {import('react').ReactNode} ReactNode */
@@ -147,32 +148,18 @@ function AcuantContextProvider({
// types should treat camera as unsupported, since it's not relevant for Acuant SDK usage.
const [isCameraSupported, setIsCameraSupported] = useState(isMobile ? null : false);
const [isActive, setIsActive] = useState(false);
- const value = useMemo(
- () => ({
- isReady,
- isAcuantLoaded,
- isError,
- isCameraSupported,
- isActive,
- setIsActive,
- endpoint,
- credentials,
- glareThreshold,
- sharpnessThreshold,
- }),
- [
- isReady,
- isAcuantLoaded,
- isError,
- isCameraSupported,
- isActive,
- setIsActive,
- endpoint,
- credentials,
- glareThreshold,
- sharpnessThreshold,
- ],
- );
+ const value = useObjectMemo({
+ isReady,
+ isAcuantLoaded,
+ isError,
+ isCameraSupported,
+ isActive,
+ setIsActive,
+ endpoint,
+ credentials,
+ glareThreshold,
+ sharpnessThreshold,
+ });
useEffect(() => {
// If state is already ready (via consideration of device type), skip loading Acuant SDK.
diff --git a/app/javascript/packages/document-capture/context/upload.jsx b/app/javascript/packages/document-capture/context/upload.jsx
index 4aedd4fbfb8..dafc7c80fd2 100644
--- a/app/javascript/packages/document-capture/context/upload.jsx
+++ b/app/javascript/packages/document-capture/context/upload.jsx
@@ -1,4 +1,5 @@
-import { createContext, useMemo } from 'react';
+import { createContext } from 'react';
+import { useObjectMemo } from '@18f/identity-react-hooks';
import defaultUpload from '../services/upload';
const UploadContext = createContext({
@@ -102,28 +103,16 @@ function UploadContextProvider({
? upload({ ...formData }, { endpoint: statusEndpoint, method, csrf })
: Promise.reject();
- const value = useMemo(
- () => ({
- upload: uploadWithCSRF,
- getStatus,
- statusPollInterval,
- backgroundUploadURLs,
- backgroundUploadEncryptKey,
- isMockClient,
- flowPath,
- csrf,
- }),
- [
- upload,
- getStatus,
- statusPollInterval,
- backgroundUploadURLs,
- backgroundUploadEncryptKey,
- isMockClient,
- flowPath,
- csrf,
- ],
- );
+ const value = useObjectMemo({
+ upload: uploadWithCSRF,
+ getStatus,
+ statusPollInterval,
+ backgroundUploadURLs,
+ backgroundUploadEncryptKey,
+ isMockClient,
+ flowPath,
+ csrf,
+ });
return {children};
}
diff --git a/app/javascript/packages/react-hooks/index.ts b/app/javascript/packages/react-hooks/index.ts
index 10c1e14a8de..e5d359b4176 100644
--- a/app/javascript/packages/react-hooks/index.ts
+++ b/app/javascript/packages/react-hooks/index.ts
@@ -2,3 +2,4 @@ export { default as useDidUpdateEffect } from './use-did-update-effect';
export { default as useIfStillMounted } from './use-if-still-mounted';
export { default as useImmutableCallback } from './use-immutable-callback';
export { default as useInstanceId } from './use-instance-id';
+export { default as useObjectMemo } from './use-object-memo';
diff --git a/app/javascript/packages/react-hooks/use-object-memo.spec.ts b/app/javascript/packages/react-hooks/use-object-memo.spec.ts
new file mode 100644
index 00000000000..b1fc2fec2df
--- /dev/null
+++ b/app/javascript/packages/react-hooks/use-object-memo.spec.ts
@@ -0,0 +1,35 @@
+import { renderHook } from '@testing-library/react-hooks';
+import useObjectMemo from './use-object-memo';
+
+describe('useObjectMemo', () => {
+ it('maintains reference over re-render for identical object values', () => {
+ const { rerender, result } = renderHook(({ object }) => useObjectMemo(object), {
+ initialProps: { object: { a: 1 } },
+ });
+
+ const { current: object1 } = result;
+
+ rerender({ object: { a: 1 } });
+
+ const { current: object2 } = result;
+
+ expect(object1).to.equal(object2);
+ expect(object1).to.deep.equal({ a: 1 });
+ });
+
+ it('updates reference when re-rendering with new values', () => {
+ const { rerender, result } = renderHook(({ object }) => useObjectMemo(object), {
+ initialProps: { object: { a: 1 } },
+ });
+
+ const { current: object1 } = result;
+ expect(object1).to.deep.equal({ a: 1 });
+
+ rerender({ object: { a: 2 } });
+
+ const { current: object2 } = result;
+ expect(object2).to.deep.equal({ a: 2 });
+
+ expect(object1).to.not.equal(object2);
+ });
+});
diff --git a/app/javascript/packages/react-hooks/use-object-memo.ts b/app/javascript/packages/react-hooks/use-object-memo.ts
new file mode 100644
index 00000000000..ddfbe7290e2
--- /dev/null
+++ b/app/javascript/packages/react-hooks/use-object-memo.ts
@@ -0,0 +1,20 @@
+import { useMemo } from 'react';
+
+/**
+ * React hook which creates a memoized object whose reference changes when values of the object
+ * changes.
+ *
+ * This can be useful in situations like object context values, since without memoization an object
+ * context would trigger re-renders of all consumers on every update.
+ *
+ * Note that the keys of the object must remain the same for the lifecycle of the component for the
+ * hook to work correctly.
+ *
+ * @param object Object to memoize.
+ *
+ * @return Memoized object.
+ */
+const useObjectMemo = (object: T): T =>
+ useMemo(() => object, Object.values(object));
+
+export default useObjectMemo;
diff --git a/app/javascript/packages/verify-flow/verify-flow.tsx b/app/javascript/packages/verify-flow/verify-flow.tsx
index 50e41235f7d..3ffdc202060 100644
--- a/app/javascript/packages/verify-flow/verify-flow.tsx
+++ b/app/javascript/packages/verify-flow/verify-flow.tsx
@@ -1,7 +1,8 @@
-import { useEffect, useState, useMemo } from 'react';
+import { useEffect, useState } from 'react';
import { FormSteps } from '@18f/identity-form-steps';
import { trackEvent } from '@18f/identity-analytics';
import { getConfigValue } from '@18f/identity-config';
+import { useObjectMemo } from '@18f/identity-react-hooks';
import { STEPS } from './steps';
import VerifyFlowStepIndicator from './verify-flow-step-indicator';
import VerifyFlowAlert from './verify-flow-alert';
@@ -112,10 +113,13 @@ function VerifyFlow({
const [syncedValues, setSyncedValues] = useSyncedSecretValues(initialValues);
const [currentStep, setCurrentStep] = useState(steps[0].name);
const [initialStep, setCompletedStep] = useInitialStepValidation(basePath, steps);
- const context = useMemo(
- () => ({ startOverURL, cancelURL, currentStep, basePath, resetPasswordUrl }),
- [startOverURL, cancelURL, currentStep, basePath, resetPasswordUrl],
- );
+ const context = useObjectMemo({
+ startOverURL,
+ cancelURL,
+ currentStep,
+ basePath,
+ resetPasswordUrl,
+ });
useEffect(() => {
logStepVisited(currentStep);
}, [currentStep]);