use-context-selector icon indicating copy to clipboard operation
use-context-selector copied to clipboard

Don't work with react 18.2

Open liknenS opened this issue 1 year ago • 10 comments

Components used useContextSelector with return save value always rerenders.

Example https://codesandbox.io/s/musing-poincare-hz2g6g?file=/src/App.js

liknenS avatar Oct 27 '23 13:10 liknenS

That's the intended behavior by design. Wrapping with useEffect should show more intuitive result: https://codesandbox.io/s/interesting-mccarthy-m8vzyl?file=/src/App.js

dai-shi avatar Oct 27 '23 14:10 dai-shi

I have more than 1000 components with useContextSeletor. Any changes in context value trigger call render function of all components.

Even if it doesn't cause effects and update the DOM(only call render function), it takes a lot of computing resources.

I change example and add Redux example: https://codesandbox.io/s/muddy-bush-ndqqwg?file=/src/App.js With 10 components i has next stats:

aRender: 100
aEffect: 21
aChange: 2

bRender: 22
bEffect: 21
bChange: 2

100 components:

aRender: 800
aEffect: 201
aChange: 1

bRender: 202
bEffect: 201
bChange: 1

liknenS avatar Oct 30 '23 10:10 liknenS

one more example - symetric updates states + calc render time, 1000 nodes: https://codesandbox.io/s/muddy-bush-ndqqwg?file=/src/App.js

stats:

change: 100

aRender: 206000
aEffect: 2200
aTime: 321.1000003814697

bRender: 2400
bEffect: 2200
bTime: 11.500000953674316

liknenS avatar Oct 30 '23 10:10 liknenS

Even if it doesn't cause effects and update the DOM(only call render function), it takes a lot of computing resources.

One workaround is to useMemo.

Another would be to use subscription based state management such as Redux / Zustand / ...

You can also implement a simplified useContextSelector without concurrency support pretty easily.

Seel also #100

dai-shi avatar Oct 30 '23 12:10 dai-shi

import {
  createContext as createContextOrig,
  useContext as useContextOrig,
  useRef,
  useSyncExternalStore,
} from 'react';

export const createContext = (defaultValue) => {
  const context = createContextOrig();
  const ProviderOrig = context.Provider;
  context.Provider = ({ value, children }) => {
    const storeRef = useRef();
    let store = storeRef.current;
    if (!store) {
      const listeners = new Set();
      store = {
        value,
        subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); },
        notify: () => listeners.forEach((l) => l()),
      }
      storeRef.current = store;
    }
    useEffect(() => {
      if (!Object.is(store.value, value)) {
        store.value = value;
        store.notify();
      }
    });
    return <ProviderOrig value={store}>{children}</ProviderOrig>
  };
  return context;
}

export const useContextSelector = (context, selector) => {
  const store = useContextOrig(context);
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.value),
  );
};

dai-shi avatar Oct 30 '23 13:10 dai-shi

import {
  createContext as createContextOrig,
  useContext as useContextOrig,
  useRef,
  useSyncExternalStore,
} from 'react';

export const createContext = (defaultValue) => {
  const context = createContextOrig();
  const ProviderOrig = context.Provider;
  context.Provider = ({ value, children }) => {
    const storeRef = useRef();
    let store = storeRef.current;
    if (!store) {
      const listeners = new Set();
      store = {
        value,
        subscribe: (l) => { listeners.add(l); return () => listeners.delete(l); },
        notify: () => listeners.forEach((l) => l()),
      }
      storeRef.current = store;
    }
    useEffect(() => {
      if (!Object.is(store.value, value)) {
        store.value = value;
        store.notify();
      }
    });
    return <ProviderOrig value={store}>{children}</ProviderOrig>
  };
  return context;
}

export const useContextSelector = (context, selector) => {
  const store = useContextOrig(context);
  return useSyncExternalStore(
    store.subscribe,
    () => selector(store.value),
  );
};

This works in React 18.2 and even works with useImmer.

Jayatubi avatar Nov 24 '23 07:11 Jayatubi

Great to hear that, because I haven't tried it. 😁

dai-shi avatar Nov 24 '23 08:11 dai-shi

However, useContextSelector could only return a single result. I was tring to get multiple entries such as const { a, b } = useContextSelector(context, (root)=>({a: root.a, b: root.b})) but it cause useSyncExternalStore enter an infinity loop.

Jayatubi avatar Nov 24 '23 10:11 Jayatubi

I tried to add a cache with shallowEqual, from redux, to workaround but I'm not sure if that is ok. I'm not familiar to React.

export function useContextSelector<T, Selected>(
  context: React.Context<ContextStore<T>>,
  selector: (value: T) => Selected,
) {
  const store = useContext(context);
  let cache: any;
  return useSyncExternalStore(store.subscribe, () => {
    const value = selector(store.value);
    if (!shallowEqual(cache, value)) {
      cache = value;
    }
    return cache;
  });
}

Jayatubi avatar Nov 24 '23 11:11 Jayatubi

Use https://www.npmjs.com/package/use-sync-external-store instead and pass shallowEqual.

dai-shi avatar Nov 24 '23 15:11 dai-shi