apollographql / apollo-feature-requests

🧑‍🚀 Apollo Client Feature Requests | (no 🐛 please).
Other
130 stars 7 forks source link

Add selector to useReactiveVar hook #281

Open delyanr opened 3 years ago

delyanr commented 3 years ago

Hello!

I would like to request the addition of an argument (or two) to the useReactiveVar hook, that can be used to specify a subset of selected values, where if the values in the subset do not change, the component will bail out of re-rendering, even if the entire set of values does change. This is similar to many other existing APIs in redux-land, for example. Proposed API (the third argument is an optional function to assert equality):

const selection = useReactiveVar(myVar, (v) => select(v), isEqual)

The above can currently be achieved using the useQuery and @client directive combination, but usually requires multiple gql schema definitions, which is unnecessarily verbose and cluttering, when used across many components. Alternatively, the various values can be defined using multiple reactive vars, but then we lose the benefits of co-location.

This could also easily be implemented as a brand new hook as well - useReactiveVarSelector or similar.

Below is a super simple example just to avoid confusion.

Consider when you want to co-locate some global configuration into one central reactive variable:

export const initialConfig = {
  field1: null,
  field2: null,
  field3: null,
};

const configVar = makeVar(initialConfig);

const fields = {
  config: {
    read() {
      return configVar();
    },
  },
};

If a component uses useReactiveVar to get field1, the component would need to re-render even if field2 and/or field3 change, which is not ideal:

const Component = () => {
  const config = useReactiveVar(configVar);

  return <div>{config.field1}</div>
}

This can be mitigated with:

const Component = () => {
  const { data } = useQuery(gql`
    query GetConfig {
      config @client {
        field1
      }
    }
  `);

  return <div>{data.config.field1}</div>
}

However, it would be a lot more cleaner to be able to use:

const Component = () => {
  const field1 = useReactiveVar(configVar, (c) => c.field1);

  return <div>{field1}</div>
}

Thanks.

hssrrw commented 3 years ago

We use a reactive variable as a complex state object. It would be really useful to have a way to react to changes in a particular part of this data.

Reaction to changes in some bits of the data state is also a problem for React Context value. That's why the React core team is working on selectors as well with a similar API: https://github.com/facebook/react/pull/20646 https://github.com/reactjs/rfcs/pull/119

I hope, we will see something like that in Apollo Client for React as well.

DanielBoa commented 3 years ago

Didn't actually see this prior to creating another proposal which is related.

Issue in question: #305

smikula commented 2 years ago

I'm really interested in a feature like this, too. I think this would be the answer to the question I just posted here.

viliket commented 1 year ago

I would also find this feature useful. For my specific needs for now, I implemented a custom version shown below that is inspired by react-redux useSelector hook:

useReactiveVarWithSelector.ts

import { ReactiveVar } from '@apollo/client';
import { useSyncExternalStoreWithSelector } from 'use-sync-external-store/with-selector';

function useReactiveVarWithSelector<T, Selection>(
  rv: ReactiveVar<T>,
  selector: (state: T) => Selection,
  isEqual?: (a: Selection, b: Selection) => boolean
): Selection {
  const value = useSyncExternalStoreWithSelector(
    (onStoreChange: () => void) => {
      let unsubscribe: () => void;
      const listener = () => {
        // Notify parent listener that the variable has changed
        onStoreChange();

        // When variable value is changed, apollo-client notifies all the listeners
        // and then clears all the listeners. Thus, we need to resubscribe to the
        // next variable value change.
        // See https://github.com/apollographql/apollo-client/blob/main/src/cache/inmemory/reactiveVars.ts
        unsubscribe = rv.onNextChange(listener);
      };

      unsubscribe = rv.onNextChange(listener);
      return () => unsubscribe();
    },
    rv,
    rv,
    selector,
    isEqual
  );

  return value;
}

export default useReactiveVarWithSelector;

The hook leverages the useSyncExternalStoreWithSelector from React's own package use-sync-external-store. The subscribe parameter passed to the useSyncExternalStoreWithSelector uses apollo-client ReactiveVar's onNextChange subscribe method (see https://github.com/apollographql/apollo-client/blob/main/src/cache/inmemory/reactiveVars.ts#L51) that works so that when the ReactiveVar's value changes, the subscribers are notified and then the subscriptions are cleared. Hence the need to resubscribe the listener on every change to the variable.

Paso commented 1 year ago

Just a heads up that vilikets code above crashes when running on the server (ie SSR). I replaced the ùndefinedparameter withrv´ to get around it but don't really understand the consequences of it.

viliket commented 1 year ago

@Paso Good notice, I had not tested the code with SSR and left the third parameter as undefined. According to React's documentation on useSyncExternalStore, the third parameter getServerSnapshot should be set as the function that returns the snapshot used during server side rendering. See the provided example there:

When server rendering, you must serialize the store value used on the server, and provide it to useSyncExternalStore. React will use this snapshot during hydration to prevent server mismatches:

const selectedField = useSyncExternalStore(
store.subscribe,
() => store.getSnapshot().selectedField,
() => INITIAL_SERVER_SNAPSHOT.selectedField,
);

In the context of apollo-client's reactive variables, it should be fine to just set this parameter as the reactive variable itself (i.e., rv in my original comment) so that during the SSR the server snapshot would simply be the (initial) value of the reactive variable. I also updated the provided code to take this into account.