diff --git a/src/hooks/useDeepCompareEffect.ts b/src/hooks/useDeepCompareEffect.ts new file mode 100644 index 000000000..db937bba7 --- /dev/null +++ b/src/hooks/useDeepCompareEffect.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef } from 'react'; +import isEqual from 'lodash/isEqual'; + +function useDeepCompareMemoize(value: T): T { + const ref = useRef(value); + + if (!isEqual(value, ref.current)) { + ref.current = value; + } + + return ref.current; +} + +/** + * Custom hook that works like useEffect but performs a deep comparison of dependencies + * instead of reference equality. This is useful when dealing with complex objects or arrays + * in dependencies that could trigger unnecessary re-renders. + * + * Inspired by https://github.com/kentcdodds/use-deep-compare-effect + * + * @param callback Effect callback that can either return nothing (void) or return a cleanup function (() => void). + * @param dependencies Array of dependencies to be deeply compared + */ +function useDeepCompareEffect( + callback: () => void | (() => void), + dependencies: any[], +) { + useEffect(callback, dependencies.map(useDeepCompareMemoize)); +} + +export default useDeepCompareEffect; diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts index ccd906318..bd6324068 100644 --- a/src/hooks/useStore.ts +++ b/src/hooks/useStore.ts @@ -1,6 +1,6 @@ -import { useContext, useRef, useCallback } from 'react'; +import { useContext, useRef, useCallback, useMemo } from 'react'; import { useSyncExternalStore } from 'use-sync-external-store/shim'; -import { type Store } from '../utils/storeManager'; +import { type Store, hasStateChanged } from '../utils/storeManager'; type StoreSelector = (state: T) => U; @@ -19,6 +19,7 @@ export function useStore( if (!store) { throw new Error('useStore must be used within a StoreProvider'); } + // Ensure the stability of the selector function using useRef const selectorRef = useRef(selector); selectorRef.current = selector; @@ -36,14 +37,18 @@ export function useStore( ); const updateState = useCallback((updates: Partial) => { - store.setState((prevState) => ({ - ...prevState, - ...updates, - })); + const currentState = store.getState(); + + if (hasStateChanged(currentState, updates)) { + store.setState((prevState) => ({ + ...prevState, + ...updates, + })); + } }, [store]); - return { + return useMemo(() => ({ state, updateState, - }; + }), [state, updateState]); } diff --git a/src/modules/GroupChannel/context/GroupChannelProvider.tsx b/src/modules/GroupChannel/context/GroupChannelProvider.tsx index b9a6a9fda..934077cbe 100644 --- a/src/modules/GroupChannel/context/GroupChannelProvider.tsx +++ b/src/modules/GroupChannel/context/GroupChannelProvider.tsx @@ -30,6 +30,7 @@ import type { GroupChannelState, } from './types'; import useSendbird from '../../../lib/Sendbird/context/hooks/useSendbird'; +import useDeepCompareEffect from '../../../hooks/useDeepCompareEffect'; const initialState = { currentChannel: null, @@ -280,7 +281,7 @@ const GroupChannelManager :React.FC { + useDeepCompareEffect(() => { updateState({ // Channel state channelUrl, diff --git a/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx b/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx index bce7be552..503a74653 100644 --- a/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx +++ b/src/modules/GroupChannel/context/hooks/useMessageListScroll.tsx @@ -1,5 +1,6 @@ import { DependencyList, useLayoutEffect, useRef, useState } from 'react'; import pubSubFactory from '../../../../lib/pubSub'; +import { useGroupChannel } from './useGroupChannel'; /** * You can pass the resolve function to scrollPubSub, if you want to catch when the scroll is finished. @@ -30,7 +31,10 @@ export function useMessageListScroll(behavior: 'smooth' | 'auto', deps: Dependen const scrollDistanceFromBottomRef = useRef(0); const [scrollPubSub] = useState(() => pubSubFactory({ publishSynchronous: true })); - const [isScrollBottomReached, setIsScrollBottomReached] = useState(true); + const { + state: { isScrollBottomReached }, + actions: { setIsScrollBottomReached }, + } = useGroupChannel(); // SideEffect: Reset scroll state useLayoutEffect(() => { diff --git a/src/utils/storeManager.ts b/src/utils/storeManager.ts index 166a3c853..1274cee05 100644 --- a/src/utils/storeManager.ts +++ b/src/utils/storeManager.ts @@ -5,17 +5,36 @@ export type Store = { subscribe: (listener: () => void) => () => void; }; +export function hasStateChanged(prevState: T, updates: Partial): boolean { + return Object.entries(updates).some(([key, value]) => { + return prevState[key as keyof T] !== value; + }); +} + /** * A custom store creation utility */ export function createStore(initialState: T): Store { let state = { ...initialState }; const listeners = new Set<() => void>(); + let isUpdating = false; const setState = (partial: Partial | ((state: T) => Partial)) => { - const nextState = typeof partial === 'function' ? partial(state) : partial; - state = { ...state, ...nextState }; - listeners.forEach((listener) => listener()); + // Prevent nested updates + if (isUpdating) return; + + try { + isUpdating = true; + const nextState = typeof partial === 'function' ? partial(state) : partial; + const hasChanged = hasStateChanged(state, nextState); + + if (hasChanged) { + state = { ...state, ...nextState }; + listeners.forEach((listener) => listener()); + } + } finally { + isUpdating = false; + } }; return {