diff --git a/src/data/apiHooks.test.tsx b/src/data/apiHooks.test.tsx
index 996a62daf1..7dd143f805 100644
--- a/src/data/apiHooks.test.tsx
+++ b/src/data/apiHooks.test.tsx
@@ -1,3 +1,6 @@
+import { useRef } from 'react';
+import userEvent from '@testing-library/user-event';
+
import {
initializeMocks,
cleanup,
@@ -5,7 +8,7 @@ import {
render,
waitFor,
} from '../testUtils';
-import { useWaffleFlags } from './apiHooks';
+import { createGlobalState, useWaffleFlags } from './apiHooks';
import { getApiWaffleFlagsUrl } from './api';
// A little component for testing our waffle flag hooks.
@@ -110,3 +113,60 @@ describe('useWaffleFlags', () => {
expect(await screen.findByLabelText('useReactMarkdownEditor')).toHaveTextContent('enabled');
});
});
+
+// A little component for testing the global state hooks.
+const useCounter = createGlobalState<{ count: number; }>(() => ['test', 'counter'], { count: 0 });
+
+const CounterComponent = () => {
+ const { data, setData, resetData } = useCounter();
+ const firstSetData = useRef(setData);
+ const firstResetData = useRef(resetData);
+ const callbacksKeptIdentity = setData === firstSetData.current && resetData === firstResetData.current;
+
+ return (
+
+ - {data?.count ?? 'none'}
+ - {callbacksKeptIdentity ? 'same' : 'recreated'}
+ -
+
+
+ -
+
+
+
+ );
+};
+
+describe('createGlobalState', () => {
+ it('keeps its callbacks across renders, so effects depending on them do not re-run', async () => {
+ const user = userEvent.setup();
+ initializeMocks();
+ render();
+ await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('0'));
+
+ await user.click(screen.getByRole('button', { name: 'increment' }));
+ await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('1'));
+
+ expect(screen.getByLabelText('callbacks')).toHaveTextContent('same');
+ });
+
+ it('resets the value it stores', async () => {
+ const user = userEvent.setup();
+ initializeMocks();
+ render();
+ await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('0'));
+
+ await user.click(screen.getByRole('button', { name: 'increment' }));
+ await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('1'));
+
+ await user.click(screen.getByRole('button', { name: 'reset' }));
+ await waitFor(() => expect(screen.getByLabelText('count')).toHaveTextContent('0'));
+ });
+});
diff --git a/src/data/apiHooks.ts b/src/data/apiHooks.ts
index ba8baee8ff..f2652f3994 100644
--- a/src/data/apiHooks.ts
+++ b/src/data/apiHooks.ts
@@ -1,5 +1,7 @@
/* eslint-disable import/no-extraneous-dependencies */
import { AxiosError } from 'axios';
+import { useCallback, useMemo } from 'react';
+
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { UserAgreement, UserAgreementRecord } from '@src/data/types';
@@ -158,7 +160,7 @@ export function createGlobalState(
) {
return (queryKeyArgs?: any) => {
const queryClient = useQueryClient();
- const queryKey = queryKeyFn(queryKeyArgs);
+ const queryKey = useMemo(() => queryKeyFn(queryKeyArgs), [queryKeyArgs]);
const { data } = useQuery({
queryKey,
@@ -170,15 +172,13 @@ export function createGlobalState(
refetchIntervalInBackground: false,
});
- function setData(x: Partial) {
+ const setData = useCallback((x: Partial) => {
queryClient.setQueryData(queryKey, x);
- }
+ }, [queryClient, queryKey]);
- async function resetData() {
- await queryClient.invalidateQueries({
- queryKey,
- });
- }
+ const resetData = useCallback(async () => {
+ await queryClient.invalidateQueries({ queryKey });
+ }, [queryClient, queryKey]);
return { data, setData, resetData };
};