Skip to main content
jg-packages

Search

    Server data

    jg-tanstack-query

    TanStack Query with safe defaults, opt-in IndexedDB persistence, and a sign-out clear that waits for the cache to finish restoring.

    v0.2.0BrowserESMMIT

    npm install jg-tanstack-query

    Usage

    Create the client and its persister once, outside any component, and provide them at the root:

    ts
    import { createElement } from 'react';
    import {
      createIndexedDbPersister,
      createQueryClient,
      PersistedQueryProvider,
    } from 'jg-tanstack-query';
    
    declare const App: () => null;
    
    export const queryClient = createQueryClient();
    export const persister = createIndexedDbPersister({
      databaseName: 'app-query-cache',
      storeName: 'queries',
      key: 'query-client',
    });
    
    export const Root = () =>
      createElement(
        PersistedQueryProvider,
        { client: queryClient, persister, buster: 'v1' },
        createElement(App),
      );
    • The client never retries a query or a mutation, so an error shows at once and a failed write never repeats, and doesn’t refetch when the window regains focus. Unused data stays in memory for maxAgeSeconds, 10 days by default, as long as it stays persisted.
    • Persistence is opt-in: only a query with meta: { persist: true } is saved, once it succeeds, so private or short-lived data never reaches the disk. Mutations are never saved, since one replayed after a reload could act twice. Change buster when a release changes a persisted query’s data, so older copies are thrown away.
    • Where IndexedDB is missing, such as in some private windows, the provider provides the client without persistence, and the persister does nothing.

    Signing out

    ts
    import { clearQueryCache } from 'jg-tanstack-query';
    
    declare const queryClient: Parameters<typeof clearQueryCache>[0];
    declare const persister: Parameters<typeof clearQueryCache>[1];
    
    export const signOut = async (): Promise<void> => {
      await clearQueryCache(queryClient, persister);
    };

    clearQueryCache waits for the client’s restoration from its persisted copy, so restored data can’t come back after the clear. It then clears the memory and removes the persisted copy, and concurrent calls share one clear.

    Paging after a first page

    When one query loads a whole screen, including the first page of a list, useSeededInfiniteQuery pages through the rest of the list without fetching that first page again:

    ts
    import { useQuery } from '@tanstack/react-query';
    import { useSeededInfiniteQuery } from 'jg-tanstack-query';
    
    const screen = useQuery({ queryKey: ['dashboard'], queryFn: fetchDashboard });
    const activity = useSeededInfiniteQuery<IActivityPage, string | null>({
      queryKey: ['dashboard', 'activity'],
      firstPage: screen.data?.activity,
      firstPageUpdatedAt: screen.dataUpdatedAt,
      initialPageParam: null,
      getNextPageParam: (page) => page.nextCursor,
      continuationQueryFn: (cursor, signal) => fetchActivity(cursor, signal),
    });
    // activity.data.pages, activity.fetchNextPage(), activity.hasNextPage

    Each firstPageUpdatedAt and first page form a generation. When the screen’s query refetches, the list starts over from its new first page, any page in flight is aborted, and a late page of an older generation is dropped. Until the generation is published, the result is loading, with no data. Components using the same key share one generation, so one unmounting aborts nothing. fetchNextPage and refetch, which retries only the page that failed, keep their identity. The query never fetches by itself and is never persisted; gcTimeSeconds, 0 by default, keeps its pages after the last component unmounts. Something that forces it to fetch before the first page arrives rejects with FIRST_PAGE_NOT_READY.

    Query state

    The query-state helpers read the flags of any query, or of useSeededInfiniteQuery’s result: isQueryLoading, isQueryReadyForInitialDisplay, which holds back stale cached data until it’s refetched after mount, canQueryLoadMore, isQueryLoadingMore, and isQueryResultDataAvailable, for a list of queries.

    Query boundaries

    useQueryBoundary decides what a screen shows around the queries its content needs, and leaves the drawing to the app:

    ts
    import { useQueryBoundary } from 'jg-tanstack-query';
    
    const boundary = useQueryBoundary({
      queries: [project, members],
      resolutionKey: projectId, // a new key hides the content until its queries resolve
      isAccessDenied: (error) => getErrorCode(error) === 'ACCESS_DENIED',
    });
    // { canRenderContent, isLoaderVisible, hasError, errorPresentation, failedQueries,
    //   loadMoreQuery, retryFailed }

    The content waits until every query has data for the key that’s fresh, or fetched since mount, so cached data from an earlier visit never shows as current. Once it shows, refetches, pagination, and background errors keep it on screen, with errorPresentation: 'retained'; an error before then, or one that leaves a query without data, is blocking. A new key, or an error isAccessDenied recognizes, hides the content in the same render, and after an access loss it returns only once every query settles with current data, even when isInitialResolutionApproved says the app approved the key’s data before. loadMoreQuery is the first query when it pages and the content shows, and retryFailed refetches every failed query; it and the result keep their identity while nothing changes. RECIPES.md has an unstyled boundary component.

    Errors

    Code When
    FIRST_PAGE_NOT_READY A seeded infinite query was forced to fetch before its first page arrived

    Generated from TSDoc

    API reference

    Every export of [email protected], 24 in all, with its signature, its errors, and an example.

    Functions

    canQueryLoadMore

    function

    Checks whether an infinite query has another page to load, and isn't failing.

    Signature
    canQueryLoadMore(query: IQueryExecutionState): boolean

    Parameters

    queryIQueryExecutionState

    The query's state, such as a useInfiniteQuery result.

    Returns

    boolean

    Whether another page can be requested.

    Example
    import { canQueryLoadMore } from 'jg-tanstack-query';
    
    canQueryLoadMore({ isError: false, hasNextPage: true }); // true

    clearQueryCache

    function

    Clears a client's cached data, in memory and persisted, such as at sign-out, so the next person on the device sees none of it. It first waits for the client's restoration from its persisted copy, so restored data can't come back after the clear. Concurrent calls share one clear.

    Signature
    clearQueryCache(client: QueryClient, persister: Persister): Promise<void>

    Parameters

    clientQueryClient

    The client.

    persisterPersister

    The client's persister.

    Returns

    Promise<void>

    A promise that resolves once the persisted copy is removed too.

    Throws

    • ErrorThe persister's own error, when it can't remove the persisted copy, after the memory is cleared.
    Example
    import { clearQueryCache, createIndexedDbPersister, createQueryClient } from 'jg-tanstack-query';
    
    const queryClient = createQueryClient();
    const persister = createIndexedDbPersister({
      databaseName: 'app-query-cache',
      storeName: 'queries',
      key: 'query-client',
    });
    
    export const signOut = async (): Promise<void> => {
      await clearQueryCache(queryClient, persister);
    };

    createIndexedDbPersister

    function

    Creates a persister that keeps a TanStack Query client's dehydrated copy in IndexedDB, through idb-keyval. It opens the database on first use, not when it's created, and does nothing where IndexedDB is missing.

    Signature
    createIndexedDbPersister(options: IIndexedDbPersisterOptions): Persister

    Parameters

    optionsIIndexedDbPersisterOptions

    The database, the store, and the key.

    Returns

    Persister

    The persister.

    Example
    import { createIndexedDbPersister } from 'jg-tanstack-query';
    
    export const persister = createIndexedDbPersister({
      databaseName: 'app-query-cache',
      storeName: 'queries',
      key: 'query-client',
    });

    createQueryClient

    function

    Creates a TanStack Query client with safe defaults: queries and mutations never retry, a focused window doesn't refetch, and unused data stays in memory as long as it stays persisted. Create it once, outside any component.

    Signature
    createQueryClient(options?: IQueryClientOptions): QueryClient

    Parameters

    options?IQueryClientOptions

    How long unused data stays in memory.

    Defaults to {}.

    Returns

    QueryClient

    The client.

    Example
    import { createQueryClient } from 'jg-tanstack-query';
    
    export const queryClient = createQueryClient();

    isQueryLoading

    function

    Checks whether a query is loading: it has no data yet, or is fetching its first.

    Signature
    isQueryLoading(query: IQueryExecutionState): boolean

    Parameters

    queryIQueryExecutionState

    The query's state, such as a useQuery result.

    Returns

    boolean

    Whether it's loading or pending.

    Example
    import { isQueryLoading } from 'jg-tanstack-query';
    
    isQueryLoading({ isError: false, isPending: true }); // true

    isQueryLoadingMore

    function

    Checks whether an infinite query is fetching its next page.

    Signature
    isQueryLoadingMore(query: IQueryExecutionState): boolean

    Parameters

    queryIQueryExecutionState

    The query's state, such as a useInfiniteQuery result.

    Returns

    boolean

    Whether a next page is in flight.

    Example
    import { isQueryLoadingMore } from 'jg-tanstack-query';
    
    isQueryLoadingMore({ isError: false, isFetchingNextPage: true }); // true

    isQueryReadyForInitialDisplay

    function

    Checks whether a query's data can be shown when a screen first renders: it didn't fail, and its data is fresh, or was fetched since the component mounted. Stale data from the cache waits for the refetch, so a screen never shows data from a previous visit as current.

    Signature
    isQueryReadyForInitialDisplay(query: IQueryInitialResolutionState): boolean

    Parameters

    queryIQueryInitialResolutionState

    The query's state, such as a useQuery result.

    Returns

    boolean

    Whether its data is fit for the first display.

    Example
    import { isQueryReadyForInitialDisplay } from 'jg-tanstack-query';
    
    isQueryReadyForInitialDisplay({ isError: false, isStale: true, isFetchedAfterMount: false }); // false

    isQueryResultDataAvailable

    function

    Checks whether every query has data to show: none failed, and none is loading.

    Signature
    isQueryResultDataAvailable(queries: readonly IQueryExecutionState[]): boolean

    Parameters

    queriesreadonly IQueryExecutionState[]

    The queries' states.

    Returns

    boolean

    Whether all of them have data.

    Example
    import { isQueryResultDataAvailable } from 'jg-tanstack-query';
    
    isQueryResultDataAvailable([{ isError: false }, { isError: false, isPending: true }]); // false

    PersistedQueryProvider

    function

    Provides a TanStack Query client that restores its persisted copy on start, and persists the queries that opt in with meta: { persist: true }, never mutations. Where IndexedDB is missing, it provides the client without persistence.

    Signature
    PersistedQueryProvider(props: IPersistedQueryProviderProps): ReactElement

    Parameters

    propsIPersistedQueryProviderProps

    The client, its persister, the copy's version, its maximum age, and the app.

    Returns

    ReactElement

    The provider.

    Example
    import { createElement } from 'react';
    import { createIndexedDbPersister, createQueryClient, PersistedQueryProvider } from 'jg-tanstack-query';
    
    declare const App: () => null;
    
    const client = createQueryClient();
    const persister = createIndexedDbPersister({
      databaseName: 'app-query-cache',
      storeName: 'queries',
      key: 'query-client',
    });
    
    export const Root = () =>
      createElement(PersistedQueryProvider, { client, persister, buster: 'v1' }, createElement(App));

    useQueryBoundary

    function

    Decides what a screen shows around the queries its content needs, without rendering anything, so any design system can draw the loader, the error, and the content.

    The content waits until every query has data for the current resolutionKey that's fresh, or fetched since mount, so stale cached data never shows as current. Once it shows, refetches, pagination, and background errors keep it on screen, with errorPresentation: 'retained'. A new key, or an error isAccessDenied recognizes, hides it in the same render; after an access loss, it returns only once every query settles with current data, even with isInitialResolutionApproved.

    Signature
    useQueryBoundary<TQuery extends IQueryBoundaryQuery>(
      options: IUseQueryBoundaryOptions<TQuery>,
    ): IQueryBoundaryState<TQuery>

    Parameters

    optionsIUseQueryBoundaryOptions<TQuery>

    The queries, the key of what they show, whether the app already approved its data, and how to recognize a lost access.

    Returns

    IQueryBoundaryState<TQuery>

    Whether to render the content or a loader, the error state and the failed queries, the query for a load-more control, and a stable retryFailed. The result keeps its identity while none of it changes.

    Example
    import { useQuery } from '@tanstack/react-query';
    import { useQueryBoundary } from 'jg-tanstack-query';
    
    declare const fetchProject: (id: string) => Promise<{ name: string }>;
    
    export const useProjectBoundary = (projectId: string) => {
      const project = useQuery({
        queryKey: ['project', projectId],
        queryFn: () => fetchProject(projectId),
      });
      const boundary = useQueryBoundary({ queries: [project], resolutionKey: projectId });
      return { project, boundary };
    };

    useSeededInfiniteQuery

    function

    Pages through a list whose first page another query already fetched, such as one that loads a whole screen, without fetching that page again. The first page is published into an infinite query under queryKey, and continuationQueryFn fetches only the pages after it.

    Each firstPageUpdatedAt and first page form a generation. A new generation cancels any page in flight and starts over from the new first page, and an older publication that finishes late never replaces a newer one. Components that use the same key share one generation and its pages, so one unmounting aborts nothing. Until the current generation is published, the result is loading, with no data. The query never fetches by itself and is never persisted.

    Signature
    useSeededInfiniteQuery<TPage, TPageParam>(
      options: IUseSeededInfiniteQueryOptions<TPage, TPageParam>,
    ): ISeededInfiniteQueryResult<TPage, TPageParam>

    Parameters

    optionsIUseSeededInfiniteQueryOptions<TPage, TPageParam>

    The key, the first page and when it was fetched, how to fetch the pages after it, and the cache settings.

    Returns

    ISeededInfiniteQueryResult<TPage, TPageParam>

    A query result for boundaries and load-more controls, whose fetchNextPage and refetch keep their identity.

    Throws

    • FIRST_PAGE_NOT_READYThe first page isn't ready yet.
    Example
    import { useQuery } from '@tanstack/react-query';
    import { useSeededInfiniteQuery } from 'jg-tanstack-query';
    
    type IPage = { items: string[]; nextCursor: string | null };
    declare const fetchScreen: () => Promise<{ activity: IPage }>;
    declare const fetchActivity: (cursor: string, signal: AbortSignal) => Promise<IPage>;
    
    export const useActivity = () => {
      const screen = useQuery({ queryKey: ['dashboard'], queryFn: fetchScreen });
      return useSeededInfiniteQuery<IPage, string | null>({
        queryKey: ['dashboard', 'activity'],
        firstPage: screen.data?.activity,
        firstPageUpdatedAt: screen.dataUpdatedAt,
        initialPageParam: null,
        getNextPageParam: (page) => page.nextCursor,
        continuationQueryFn: (cursor, signal) => fetchActivity(cursor as string, signal),
      });
    };

    Constants

    DEFAULT_QUERY_MAX_AGE_SECONDS

    const

    how long a query's data stays cached and persisted by default: 10 days, in seconds

    Declaration
    const DEFAULT_QUERY_MAX_AGE_SECONDS: 864000

    ERRORS

    const

    the codes for every error thrown by the package

    Declaration
    const ERRORS: { FIRST_PAGE_NOT_READY: "FIRST_PAGE_NOT_READY" }

    Types

    IIndexedDbPersisterOptions

    type

    where createIndexedDbPersister keeps a client's persisted copy

    Declaration
    type IIndexedDbPersisterOptions = {
        databaseName: string;
        storeName: string;
        key: string;
    };

    Members

    • databaseNameproperty
      string

      the IndexedDB database's name, such as 'app-query-cache'

    • storeNameproperty
      string

      the object store's name in that database

    • keyproperty
      string

      the key the persisted copy is stored under

    IPersistedQueryProviderProps

    type

    what PersistedQueryProvider takes

    Declaration
    type IPersistedQueryProviderProps = {
        client: QueryClient;
        persister: Persister;
        buster: string;
        maxAgeSeconds?: number;
        children?: ReactNode;
    };

    Members

    • clientproperty
      QueryClient

      the client, from createQueryClient

    • persisterproperty
      Persister

      where the client's persisted copy lives, from createIndexedDbPersister

    • busterproperty
      string

      the persisted copy's version: change it to throw away copies an older release wrote

    • maxAgeSecondsproperty
      number

      how long a persisted copy stays usable, which the client's maxAgeSeconds must be at least. Defaults to DEFAULT_QUERY_MAX_AGE_SECONDS

    • childrenproperty
      ReactNode

    IQueryBoundaryQuery

    type

    a query a boundary watches: a useQuery or useInfiniteQuery result, or useSeededInfiniteQuery's

    Declaration
    type IQueryBoundaryQuery = IQueryExecutionState & IQueryInitialResolutionState & {
        data: unknown;
        error?: unknown;
        isFetching?: boolean | undefined;
        isRefetching?: boolean | undefined;
        refetch?: (() => unknown) | undefined;
        fetchNextPage?: (() => unknown) | undefined;
    };

    IQueryBoundaryState

    type

    what useQueryBoundary returns

    Declaration
    type IQueryBoundaryState<TQuery extends IQueryBoundaryQuery> = {
        canRenderContent: boolean;
        isLoaderVisible: boolean;
        hasError: boolean;
        errorPresentation: IQueryErrorPresentation;
        failedQueries: TQuery[];
        loadMoreQuery: TQuery | undefined;
        retryFailed: () => Promise<void>;
    };

    Members

    • canRenderContentproperty
      boolean

      whether to render the content: the key resolved, every query has data, and no access is lost

    • isLoaderVisibleproperty
      boolean

      whether to show a loader in place of the content

    • hasErrorproperty
      boolean

      whether a query failed

    • errorPresentationproperty
      IQueryErrorPresentation

      whether the error replaces the content, or shows beside content that's still current

    • failedQueriesproperty
      TQuery[]

      the queries that failed

    • loadMoreQueryproperty
      TQuery | undefined

      the first query, when it pages and the content renders, for a load-more control

    • retryFailedproperty
      () => Promise<void>

      refetches every failed query, resolving once all of them settle. Its identity never changes

    IQueryClientOptions

    type

    what createQueryClient takes

    Declaration
    type IQueryClientOptions = {
        maxAgeSeconds?: number;
    };

    Members

    • maxAgeSecondsproperty
      number

      how long unused query data stays in memory, which must be at least how long it stays persisted. Defaults to DEFAULT_QUERY_MAX_AGE_SECONDS

    IQueryErrorPresentation

    type

    how an error shows: in place of the content, or beside content that's still current

    Declaration
    type IQueryErrorPresentation = 'blocking' | 'retained';

    IQueryExecutionState

    type

    the flags of a query, infinite or not, that the loading and load-more helpers read

    Declaration
    type IQueryExecutionState = {
        isError: boolean;
        isLoading?: boolean | undefined;
        isPending?: boolean | undefined;
        hasNextPage?: boolean | undefined;
        isFetchingNextPage?: boolean | undefined;
    };

    Members

    • isErrorproperty
      boolean
    • isLoadingproperty
      boolean
    • isPendingproperty
      boolean
    • hasNextPageproperty
      boolean
    • isFetchingNextPageproperty
      boolean

    IQueryInitialResolutionState

    type

    the flags that decide whether a query's cached data is fit for the first display after mount

    Declaration
    type IQueryInitialResolutionState = {
        isError: boolean;
        isFetchedAfterMount: boolean;
        isStale: boolean;
    };

    Members

    • isErrorproperty
      boolean
    • isFetchedAfterMountproperty
      boolean
    • isStaleproperty
      boolean

    ISeededInfiniteQueryResult

    type

    What useSeededInfiniteQuery returns: a subset of TanStack's infinite query result, shaped so query boundaries and load-more controls read it like any other query. Until the current generation is published, it's loading, with no data. fetchNextPage and refetch keep their identity across renders.

    Declaration
    type ISeededInfiniteQueryResult<TPage, TPageParam> = {
        data: InfiniteData<TPage, TPageParam> | undefined;
        error: Error | null;
        fetchNextPage: () => Promise<unknown>;
        hasNextPage: boolean;
        isError: boolean;
        isFetchedAfterMount: boolean;
        isFetching: boolean;
        isFetchingNextPage: boolean;
        isLoading: boolean;
        isPending: boolean;
        isReady: boolean;
        isRefetching: boolean;
        isStale: boolean;
        isSuccess: boolean;
        refetch: () => Promise<unknown>;
    };

    Members

    • dataproperty
      InfiniteData<TPage, TPageParam> | undefined

      the pages, once the current generation is published

    • errorproperty
      Error | null

      the error of the last continuation that failed, or null

    • fetchNextPageproperty
      () => Promise<unknown>

      fetches the next page, when there is one and none is being fetched

    • hasNextPageproperty
      boolean

      whether there's another page

    • isErrorproperty
      boolean

      whether the last continuation failed

    • isFetchedAfterMountproperty
      boolean

      whether the current generation is published, which a boundary reads as fetched after mount

    • isFetchingproperty
      boolean

      whether a continuation is in flight

    • isFetchingNextPageproperty
      boolean

      whether a continuation is in flight

    • isLoadingproperty
      boolean

      whether the current generation isn't published yet

    • isPendingproperty
      boolean

      whether the current generation isn't published yet

    • isReadyproperty
      boolean

      whether the current generation is published

    • isRefetchingproperty
      boolean

      whether a continuation is in flight

    • isStaleproperty
      boolean

      always false: the first page is as fresh as the query it came from

    • isSuccessproperty
      boolean

      whether the current generation is published, with its data

    • refetchproperty
      () => Promise<unknown>

      fetches the failed continuation again, after an error

    IUseQueryBoundaryOptions

    type

    what useQueryBoundary takes

    Declaration
    type IUseQueryBoundaryOptions<TQuery extends IQueryBoundaryQuery> = {
        queries: readonly TQuery[];
        resolutionKey: string;
        isInitialResolutionApproved?: boolean | undefined;
        isAccessDenied?: ((error: unknown) => boolean) | undefined;
    };

    Members

    • queriesproperty
      readonly TQuery[]

      the queries whose data the content shows

    • resolutionKeyproperty
      string

      what the content shows, such as a project's id: a new key hides the content until the queries resolve for it

    • isInitialResolutionApprovedproperty
      boolean

      whether the app already approved this key's data, such as before the boundary remounted, so cached data shows at once. It never brings content back after an access loss

    • isAccessDeniedproperty
      (error: unknown) => boolean

      whether a query's error means the user lost access, such as a 403, which hides the content at once, even approved, until every query succeeds again. Defaults to never

    IUseSeededInfiniteQueryOptions

    type

    what useSeededInfiniteQuery takes

    Declaration
    type IUseSeededInfiniteQueryOptions<TPage, TPageParam> = {
        queryKey: QueryKey;
        firstPage: TPage | undefined;
        firstPageUpdatedAt: number;
        initialPageParam: TPageParam;
        getNextPageParam: (lastPage: TPage, pages: TPage[]) => TPageParam | undefined | null;
        continuationQueryFn: (pageParam: TPageParam, signal: AbortSignal) => Promise<TPage>;
        gcTimeSeconds?: number | undefined;
        maxPages?: number | undefined;
        meta?: QueryMeta | undefined;
    };

    Members

    • queryKeyproperty
      QueryKey

      the key of the query that holds the pages, which no other query may use

    • firstPageproperty
      TPage | undefined

      the first page, which another query, such as one for a whole screen, already fetched

    • firstPageUpdatedAtproperty
      number

      when the first page was fetched, such as that query's dataUpdatedAt. A new value, or a new first page, starts a new generation: the pages loaded so far are dropped

    • initialPageParamproperty
      TPageParam

      the page param the first page was fetched with

    • getNextPageParamproperty
      (lastPage: TPage, pages: TPage[]) => TPageParam | undefined | null

      gives the next page's param, or undefined or null when there are no more pages

    • continuationQueryFnproperty
      (pageParam: TPageParam, signal: AbortSignal) => Promise<TPage>

      fetches every page after the first, aborting when the signal does

    • gcTimeSecondsproperty
      number

      the seconds the pages stay cached once no component uses them. Defaults to 0

    • maxPagesproperty
      number

      the most pages kept, dropping the oldest ones

    • metaproperty
      QueryMeta

      the query's meta, always with persist: false, so the pages are never persisted