apollographql / apollo-feature-requests

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

Derived State from Reactive Variables (without using a TypePolicy) #305

Open DanielBoa opened 3 years ago

DanielBoa commented 3 years ago

Problem

To achieve derived local state when using ReactiveVar's you must define a field policy on the InMemoryCache.

Example Let's create some local state ```TypeScript import { makeVar } from "@apollo/client"; interface Item { name: string; price: number; } export const items = makeVar>([ { name: "GameBoy Pocket", price: 50 }, { name: "GameBoy Advance", price: 30 }, ]); ``` I want a derived total that is only calculated when my item list changes, I need to do this where I define my cache using a pretty different API ```TypeScript import { InMemoryCache } from "@apollo/client"; import { items } from "./state"; export const cache: InMemoryCache = new InMemoryCache({ typePolicies: { Query: { fields: { total: { read() { return items().reduce((acc, item) => acc + item.price, 0); } } } } } }); ``` Then to use this I must write a GQL query ```tsx const GET_TOTAL = gql` query GetTotal { total @client } `; const ListSummary = () => { const getTotalResult = useQuery(GET_TOTAL); return (
Total: £{getTotalResult.data.total}
); }; ```

Why is this a problem?

I see some problems with this approach:

  1. The FieldPolicy API feels very different from that of Reactive Variables - I can see that it makes sense when I want to export the value for use in a query, or attach the derived value to a type defined in a Schema, but sometimes (as in the above example) I really only want to use the derived value locally and associating it with the Cache feels unnecessary
    • Yes, you could easily use useMemo but if you then want to use the derived state in multiple places you'd still be deriving it multiple times. As soon as you try and avoid this I think you stray into developing a custom solution like the below proposed solution
    • I'm not sure if I'm missing something - please tell me if so - but it seems unless I'm associating the derived field with an existing Schema I need to just associate the field with the base Query. It would be nicer having a bit more control over the structure and organisation of local state
  2. I could see a scenario where the same configured Apollo Client is shared amongst multiple Micro Front-Ends within an organisation, however you might want to also use AC for the local state management within each of these MFE's. In this scenario you might want derived local state that isn't coupled to the cache
  3. I have to write a GQL query to get my derived state out of the cache
    • In the process I've lost type safety, I can pass a type into useQuery but if GQL and the cache were avoided all the types could be inferred
    • I have to dereference any returned value e.g. queryResult.data.myDerivedValue
    • useQuery makes it less explicit I'm depending on local state. It looks exactly the same as an actual query, except for the fact I'm not handling error and loading states
  4. Most other local state management solutions solve this for you in a similar fashion to the one I'm proposing

Suggested Solution

An API for creating a DerivedReactiveVar. Something akin to reselect.

Usage

export const items = makeVar<Array<Item>>([
  { name: "GameBoy Pocket", price: 50 },
  { name: "GameBoy Advance", price: 30 }
]);

export const balance = makeVar(1000);

export const total = makeDerivedVar([items], (items) =>
  items.reduce((acc, item) => acc + item.price, 0)
);

export const remainingBalance = makeDerivedVar(
  [balance, total],
  (balance, total) => balance - total
);

// could use same `useReactiveVar` hook for derived data
const ListSummary = () => {
  const totalValue = useReactiveVar(total);
  const remainingBalanceValue = useReactiveVar(remainingBalance);

  return (
    <>
      <div>
        <span>Total: </span>
        <b>£{totalValue}</b>
      </div>
      <div>
        <span>Balance After: </span>
        <b>£{remainingBalanceValue}</b>
      </div>
    </>
  );
};

Some Typing


interface DerivedReactiveVar<T> {
  (): T;
}

// allow DerivedReactiveVar and ReactiveVar to be used interchangeably
type AllReactiveVars<T> = ReactiveVar<T> | DerivedReactiveVar<T>;

// [AllReactiveVars<number>, AllReactiveVars<string>] => [number, string]
type ExtractReactiveVarTupleTypes<
  T extends ReadonlyArray<AllReactiveVars<any>>
> = { [K in keyof T]: T[K] extends AllReactiveVars<infer V> ? V : never };

// Almost exactly the same as reselect, used an array to avoid having to use type overloading
function makeDerivedVar<V extends AllReactiveVars<any>[], T extends [...V], O>(
  reactiveVars: T,
  combiner: (...args: ExtractReactiveVarTupleTypes<T>) => O
): DerivedReactiveVar<O> {
  // ... magic implementation
  return (() => {}) as DerivedReactiveVar<O>;
}
pxwee5 commented 2 years ago

From what I can understand this is pretty much a computed state. If this is the case, can we name this computedVar or something so that it's similar to the name used by other state managers?