Skip to content

Commit

Permalink
docs: Create some stories for the utils/api/* hooks, incl useAggregat…
Browse files Browse the repository at this point in the history
…eQueryKeys (#84023)

In the process of writing the stories i realized that the Parallel and
Sequential hooks work differently, and that might be
surprising/annoying. One returns data sequentially, the other does not.
One can be interrupted/controlled by returning `undefined` from
`getQueryKey`, the other cannot. It'd be nice to bring all the features
to both.

Also `useAggregateQueryKeys` is here. The story could be iterated on to
show more of what's happening in the UI instead of referring folks to
look at the network traffic. For example, if there were some buttons to
fetch more and more `teamIds` on demand that'd be cool. But for now
we've got an example wired up, and can see some results. It works as
long as the viewer of the story is part of at least 3 teams within their
org.

<img width="654" alt="SCR-20250124-llev"
src="https://github.com/user-attachments/assets/41f0d86d-1edb-410d-b13c-be5de79947b8"
/>
<img width="662" alt="SCR-20250124-llfz"
src="https://github.com/user-attachments/assets/fb22aca0-6866-4087-a157-238b59f58a63"
/>
<img width="655" alt="SCR-20250124-llho"
src="https://github.com/user-attachments/assets/4f616a6e-571b-4fbe-be13-8e019d687645"
/>

---------

Co-authored-by: Michelle Zhang <[email protected]>
  • Loading branch information
ryan953 and michellewzhang authored Jan 24, 2025
1 parent fd52e4c commit 8fe530e
Show file tree
Hide file tree
Showing 5 changed files with 241 additions and 2 deletions.
73 changes: 73 additions & 0 deletions static/app/utils/api/useAggregatedQueryKeys.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import {Fragment, useCallback, useEffect} from 'react';

import type {ApiResult} from 'sentry/api';
import StructuredEventData from 'sentry/components/structuredEventData';
import storyBook from 'sentry/stories/storyBook';
import type {Team} from 'sentry/types/organization';
import useAggregatedQueryKeys from 'sentry/utils/api/useAggregatedQueryKeys';
import type {ApiQueryKey} from 'sentry/utils/queryClient';
import useOrganization from 'sentry/utils/useOrganization';
import {useUserTeams} from 'sentry/utils/useUserTeams';

export default storyBook('useAggregatedQueryKeys', story => {
story('useAggregatedQueryKeys', () => {
const organization = useOrganization();

// Get a list of valid teamIds for the demo.
const {teams: userTeams} = useUserTeams();

const cache = useAggregatedQueryKeys<string, Team[]>({
getQueryKey: useCallback(
(ids: readonly string[]): ApiQueryKey => {
return [
`/organizations/${organization.slug}/teams/`,
{
query: {
query: ids.map(id => `id:${id}`).join(' '),
},
},
];
},
[organization.slug]
),
onError: useCallback(() => {}, []),
responseReducer: useCallback(
(
prevState: undefined | Team[],
response: ApiResult,
_aggregates: readonly string[]
) => {
return {...prevState, ...response[0]};
},
[]
),
bufferLimit: 50,
});

useEffect(() => {
// Request only the first team
const firstTeam = userTeams[0];
if (firstTeam) {
cache.buffer([firstTeam.id]);
}
}, [cache, userTeams]);
useEffect(() => {
// Request some more teams separatly
const moreTeams = userTeams.slice(1, 3);
if (moreTeams.length) {
cache.buffer(moreTeams.map(team => team.id));
}
}, [cache, userTeams]);

return (
<Fragment>
<p>
Checkout the network traffic to really understand how this works. We've called{' '}
<code>cache.buffer()</code> in two independent places, but those 3 ids were
grouped together into one request on the network.
</p>
<StructuredEventData data={cache.data} />
</Fragment>
);
});
});
2 changes: 1 addition & 1 deletion static/app/utils/api/useAggregatedQueryKeys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ function isQueryKeyInList<AggregatableQueryKey>(queryList: AggregatableQueryKey[
* - You will implement the props `getQueryKey(aggregates: Array<any>)` which
* takes the unique list of `aggregates` that have been passed into `buffer()`.
* The returned queryKey must have a stable url as the first array item.
* - After after `buffer()` has stopped being called for BUFFER_WAIT_MS, or if
* - After `buffer()` has stopped being called for BUFFER_WAIT_MS, or if
* bufferLimit items are queued, then `getQueryKey()` function will be called.
* - The new queryKey will be used to fetch some data.
* - You will implement `responseReducer(prev: Data, result: ApiResult)` which
Expand Down
70 changes: 70 additions & 0 deletions static/app/utils/api/useFetchParallelPages.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import {Fragment, useCallback} from 'react';

import StructuredEventData from 'sentry/components/structuredEventData';
import storyBook from 'sentry/stories/storyBook';
import useFetchParallelPages from 'sentry/utils/api/useFetchParallelPages';
import useOrganization from 'sentry/utils/useOrganization';

export default storyBook('useFetchParallelPages', story => {
story('WARNING!', () => (
<Fragment>
<p>
Using this hook might not be a good idea!
<br />
Pagination is a good strategy to limit the amount of data that a server needs to
fetch at a given time; it also limits the amount of data that the browser needs to
hold in memory. Loading all data with this hook could cause rate-limiting, memory
exhaustion, slow rendering, and other problems.
</p>
<p>
Before implementing a parallel-fetch you should first think about building new api
endpoints that return just the data you need (in a paginated way), or look at the
feature design itself and make adjustments.
</p>
</Fragment>
));

story('useFetchParallelPages', () => {
const organization = useOrganization();

const hits = 200; // the maximum number of items we expect to fetch

const {pages, isFetching} = useFetchParallelPages<{data: unknown}>({
enabled: true,
hits,
getQueryKey: useCallback(
({cursor, per_page}) => {
return [
`/organizations/${organization.slug}/projects/`,
{query: {cursor, per_page}},
];
},
[organization.slug]
),
perPage: 20,
});

return (
<Fragment>
<p>
<code>useFetchParallelPages</code> will fetch all pages of data for a given
query. The return value of the hook will update as requests complete, meaning
that the UI can update incrementally.
</p>
<p>
Note that you need to set <code>hits</code> and <code>perPage</code> so the
helper can know how many requests to make. If you don't already know how many
results to expect then it can be helpful to manually request the first full page
of results, check the <code>X-Hits</code> response header, then use the hook to
fetch the complete list of results. Use the same <code>perPage</code> value in
both callsites to leverage the query cache.
</p>
<p>
Note that <code>getQueryKey</code> needs to be a stable reference, so wrap it
with <code>useCallback</code>.
</p>
<StructuredEventData data={{pages: pages.length, isFetching}} />
</Fragment>
);
});
});
2 changes: 1 addition & 1 deletion static/app/utils/api/useFetchParallelPages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ interface State<Data> {
* browser needs to hold in memory. Loading all data with this hook could
* cause rate-limiting, memory exhaustion, slow rendering, and other problems.
*
* Before implementing a sequential-fetch you should first think about
* Before implementing a parallel-fetch you should first think about
* building new api endpoints that return just the data you need (in a
* paginated way), or look at the feature design itself and make adjustments.
* </WARNING>
Expand Down
96 changes: 96 additions & 0 deletions static/app/utils/api/useFetchSequentialPages.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import {Fragment, useCallback, useRef} from 'react';

import StructuredEventData from 'sentry/components/structuredEventData';
import storyBook from 'sentry/stories/storyBook';
import useFetchSequentialPages from 'sentry/utils/api/useFetchSequentialPages';
import useOrganization from 'sentry/utils/useOrganization';

export default storyBook('useFetchSequentialPages', story => {
story('WARNING!', () => (
<Fragment>
<p>
Using this hook might not be a good idea!
<br />
Pagination is a good strategy to limit the amount of data that a server needs to
fetch at a given time, it also limits the amount of data that the browser needs to
hold in memory. Loading all data with this hook could cause rate-limiting, memory
exhaustion, slow rendering, and other problems.
</p>
<p>
Before implementing a sequential-fetch you should first think about building new
api endpoints that return just the data you need (in a paginated way), or look at
the feature design itself and make adjustments.
</p>
</Fragment>
));

story('useFetchSequentialPages', () => {
const organization = useOrganization();
const {pages, isFetching} = useFetchSequentialPages<{data: unknown}>({
enabled: true,
initialCursor: undefined,
getQueryKey: useCallback(
({cursor, per_page}) => {
// console.log('cursor', cursor);
return [
`/organizations/${organization.slug}/projects/`,
{query: {cursor, per_page}},
];
},
[organization.slug]
),
perPage: 20,
});

return (
<Fragment>
<p>
<code>useFetchSequentialPages</code> will fetch all pages of data for a given
query. After all pages are fetched the full list of responses is returned as
`pages`. The UI doesn't incrementally render as data is coming in.
</p>
<p>
Note that <code>getQueryKey</code> needs to be a stable reference, so wrap it
with <code>useCallback</code>.
</p>
<StructuredEventData data={{pages: pages.length, isFetching}} />
</Fragment>
);
});

story('Interrupt a sequential series', () => {
const organization = useOrganization();
const pagesFetched = useRef(0);

const {pages, isFetching} = useFetchSequentialPages<{data: unknown}>({
enabled: true,
initialCursor: undefined,
getQueryKey: useCallback(
({cursor, per_page}) => {
pagesFetched.current++;
if (pagesFetched.current > 2) {
return undefined;
}

return [
`/organizations/${organization.slug}/projects/`,
{query: {cursor, per_page}},
];
},
[organization.slug]
),
perPage: 1,
});

return (
<Fragment>
<p>
You can stop a series of requests from continuing by returning a{' '}
<kbd>undefined</kbd> from the <code>getQueryKey</code> callback.
</p>
<p>Here we limit the number of pages to 2</p>
<StructuredEventData data={{pages: pages.length, isFetching}} />
</Fragment>
);
});
});

0 comments on commit 8fe530e

Please sign in to comment.