Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 130 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ function MyComponent() {
- [useMutation](#useMutation)
- Guides
- [SSR](#SSR)
- [Pagination](#Pagination)
- [Authentication](#Authentication)
- [Fragments](#Fragments)
- [Migrating from Apollo](#Migrating-from-Apollo)
Expand Down Expand Up @@ -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
- `updateResult(previousResult, result)`: Function - Custom handler for merging previous & new query results; return value will replace `data` in `useQuery` return value
- `previousResult`: Previous GraphQL query or `updateResult` result
- `result`: New GraphQL query result

### `useQuery` return value

Expand All @@ -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`
- 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
Expand Down Expand Up @@ -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
}
}
`;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should simplify the example - this is could be:

ALL_POSTS_QUERY = `
  query($limit: Int, $skip: Int) {
    allPosts(limit: $limit, skip: $skip) {
      total,
      items {
        id
        title
      }
    }
  }
`

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the separation between data & meta is good here? There are redundant parts to this example that could be removed & make it more readable though, how about:

export const ALL_POSTS_QUERY = `
  query allPosts($first: Int!, $skip: Int!) {
    allPosts(first: $first, skip: $skip) {
      id
      title
      url
    }
    _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 <div>There was an error!</div>;
if (loading && !data) return <div>Loading</div>;

const { allPosts, _allPostsMeta } = data;
const areMorePosts = allPosts.length < _allPostsMeta.count;

return (
<section>
<ul>
{allPosts.map(post => (
<li key={post.id}>
<a href={post.url}>{post.title}</a>
</li>
))}
</ul>
<button
// reduce the offset by 10 to fetch the previous page
onClick={() => setSkipCount(skipCount - 10)}
disabled={skipCount === 0}
>
Previous page
</button>
<button
// increase the offset by 10 to fetch the next page
onClick={() => setSkipCount(skipCount + 10)}
disabled={!areMorePosts}
>
Next page
</button>
</section>
);
}
```

#### 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.updateResult to append the new page of posts to our current list of posts
const updateResult = (prevResult, result) => ({
...result,
allPosts: [...prevResult.allPosts, ...result.allPosts]
});

export default function PostList() {
const [skipCount, setSkipCount] = useState(0);

const { loading, error, data } = useQuery(
allPostsQuery,
{ variables: { skip: skipCount, first: 10 } },
updateResult
);

if (error) return <div>There was an error!</div>;
if (loading && !data) return <div>Loading</div>;

const { allPosts, _allPostsMeta } = data;
const areMorePosts = allPosts.length < _allPostsMeta.count;

return (
<section>
<ul>
{allPosts.map(post => (
<li key={post.id}>
<a href={post.url}>{post.title}</a>
</li>
))}
</ul>
{areMorePosts && (
<button
// set the offset to the current number of posts to fetch the next page
onClick={() => setSkipCount(allPosts.length)}
>
Show more
</button>
)}
</section>
);
}
```

### Authentication

Coming soon!
Expand Down
9 changes: 8 additions & 1 deletion src/useClientRequest.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.updateResult) {
if (typeof revisedOpts.updateResult !== 'function') {
throw new Error('options.updateResult must be a function');
}
result.data = revisedOpts.updateResult(state.data, result.data);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the name is updateResult, maybe we should pass the entire result? It might be useful for a developer to change / override the errors in some circumstances.

useClientRequest would then look something like:

let result = await client.request(revisedOperation, revisedOpts);

if (revisedOpts.updateResult) {
  if (typeof revisedOpts.updateResult !== 'function') {
    throw new Error('options.updateResult must be a function');
  }
  result = revisedOpts.updateResult(state, result)
}

The pagination example updateResult:

const updateResult = (prevResult, newResult) => ({
  ...newResult,
  data: {
    ...newResult.data,
    allPosts: [...prevResult.data.allPosts, ...result.data.allPosts]
  }
});

useQuery(MY_QUERY, {
  updateResult
})

It makes updating the data a bit more verbose, but the developer could write a wrapper if it was a really common usecase:

const dataUpdater = (fn) => (prevResult, newResult) => ({
  ...newResult,
  data: fn(prevResult.data, newResult.data)
})

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As discussed, passing the entire result back allows internal properties like loading & error to be changed, or new ones to be added which could be abused or cause tricky bugs. We're going to rename this to updateData as a better description of what it's doing & if we see a use case for changing / overriding errors in the future, we could always introduce an updateErrors option.

}

if (revisedOpts.useCache && client.cache) {
client.cache.set(revisedCacheKey, result);
Expand Down
13 changes: 8 additions & 5 deletions src/useQuery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,6 +28,11 @@ module.exports = function useQuery(query, opts = {}) {

return {
...state,
refetch: () => queryReq({ skipCache: true })
refetch: (options = {}) =>
queryReq({
skipCache: true,
updateResult: (_, result) => result,
...options
})
};
};
94 changes: 89 additions & 5 deletions test/unit/useClientRequest.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ describe('useClientRequest', () => {
get: jest.fn(),
set: jest.fn()
},
request: jest.fn().mockResolvedValue({ some: 'data' })
request: jest.fn().mockResolvedValue({ data: 'data' })
};
});

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -181,7 +181,7 @@ describe('useClientRequest', () => {
expect(state).toEqual({
cacheHit: false,
loading: false,
some: 'data'
data: 'data'
});
});

Expand All @@ -198,7 +198,7 @@ describe('useClientRequest', () => {
expect(state).toEqual({
cacheHit: false,
loading: false,
some: 'data'
data: 'data'
});
});

Expand All @@ -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 updateResultMock = jest.fn().mockReturnValue('merged data');
testHook(
() =>
([fetchData, state] = useClientRequest(TEST_QUERY, {
variables: { limit: 10 },
updateResult: updateResultMock
})),
{ wrapper: Wrapper }
);

// first fetch to populate state
await fetchData();

mockClient.request.mockResolvedValueOnce({ data: 'new data' });
await fetchData({ variables: { limit: 20 } });

expect(updateResultMock).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 updateResultMock = jest.fn();
testHook(
() =>
([fetchData] = useClientRequest(TEST_QUERY, {
variables: { limit: 10 },
updateResult: updateResultMock
})),
{ wrapper: Wrapper }
);

await fetchData();

expect(updateResultMock).not.toHaveBeenCalled();
});

it('is not called if there is no new data', async () => {
let fetchData;
const updateResultMock = jest.fn();
testHook(
() =>
([fetchData] = useClientRequest(TEST_QUERY, {
variables: { limit: 10 },
updateResult: updateResultMock
})),
{ wrapper: Wrapper }
);

await fetchData();

mockClient.request.mockReturnValueOnce({ errors: ['on no!'] });
await fetchData({ variables: { limit: 20 } });

expect(updateResultMock).not.toHaveBeenCalled();
});

it('throws if updateResult is not a function', async () => {
let fetchData;
testHook(
() =>
([fetchData] = useClientRequest(TEST_QUERY, {
variables: { limit: 10 },
updateResult: '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.updateResult must be a function'
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👏 excellent tests

});
});
});
Expand Down
50 changes: 48 additions & 2 deletions test/unit/useQuery.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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,
updateResult: 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 updateResult = () => {};
refetch({
extra: 'option',
variables: { skip: 10, first: 10, extra: 'variable' },
updateResult
});
expect(mockQueryReq).toHaveBeenCalledWith({
skipCache: true,
extra: 'option',
variables: { skip: 10, first: 10, extra: 'variable' },
updateResult
});
});

it('gets updateResult to replace the result by default', () => {
let refetch;
testHook(
() =>
({ refetch } = useQuery(TEST_QUERY, {
variables: { skip: 0, first: 10 }
})),
{
wrapper: Wrapper
}
);
mockQueryReq.mockImplementationOnce(({ updateResult }) => {
return updateResult('previousResult', 'result');
});
refetch();
expect(mockQueryReq).toHaveReturnedWith('result');
});

it('sends the query on mount if no data & no error', () => {
Expand Down