diff --git a/CHANGELOG.md b/CHANGELOG.md
index d339d242cc9..cc6244d9387 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -11,6 +11,9 @@
- Prevent full reobservation of queries affected by optimistic mutation updates, while still delivering results from the cache.
[@benjamn](https://github.com/benjamn) in [#6854](https://github.com/apollographql/apollo-client/pull/6854)
+- Implement `useReactiveVar` hook for consuming reactive variables in React components.
+ [@benjamn](https://github.com/benjamn) in [#6867](https://github.com/apollographql/apollo-client/pull/6867)
+
## Apollo Client 3.1.3
## Bug Fixes
diff --git a/docs/source/local-state/managing-state-with-field-policies.mdx b/docs/source/local-state/managing-state-with-field-policies.mdx
index a8d0448eeb4..e25ed7b8cec 100644
--- a/docs/source/local-state/managing-state-with-field-policies.mdx
+++ b/docs/source/local-state/managing-state-with-field-policies.mdx
@@ -147,15 +147,14 @@ This `read` function returns the value of our reactive variable whenever `cartIt
Now, let's create a button component that enables the user to add a product to their cart:
-```jsx{8}:title=AddToCartButton.js
+```jsx{7}:title=AddToCartButton.js
import { cartItemsVar } from './cache';
// ... other imports
export function AddToCartButton({ productId }) {
- const cartItems = cartItemsVar();
return (
- cartItemsVar([...cartItems, productId])}>
+ cartItemsVar([...cartItemsVar(), productId])}>
Add to Cart
@@ -167,8 +166,6 @@ On click, this button updates the value of `cartItemsVar` to append the button's
Here's a `Cart` component that uses the `GET_CART_ITEMS` query and therefore refreshes automatically whenever the value of `cartItemsVar` changes:
-
-
```jsx:title=Cart.js
export const GET_CART_ITEMS = gql`
query GetCartItems {
@@ -199,7 +196,32 @@ export function Cart() {
}
```
-
+Alternatively, you can read directly from a reactive variable using the `useReactiveVar` hook introduced in Apollo Client 3.2.0:
+
+```jsx:title=Cart.js
+import { useReactiveVar } from '@apollo/client';
+
+export function Cart() {
+ const cartItems = useReactiveVar(cartItemsVar);
+
+ return (
+
+
+ {cartItems.length === 0 ? (
+
No items in your cart
+ ) : (
+
+ {cartItems.map(productId => (
+
+ ))}
+
+ )}
+
+ );
+}
+```
+
+As in the earlier `useQuery` example, whenever the `cartItemsVar` variable is updated, any currently-mounted `Cart` components will rerender. Calling `cartItemsVar()` without `useReactiveVar` will not capture this dependency, so future variable updates will not rerender the component. Both of these approaches are useful in different situations.
### Storing local state in the cache
diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap
index 91326b9ce76..6f7153ca44b 100644
--- a/src/__tests__/__snapshots__/exports.ts.snap
+++ b/src/__tests__/__snapshots__/exports.ts.snap
@@ -52,6 +52,7 @@ Array [
"useLazyQuery",
"useMutation",
"useQuery",
+ "useReactiveVar",
"useSubscription",
]
`;
@@ -216,6 +217,7 @@ Array [
"useLazyQuery",
"useMutation",
"useQuery",
+ "useReactiveVar",
"useSubscription",
]
`;
@@ -262,6 +264,7 @@ Array [
"useLazyQuery",
"useMutation",
"useQuery",
+ "useReactiveVar",
"useSubscription",
]
`;
diff --git a/src/cache/inmemory/__tests__/cache.ts b/src/cache/inmemory/__tests__/cache.ts
index b495e002d51..f2b7c22b8eb 100644
--- a/src/cache/inmemory/__tests__/cache.ts
+++ b/src/cache/inmemory/__tests__/cache.ts
@@ -3,6 +3,7 @@ import gql, { disableFragmentWarnings } from 'graphql-tag';
import { stripSymbols } from '../../../utilities/testing/stripSymbols';
import { cloneDeep } from '../../../utilities/common/cloneDeep';
import { makeReference, Reference, makeVar, TypedDocumentNode, isReference } from '../../../core';
+import { Cache } from '../../../cache';
import { InMemoryCache, InMemoryCacheConfig } from '../inMemoryCache';
disableFragmentWarnings();
@@ -2495,6 +2496,105 @@ describe("ReactiveVar and makeVar", () => {
},
});
});
+
+ it("should broadcast only once for multiple reads of same variable", () => {
+ const nameVar = makeVar("Ben");
+ const cache = new InMemoryCache({
+ typePolicies: {
+ Query: {
+ fields: {
+ name() {
+ return nameVar();
+ },
+ },
+ },
+ },
+ });
+
+ // TODO This should not be necessary, but cache.readQuery currently
+ // returns null if we read a query before writing any queries.
+ cache.restore({
+ ROOT_QUERY: {}
+ });
+
+ const broadcast = cache["broadcastWatches"];
+ let broadcastCount = 0;
+ cache["broadcastWatches"] = function () {
+ ++broadcastCount;
+ return broadcast.apply(this, arguments);
+ };
+
+ const query = gql`
+ query {
+ name1: name
+ name2: name
+ }
+ `;
+
+ const watchDiffs: Cache.DiffResult[] = [];
+ cache.watch({
+ query,
+ optimistic: true,
+ callback(diff) {
+ watchDiffs.push(diff);
+ },
+ });
+
+ const benResult = cache.readQuery({ query });
+ expect(benResult).toEqual({
+ name1: "Ben",
+ name2: "Ben",
+ });
+
+ expect(watchDiffs).toEqual([]);
+
+ expect(broadcastCount).toBe(0);
+ nameVar("Jenn");
+ expect(broadcastCount).toBe(1);
+
+ const jennResult = cache.readQuery({ query });
+ expect(jennResult).toEqual({
+ name1: "Jenn",
+ name2: "Jenn",
+ });
+
+ expect(watchDiffs).toEqual([
+ {
+ complete: true,
+ result: {
+ name1: "Jenn",
+ name2: "Jenn",
+ },
+ },
+ ]);
+
+ expect(broadcastCount).toBe(1);
+ nameVar("Hugh");
+ expect(broadcastCount).toBe(2);
+
+ const hughResult = cache.readQuery({ query });
+ expect(hughResult).toEqual({
+ name1: "Hugh",
+ name2: "Hugh",
+ });
+
+ expect(watchDiffs).toEqual([
+ {
+ complete: true,
+ result: {
+ name1: "Jenn",
+ name2: "Jenn",
+ },
+ },
+ {
+ complete: true,
+ result: {
+ name1: "Hugh",
+ name2: "Hugh",
+ },
+ },
+ ]);
+ });
});
describe('TypedDocumentNode', () => {
diff --git a/src/cache/inmemory/reactiveVars.ts b/src/cache/inmemory/reactiveVars.ts
index c702104aa59..f063bb03546 100644
--- a/src/cache/inmemory/reactiveVars.ts
+++ b/src/cache/inmemory/reactiveVars.ts
@@ -3,7 +3,12 @@ import { dep } from "optimism";
import { InMemoryCache } from "./inMemoryCache";
import { ApolloCache } from '../../core';
-export type ReactiveVar = (newValue?: T) => T;
+export interface ReactiveVar {
+ (newValue?: T): T;
+ onNextChange(listener: ReactiveListener): () => void;
+}
+
+export type ReactiveListener = (value: T) => any;
const varDep = dep>();
@@ -11,17 +16,34 @@ const varDep = dep>();
// called in Policies#readField.
export const cacheSlot = new Slot>();
+// A listener function could in theory cause another listener to be added
+// to the set while we're iterating over it, so it's important to commit
+// to the original elements of the set before we begin iterating. See
+// iterateObserversSafely for another example of this pattern.
+function consumeAndIterate(set: Set, callback: (item: T) => any) {
+ const items: T[] = [];
+ set.forEach(item => items.push(item));
+ set.clear();
+ items.forEach(callback);
+}
+
export function makeVar(value: T): ReactiveVar {
const caches = new Set>();
+ const listeners = new Set>();
- return function rv(newValue) {
+ const rv: ReactiveVar = function (newValue) {
if (arguments.length > 0) {
if (value !== newValue) {
value = newValue!;
+ // First, invalidate any fields with custom read functions that
+ // consumed this variable, so query results involving those fields
+ // will be recomputed the next time we read them.
varDep.dirty(rv);
- // Trigger broadcast for any caches that were previously involved
- // in reading this variable.
+ // Next, broadcast changes to any caches that have previously read
+ // from this variable.
caches.forEach(broadcast);
+ // Finally, notify any listeners added via rv.onNextChange.
+ consumeAndIterate(listeners, listener => listener(value));
}
} else {
// When reading from the variable, obtain the current cache from
@@ -34,12 +56,21 @@ export function makeVar(value: T): ReactiveVar {
return value;
};
+
+ rv.onNextChange = listener => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ };
+
+ return rv;
}
type Broadcastable = ApolloCache & {
// This method is protected in InMemoryCache, which we are ignoring, but
// we still want some semblance of type safety when we call it.
- broadcastWatches: InMemoryCache["broadcastWatches"];
+ broadcastWatches?: InMemoryCache["broadcastWatches"];
};
function broadcast(cache: Broadcastable) {
diff --git a/src/react/hooks/__tests__/useReactiveVar.test.tsx b/src/react/hooks/__tests__/useReactiveVar.test.tsx
new file mode 100644
index 00000000000..2cef7bfe836
--- /dev/null
+++ b/src/react/hooks/__tests__/useReactiveVar.test.tsx
@@ -0,0 +1,183 @@
+import React from "react";
+import { render, wait, act } from "@testing-library/react";
+
+import { itAsync } from "../../../testing";
+import { makeVar } from "../../../core";
+import { useReactiveVar } from "../useReactiveVar";
+
+describe("useReactiveVar Hook", () => {
+ itAsync("works with one component", (resolve, reject) => {
+ const counterVar = makeVar(0);
+ let renderCount = 0;
+
+ function Component() {
+ const count = useReactiveVar(counterVar);
+
+ switch (++renderCount) {
+ case 1:
+ expect(count).toBe(0);
+ act(() => {
+ counterVar(count + 1);
+ });
+ break;
+ case 2:
+ expect(count).toBe(1);
+ act(() => {
+ counterVar(counterVar() + 2);
+ });
+ break;
+ case 3:
+ expect(count).toBe(3);
+ break;
+ default:
+ reject(`too many (${renderCount}) renders`);
+ }
+
+ return null;
+ }
+
+ render( );
+
+ return wait(() => {
+ expect(renderCount).toBe(3);
+ expect(counterVar()).toBe(3);
+ }).then(resolve, reject);
+ });
+
+ itAsync("works when two components share a variable", async (resolve, reject) => {
+ const counterVar = makeVar(0);
+
+ let parentRenderCount = 0;
+ function Parent() {
+ const count = useReactiveVar(counterVar);
+
+ switch (++parentRenderCount) {
+ case 1:
+ expect(count).toBe(0);
+ break;
+ case 2:
+ expect(count).toBe(1);
+ break;
+ case 3:
+ expect(count).toBe(11);
+ break;
+ default:
+ reject(`too many (${parentRenderCount}) parent renders`);
+ }
+
+ return ;
+ }
+
+ let childRenderCount = 0;
+ function Child() {
+ const count = useReactiveVar(counterVar);
+
+ switch (++childRenderCount) {
+ case 1:
+ expect(count).toBe(0);
+ break;
+ case 2:
+ expect(count).toBe(1);
+ break;
+ case 3:
+ expect(count).toBe(11);
+ break;
+ default:
+ reject(`too many (${childRenderCount}) child renders`);
+ }
+
+ return null;
+ }
+
+ render( );
+
+ await wait(() => {
+ expect(parentRenderCount).toBe(1);
+ expect(childRenderCount).toBe(1);
+ });
+
+ expect(counterVar()).toBe(0);
+ act(() => {
+ counterVar(1);
+ });
+
+ await wait(() => {
+ expect(parentRenderCount).toBe(2);
+ expect(childRenderCount).toBe(2);
+ });
+
+ expect(counterVar()).toBe(1);
+ act(() => {
+ counterVar(counterVar() + 10);
+ });
+
+ await wait(() => {
+ expect(parentRenderCount).toBe(3);
+ expect(childRenderCount).toBe(3);
+ });
+
+ expect(counterVar()).toBe(11);
+
+ resolve();
+ });
+
+ itAsync("does not update if component has been unmounted", (resolve, reject) => {
+ const counterVar = makeVar(0);
+ let renderCount = 0;
+ let attemptedUpdateAfterUnmount = false;
+
+ function Component() {
+ const count = useReactiveVar(counterVar);
+
+ switch (++renderCount) {
+ case 1:
+ expect(count).toBe(0);
+ act(() => {
+ counterVar(count + 1);
+ });
+ break;
+ case 2:
+ expect(count).toBe(1);
+ act(() => {
+ counterVar(counterVar() + 2);
+ });
+ break;
+ case 3:
+ expect(count).toBe(3);
+ setTimeout(() => {
+ unmount();
+ setTimeout(() => {
+ counterVar(counterVar() * 2);
+ attemptedUpdateAfterUnmount = true;
+ }, 10);
+ }, 10);
+ break;
+ default:
+ reject(`too many (${renderCount}) renders`);
+ }
+
+ return null;
+ }
+
+ // To detect updates of unmounted components, we have to monkey-patch
+ // the console.error method.
+ const consoleErrorArgs: any[][] = [];
+ const { error } = console;
+ console.error = function (...args: any[]) {
+ consoleErrorArgs.push(args);
+ return error.apply(this, args);
+ };
+
+ const { unmount } = render( );
+
+ return wait(() => {
+ expect(attemptedUpdateAfterUnmount).toBe(true);
+ }).then(() => {
+ expect(renderCount).toBe(3);
+ expect(counterVar()).toBe(6);
+ expect(consoleErrorArgs).toEqual([]);
+ }).finally(() => {
+ console.error = error;
+ }).then(resolve, reject);
+ });
+});
diff --git a/src/react/hooks/index.ts b/src/react/hooks/index.ts
index b301bfed59d..a9a323f7fc1 100644
--- a/src/react/hooks/index.ts
+++ b/src/react/hooks/index.ts
@@ -3,3 +3,4 @@ export * from './useLazyQuery';
export * from './useMutation';
export * from './useQuery';
export * from './useSubscription';
+export * from './useReactiveVar';
diff --git a/src/react/hooks/useReactiveVar.ts b/src/react/hooks/useReactiveVar.ts
new file mode 100644
index 00000000000..4bb277413be
--- /dev/null
+++ b/src/react/hooks/useReactiveVar.ts
@@ -0,0 +1,15 @@
+import { useState, useEffect } from 'react';
+import { ReactiveVar } from '../../core';
+
+export function useReactiveVar(rv: ReactiveVar): T {
+ const value = rv();
+ // We don't actually care what useState thinks the value of the variable
+ // is, so we take only the update function from the returned array.
+ const mute = rv.onNextChange(useState(value)[1]);
+ // Once the component is unmounted, ignore future updates. Note that the
+ // useEffect function returns the mute function without calling it,
+ // allowing it to be called when the component unmounts. This is
+ // equivalent to useEffect(() => () => mute(), []), but shorter.
+ useEffect(() => mute, []);
+ return value;
+}