-
Notifications
You must be signed in to change notification settings - Fork 2.7k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Implement useReactiveVar hook for consuming reactive variables in React components. #6867
Merged
+369
−11
Merged
Changes from 4 commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
45179b9
Generalize listening API for reactive variables.
benjamn 2f494c8
Implement useReactiveVar hook.
benjamn 141794c
Mention PR #6867 in CHANGELOG.md.
benjamn 002e199
Docs for useReactiveVar.
benjamn b9382b0
Avoid visiting new listeners added by other listeners.
benjamn b874104
Store broadcastable caches separately from listeners.
benjamn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 ( | ||
<div class="add-to-cart-button"> | ||
<Button onClick={() => cartItemsVar([...cartItems, productId])}> | ||
<Button onClick={() => cartItemsVar([...cartItemsVar(), productId])}> | ||
Add to Cart | ||
</Button> | ||
</div> | ||
|
@@ -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: | ||
|
||
<ExpansionPanel title="Expand example"> | ||
|
||
```jsx:title=Cart.js | ||
export const GET_CART_ITEMS = gql` | ||
query GetCartItems { | ||
|
@@ -199,7 +196,32 @@ export function Cart() { | |
} | ||
``` | ||
|
||
</ExpansionPanel> | ||
Alternatively, you can read directly from a reactive variable using the `useReactiveVar` hook introduced in Apollo Client 3.2.0: | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @StephenBarlow Is it okay to refer to an Apollo Client version number in the docs like this? |
||
|
||
```jsx:title=Cart.js | ||
import { useReactiveVar } from '@apollo/client'; | ||
|
||
export function Cart() { | ||
const cartItems = useReactiveVar(cartItemsVar); | ||
|
||
return ( | ||
<div class="cart"> | ||
<Header>My Cart</Header> | ||
{cartItems.length === 0 ? ( | ||
<p>No items in your cart</p> | ||
) : ( | ||
<Fragment> | ||
{cartItems.map(productId => ( | ||
<CartItem key={productId} /> | ||
))} | ||
</Fragment> | ||
)} | ||
</div> | ||
); | ||
} | ||
``` | ||
|
||
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 | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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(<Component/>); | ||
|
||
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 <Child/>; | ||
} | ||
|
||
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(<Parent/>); | ||
|
||
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(<Component/>); | ||
|
||
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); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import { useState, useEffect } from 'react'; | ||
import { ReactiveVar } from '../../core'; | ||
|
||
export function useReactiveVar<T>(rv: ReactiveVar<T>): 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; | ||
} |
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This was risky because the button could be clicked long after the component was first rendered.