diff --git a/README.md b/README.md index cacc60e5..9ddd9fc3 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ function MyComponent() { - [useMutation](#useMutation) - Guides - [SSR](#SSR) + - [Pagination](#Pagination) - [Authentication](#Authentication) - [Fragments](#Fragments) - [Migrating from Apollo](#Migrating-from-Apollo) @@ -187,6 +188,9 @@ This is a custom hook that takes care of fetching your query and storing the res - `skipCache`: Boolean - defaults to `false`; If `true` it will by-pass the cache and fetch, but the result will then be cached for subsequent calls. Note the `refetch` function will do this automatically - `ssr`: Boolean - defaults to `true`. Set to `false` if you wish to skip this query during SSR - `fetchOptionsOverrides`: Object - Specific overrides for this query. See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch) for info on what options can be passed + - `updateData(previousData, data)`: Function - Custom handler for merging previous & new query results; return value will replace `data` in `useQuery` return value + - `previousData`: Previous GraphQL query or `updateData` result + - `data`: New GraphQL query result ### `useQuery` return value @@ -197,7 +201,8 @@ const { loading, error, data, refetch, cacheHit, ...errors } = useQuery(QUERY); - `loading`: Boolean - `true` if the query is in flight - `error`: Boolean - `true` if `fetchError` or `httpError` or `graphQLErrors` has been set - `data`: Object - the result of your GraphQL query -- `refetch`: Function - useful when refetching the same query after a mutation; NOTE this presets `skipCache=true` +- `refetch(options)`: Function - useful when refetching the same query after a mutation; NOTE this presets `skipCache=true` & will bypass the `options.updateData` function that was passed into `useQuery`. You can pass a new `updateData` into `refetch` if necessary. + - `options`: Object - options that will be merged into the `options` that were passed into `useQuery` (see above). - `cacheHit`: Boolean - `true` if the query result came from the cache, useful for debugging - `fetchError`: Object - Set if an error occured during the `fetch` call - `httpError`: Object - Set if an error response was returned from the server @@ -309,6 +314,130 @@ The `options` object that can be passed either to `useMutation(mutation, options See [graphql-hooks-ssr](https://github.com/nearform/graphql-hooks-ssr) for an in depth guide. +### Pagination + +[GraphQL Pagination](https://graphql.org/learn/pagination/) can be implemented in various ways and it's down to the consumer to decide how to deal with the resulting data from paginated queries. Take the following query as an example of offset pagination: + +```javascript +export const allPostsQuery = ` + query allPosts($first: Int!, $skip: Int!) { + allPosts(orderBy: createdAt_DESC, first: $first, skip: $skip) { + id + title + votes + url + createdAt + } + _allPostsMeta { + count + } + } +`; +``` + +In this query, the `$first` variable is used to limit the number of posts that are returned and the `$skip` variable is used to determine the offset at which to start. We can use these variables to break up large payloads into smaller chunks, or "pages". We could then choose to display these chunks as distinct pages to the user, or use an infinite loading approach and append each new chunk to the existing list of posts. + +#### Separate pages + +Here is an example where we display the paginated queries on separate pages: + +```jsx +import { React, useState } from 'react'; +import { useQuery } from 'graphql-hooks'; + +export default function PostList() { + // set a default offset of 0 to load the first page + const [skipCount, setSkipCount] = useState(0); + + const { loading, error, data } = useQuery(allPostsQuery, { + variables: { skip: skipCount, first: 10 } + }); + + if (error) return
There was an error!
; + if (loading && !data) return
Loading
; + + const { allPosts, _allPostsMeta } = data; + const areMorePosts = allPosts.length < _allPostsMeta.count; + + return ( +
+ + + +
+ ); +} +``` + +#### Infinite loading + +Here is an example where we append each paginated query to the bottom of the current list: + +```jsx +import { React, useState } from 'react'; +import { useQuery } from 'graphql-hooks'; + +// use options.updateData to append the new page of posts to our current list of posts +const updateData = (prevData, data) => ({ + ...data, + allPosts: [...prevData.allPosts, ...data.allPosts] +}); + +export default function PostList() { + const [skipCount, setSkipCount] = useState(0); + + const { loading, error, data } = useQuery( + allPostsQuery, + { variables: { skip: skipCount, first: 10 } }, + updateData + ); + + if (error) return
There was an error!
; + if (loading && !data) return
Loading
; + + const { allPosts, _allPostsMeta } = data; + const areMorePosts = allPosts.length < _allPostsMeta.count; + + return ( +
+ + {areMorePosts && ( + + )} +
+ ); +} +``` + ### Authentication Coming soon! diff --git a/src/useClientRequest.js b/src/useClientRequest.js index 8487495f..9d8ade65 100644 --- a/src/useClientRequest.js +++ b/src/useClientRequest.js @@ -88,7 +88,14 @@ function useClientRequest(query, initialOpts = {}) { } dispatch({ type: actionTypes.LOADING }); - const result = await client.request(revisedOperation, revisedOpts); + let result = await client.request(revisedOperation, revisedOpts); + + if (state.data && result.data && revisedOpts.updateData) { + if (typeof revisedOpts.updateData !== 'function') { + throw new Error('options.updateData must be a function'); + } + result.data = revisedOpts.updateData(state.data, result.data); + } if (revisedOpts.useCache && client.cache) { client.cache.set(revisedCacheKey, result); diff --git a/src/useQuery.js b/src/useQuery.js index 175b5f14..40a3d1e9 100644 --- a/src/useQuery.js +++ b/src/useQuery.js @@ -8,12 +8,10 @@ const defaultOpts = { }; module.exports = function useQuery(query, opts = {}) { + const allOpts = { ...defaultOpts, ...opts }; const client = React.useContext(ClientContext); const [calledDuringSSR, setCalledDuringSSR] = React.useState(false); - const [queryReq, state] = useClientRequest(query, { - ...defaultOpts, - ...opts - }); + const [queryReq, state] = useClientRequest(query, allOpts); if (client.ssrMode && opts.ssr !== false && !calledDuringSSR) { // result may already be in the cache from previous SSR iterations @@ -30,6 +28,14 @@ module.exports = function useQuery(query, opts = {}) { return { ...state, - refetch: () => queryReq({ skipCache: true }) + refetch: (options = {}) => + queryReq({ + skipCache: true, + // don't call the updateData that has been passed into useQuery here + // reset to the default behaviour of returning the raw query result + // this can be overridden in refetch options + updateData: (_, data) => data, + ...options + }) }; }; diff --git a/test/unit/useClientRequest.test.js b/test/unit/useClientRequest.test.js index 144e01fb..52c0b9d7 100644 --- a/test/unit/useClientRequest.test.js +++ b/test/unit/useClientRequest.test.js @@ -24,7 +24,7 @@ describe('useClientRequest', () => { get: jest.fn(), set: jest.fn() }, - request: jest.fn().mockResolvedValue({ some: 'data' }) + request: jest.fn().mockResolvedValue({ data: 'data' }) }; }); @@ -127,7 +127,7 @@ describe('useClientRequest', () => { { operationName: 'test', variables: { limit: 2 }, query: TEST_QUERY }, { operationName: 'test', variables: { limit: 2 } } ); - expect(state).toEqual({ cacheHit: false, loading: false, some: 'data' }); + expect(state).toEqual({ cacheHit: false, loading: false, data: 'data' }); }); it('calls request with revised options', async () => { @@ -181,7 +181,7 @@ describe('useClientRequest', () => { expect(state).toEqual({ cacheHit: false, loading: false, - some: 'data' + data: 'data' }); }); @@ -198,7 +198,7 @@ describe('useClientRequest', () => { expect(state).toEqual({ cacheHit: false, loading: false, - some: 'data' + data: 'data' }); }); @@ -212,7 +212,91 @@ describe('useClientRequest', () => { await fetchData(); expect(mockClient.cache.set).toHaveBeenCalledWith('cacheKey', { - some: 'data' + data: 'data' + }); + }); + + describe('options.updateRequest', () => { + it('is called with old & new data if the data has changed & the result is returned', async () => { + let fetchData, state; + const updateDataMock = jest.fn().mockReturnValue('merged data'); + testHook( + () => + ([fetchData, state] = useClientRequest(TEST_QUERY, { + variables: { limit: 10 }, + updateData: updateDataMock + })), + { wrapper: Wrapper } + ); + + // first fetch to populate state + await fetchData(); + + mockClient.request.mockResolvedValueOnce({ data: 'new data' }); + await fetchData({ variables: { limit: 20 } }); + + expect(updateDataMock).toHaveBeenCalledWith('data', 'new data'); + expect(state).toEqual({ + cacheHit: false, + data: 'merged data', + loading: false + }); + }); + + it('is not called if there is no old data', async () => { + let fetchData; + const updateDataMock = jest.fn(); + testHook( + () => + ([fetchData] = useClientRequest(TEST_QUERY, { + variables: { limit: 10 }, + updateData: updateDataMock + })), + { wrapper: Wrapper } + ); + + await fetchData(); + + expect(updateDataMock).not.toHaveBeenCalled(); + }); + + it('is not called if there is no new data', async () => { + let fetchData; + const updateDataMock = jest.fn(); + testHook( + () => + ([fetchData] = useClientRequest(TEST_QUERY, { + variables: { limit: 10 }, + updateData: updateDataMock + })), + { wrapper: Wrapper } + ); + + await fetchData(); + + mockClient.request.mockReturnValueOnce({ errors: ['on no!'] }); + await fetchData({ variables: { limit: 20 } }); + + expect(updateDataMock).not.toHaveBeenCalled(); + }); + + it('throws if updateData is not a function', async () => { + let fetchData; + testHook( + () => + ([fetchData] = useClientRequest(TEST_QUERY, { + variables: { limit: 10 }, + updateData: 'do I look like a function to you?' + })), + { wrapper: Wrapper } + ); + + // first fetch to populate state + await fetchData(); + + expect(fetchData({ variables: { limit: 20 } })).rejects.toThrow( + 'options.updateData must be a function' + ); }); }); }); diff --git a/test/unit/useQuery.test.js b/test/unit/useQuery.test.js index a4db8b16..9550e908 100644 --- a/test/unit/useQuery.test.js +++ b/test/unit/useQuery.test.js @@ -53,7 +53,7 @@ describe('useQuery', () => { }); }); - it('returns initial state from useClientRequest & refetch', () => { + it('returns initial state from useClientRequest, refetch & fetchMore', () => { let state; testHook(() => (state = useQuery(TEST_QUERY)), { wrapper: Wrapper }); expect(state).toEqual({ @@ -67,7 +67,53 @@ describe('useQuery', () => { let refetch; testHook(() => ({ refetch } = useQuery(TEST_QUERY)), { wrapper: Wrapper }); refetch(); - expect(mockQueryReq).toHaveBeenCalledWith({ skipCache: true }); + expect(mockQueryReq).toHaveBeenCalledWith({ + skipCache: true, + updateData: expect.any(Function) + }); + }); + + it('merges options when refetch is called', () => { + let refetch; + testHook( + () => + ({ refetch } = useQuery(TEST_QUERY, { + variables: { skip: 0, first: 10 } + })), + { + wrapper: Wrapper + } + ); + const updateData = () => {}; + refetch({ + extra: 'option', + variables: { skip: 10, first: 10, extra: 'variable' }, + updateData + }); + expect(mockQueryReq).toHaveBeenCalledWith({ + skipCache: true, + extra: 'option', + variables: { skip: 10, first: 10, extra: 'variable' }, + updateData + }); + }); + + it('gets updateData to replace the result by default', () => { + let refetch; + testHook( + () => + ({ refetch } = useQuery(TEST_QUERY, { + variables: { skip: 0, first: 10 } + })), + { + wrapper: Wrapper + } + ); + mockQueryReq.mockImplementationOnce(({ updateData }) => { + return updateData('previousData', 'data'); + }); + refetch(); + expect(mockQueryReq).toHaveReturnedWith('data'); }); it('sends the query on mount if no data & no error', () => {