Skip to content

Commit 6f2de4e

Browse files
fix: change useStateStore to use useSyncExternalStore (#2573)
### 🎯 Goal Changing stores on the fly would keep previously calculated state for a bit before the effect would run to recalculate it - using `useSyncExternalStore` (thank you, @myandrienko) should alleviate this issue. Both `subscribe` and `getSnapshot` functions required by the React hook are wrapped to allow for selector functionality, [`geSnapshot` requires the output to be cached](https://react.dev/reference/react/useSyncExternalStore#parameters) so the wrapper reuses similar cache check mechanism as `subscribeWithSelector` does internally.
1 parent e81fc69 commit 6f2de4e

File tree

1 file changed

+43
-9
lines changed

1 file changed

+43
-9
lines changed

src/store/hooks/useStateStore.ts

+43-9
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
1-
import { useEffect, useState } from 'react';
1+
import { useCallback, useMemo } from 'react';
2+
import { useSyncExternalStore } from 'use-sync-external-store/shim';
23

34
import type { StateStore } from 'stream-chat';
45

6+
// eslint-disable-next-line @typescript-eslint/no-empty-function
7+
const noop = () => {};
8+
59
export function useStateStore<
610
T extends Record<string, unknown>,
711
O extends Readonly<Record<string, unknown> | Readonly<unknown[]>>,
@@ -14,18 +18,48 @@ export function useStateStore<
1418
T extends Record<string, unknown>,
1519
O extends Readonly<Record<string, unknown> | Readonly<unknown[]>>,
1620
>(store: StateStore<T> | undefined, selector: (v: T) => O) {
17-
const [state, setState] = useState<O | undefined>(() => {
18-
if (!store) return undefined;
19-
return selector(store.getLatestValue());
20-
});
21+
const wrappedSubscription = useCallback(
22+
(onStoreChange: () => void) => {
23+
const unsubscribe = store?.subscribeWithSelector(selector, onStoreChange);
24+
return unsubscribe ?? noop;
25+
},
26+
[store, selector],
27+
);
28+
29+
const wrappedSnapshot = useMemo(() => {
30+
let cachedTuple: [T, O];
31+
32+
return () => {
33+
const currentValue = store?.getLatestValue();
34+
35+
if (!currentValue) return undefined;
2136

22-
useEffect(() => {
23-
if (!store) return;
37+
// store value hasn't changed, no need to compare individual values
38+
if (cachedTuple && cachedTuple[0] === currentValue) {
39+
return cachedTuple[1];
40+
}
2441

25-
const unsubscribe = store.subscribeWithSelector(selector, setState);
42+
const newlySelected = selector(currentValue);
2643

27-
return unsubscribe;
44+
// store value changed but selected values wouldn't have to, double-check selected
45+
if (cachedTuple) {
46+
let selectededAreEqualToCached = true;
47+
48+
for (const key in cachedTuple[1]) {
49+
if (cachedTuple[1][key] === newlySelected[key]) continue;
50+
selectededAreEqualToCached = false;
51+
break;
52+
}
53+
54+
if (selectededAreEqualToCached) return cachedTuple[1];
55+
}
56+
57+
cachedTuple = [currentValue, newlySelected];
58+
return cachedTuple[1];
59+
};
2860
}, [store, selector]);
2961

62+
const state = useSyncExternalStore(wrappedSubscription, wrappedSnapshot);
63+
3064
return state;
3165
}

0 commit comments

Comments
 (0)