npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@proedis/react-query

v1.0.1

Published

Query suspense over useClientQuery and gated cache invalidation for @tanstack/react-query, with every view injected by the host app

Readme

@proedis/react-query

The two things every Proedis frontend ends up rewriting: a query boundary that does not know what a spinner looks like, and invalidation that knows when to wait. 🧩

npm license


✨ What's in the box

Zero UI dependencies: no component library, no styling, no copy. The views are yours, the state machine is ours. 🎛️

| | | | --- | --- | | The HOC | querySuspenseComponent — pairs a component with the query arguments that feed it. This is how you use the package | | Render helpers | suspendedComponent, useSuspendedContext | | Configuration | QuerySuspenseProvider — optional, replaces the built-in views with your own | | The engine | QuerySuspense, and resolveQueryView — the branch order as a plain function | | Invalidation | useQueryInvalidation, InvalidationQueue — deduped, batched, deferrable | | Gates | useInvalidationGate, useRefetchPause, refetchOnWindowFocusGate, PauseGate |

Two entry points beyond the root, so a consumer can take one half without the other: @proedis/react-query/suspense and @proedis/react-query/invalidation.

📦 Installation

yarn add @proedis/react-query

Every dependency is a peer, and each must resolve to a single instance: two copies of @tanstack/react-query in one bundle means two caches, and a hook reading the one your provider did not create.

| Peer | Range | Needed by | | --- | --- | --- | | react | >=18.0.0 <20.0.0 | both entry points | | @tanstack/react-query | ^5.0.0 | both entry points | | @proedis/react | ^2.0.0 | both entry points | | @proedis/react-client | ^4.0.0 | suspense only — the boundary queries through useClientQuery | | @proedis/client | ^3.0.0 | suspense only — RequestError is the error type |

The invalidation entry point needs neither of the last two: it works against any QueryClient.

🚀 Quick start

Pair a component with the arguments its query is described by. That is all: no provider, no setup. A screen never writes an isPending branch again.

const ActivityDetailRender = suspendedComponent<ActivityCompleteDto, ActivityDetailProps>(
  (activity, props) => <ActivityPanel activity={activity} {...props} />
);

const ActivityDetail = querySuspenseComponent(
  ActivityDetailRender,
  props => getSingleActivityQueryArgs(props.id)   // written by `proedis scaffold hooks`
);

// <ActivityDetail id={activityId} />

Out of the box that renders an empty role="status" element while loading, the error's own message inside a role="alert" when it fails, and a 404 as a missing entity. Which is enough to work, and not enough to ship — so when you want your own, and only then, mount the provider once next to your UI kit:

import { QuerySuspenseProvider } from '@proedis/react-query';

const AppQuerySuspense: React.FunctionComponent<React.PropsWithChildren> = ({ children }) => (
  <QuerySuspenseProvider
    config={{
      Loader      : () => <Center><Loader /></Center>,
      ErrorView   : ({ error, reload }) => <ErrorAlert error={error} onRetry={reload} />,
      NotFound    : <EmptyContent icon={ICONS.SEARCH} title={'Nessun Risultato'} />,
      AsideWrapper: ({ children: aside }) => <Box className={classes.asideComponent}>{aside}</Box>,

      debugErrors  : import.meta.env.DEV,
      onTitleChange: setPageTitle,
      defaultTitles: { pending: 'Caricamento…', error: 'Errore' }
    }}
  >
    {children}
  </QuerySuspenseProvider>
);

Every key is optional and merges over the defaults, so replacing one view leaves the others alone.

📖 API

suspense

querySuspenseComponent(Component, queryArgs, options?)

One form, and no hook to hand over: the query is described by the arguments of useClientQuery, which is exactly what proedis scaffold hooks writes as getXQueryArgs() and what call sites have always written by hand.

const ActivityDetail = querySuspenseComponent(
  ActivityDetailRender,
  props => getSingleActivityQueryArgs(props.id),
  {
    pageTitle: { success: ({ data }) => data.name },

    resetOnUnmount: true
  }
);

const ActivityVehiclesList = querySuspenseComponent<ActivityVehicleBaseDto[], ActivityVehiclesProps>(
  ActivityVehiclesListContent,
  ({ activity }) => ([
    generateEndpointQueryKey('activity-vehicles'),
    { transformer: ActivityVehicleBaseDto, params: { parentId: activity.id } }
  ]),
  { emptyContent: <EmptyContent.EmptyListDefault /> }
);

The arguments are typed readonly [ ...Parameters<typeof useClientQuery<TData>> ], and both halves of that matter: readonly, so the as const tuple of a generated getXQueryArgs() fits without a cast; bound to the data type, so a transformer that does not produce what the component renders is a compile error.

queryArgs may be the arguments themselves or a builder over the props. Builders run during render, so they may call hooks of their own — a route layout reading useParams() inside one is a supported pattern, not an accident.

resetOnUnmount drops the query on unmount, using the key it queried — the first argument. Declare queryKey when something wider should go: a generated getXQueryKey() called with no argument is the prefix of the whole resource.

The five views

| Slot | Rendered when | When unset | | --- | --- | --- | | Loader | the query has no data yet | an empty <span role="status" aria-busy> | | ErrorView | the query failed | <div role="alert"> with the error's message | | NotFound | classifyError returned 'notFound' | falls back to ErrorView | | emptyContent | the data is empty | the content renders anyway | | — | otherwise | Component |

The two defaults are one element each, with no class, no copy and no dependency: enough for the boundary to work unconfigured, and deliberately too plain to be mistaken for a design. DEFAULT_QUERY_SUSPENSE_CONFIG and classifyRequestError are exported, so a provider can extend a default instead of replacing it.

Every slot accepts an element, a component, or any renderable node, in the config and as a per-instance override:

emptyContent={<EmptyContent.EmptyThreadDefault />}          // an element, rendered as it is
emptyContent={({ reload }) => <Retry onClick={reload} />}   // a component, given the view props

A component form receives { error, reload, state } — the whole query result, not only the error, so a view can tell a first failure from a failed refetch.

Declaring emptyContent is the shorthand for "this query returns a list": isEmpty defaults to the built-in check — an array with no elements — as soon as it is present. Any other shape takes a predicate: isEmpty: data => !data.items.length.

classifyError defaults to statusCode === 404 → 'notFound', which the package can afford because the error type is the client's. Override it for an endpoint that answers 403 for "you may not see this one", or 200 with an empty envelope.

debugErrors replaces the !import.meta.env.DEV condition this pattern used to carry inline: a "not found" view hides the response that produced it, and during development the response is the interesting part. It is a config flag, not a bundler global, so it also works under Node.

The skeleton

Wrapper, ContentWrapper, Header and Footer are rendered in every state, and their element identity stays stable across the five: the panel and its header do not remount when the content swaps from loader to data, so nothing jumps and no scroll position is lost.

Each of them receives the caller's own props merged with { state, reload }, which is what lets a wrapper read the entity it is framing:

{
  Wrapper: ({ children, activity, state }) => (
    <Panel header={activity.name} loading={state.isFetching}>{children}</Panel>
  )
}

AsideWrapper, in the config, wraps Header, Footer and the error view — the seam for the spacing a design system puts around the parts that sit beside the content.

Page title

{ pageTitle: { success: ({ data }) => data.name, pending: 'Caricamento…' } }

Resolvers are declared in a module-scope options object, outside React, so they cannot call a hook to translate themselves. Whatever they need arrives through titleTools in the config, typed by declaration merging — the same pattern @proedis/react-client uses for ContextClientOverride:

declare module '@proedis/react-query' {
  interface QuerySuspenseTitleTools {
    t: TranslationFunction;
  }
}

// in the provider, which is inside React:
titleTools: { t }

// at the call site:
{ pageTitle: { success: ({ data, t }) => `${t('PAGE.ACTIVITY')} ${data.name}` } }

Reading the data further down

const ActivityName: React.FunctionComponent = () => {
  const [ activity ] = useSuspendedContext<ActivityCompleteDto>();
  return <span>{activity.name}</span>;
};

The engine, and the pure part

QuerySuspense is the component the HOC renders; use it directly only when the query is already in hand and has to stay there. resolveQueryView(state, options) is the branch order alone — a plain function over { status, data, error }, testable on object literals and reusable outside React.

invalidation

| Export | What it does | | --- | --- | | useQueryInvalidation(queries, options) | A stable invalidator. queries is a list or a builder over the call context | | InvalidationQueue | enqueue / flush / isPaused / clear, keyed per QueryClient | | useInvalidationGate(lock) | Defers invalidation while lock is true, flushes when the last holder releases | | useRefetchPause(lock) | Suppresses window-focus refetch for the queries already in cache on mount | | refetchOnWindowFocusGate(getQueryClient) | The QueryClient option that makes the above effective | | PauseGate | The counting lock underneath both gates |

const invalidateUsers = useQueryInvalidation([ [ 'users' ], { queryKey: [ 'users', 'count' ], exact: true } ]);
const invalidateOne = useQueryInvalidation<UserDto>(user => ([ [ 'users', user.id ] ]));

A bare key is a prefix filter, which is why a generated getXQueryKey() called with no argument is the right thing to invalidate a whole resource. Filters dedupe on key + exact, so the same key coming from three mutations in one tick costs one invalidation, and the whole set runs inside a single notifyManager.batch — one render pass, not one per key.

The gates exist for one screen: a modal open over a list. Without them every mutation inside it refetches the list underneath, rows move while the user is still typing, and coming back from another window reorders everything. useInvalidationGate(open) collapses those refetches into one, fired when the modal is gone; useRefetchPause(open) freezes what was already on screen while leaving the modal's own queries live.

⚠️ Requirements

One thing is inert without wiring, and it fails silently:

useRefetchPause needs the client option. Nothing in @tanstack/react-query consults the gates on its own:

const queryClient: QueryClient = new QueryClient({
  defaultOptions: { queries: { refetchOnWindowFocus: refetchOnWindowFocusGate(() => queryClient) } }
});

State is keyed by QueryClient in a WeakMap, not held in module scope — two roots mounted side by side never share a queue, and a test that builds a client per case leaks nothing.

🤝 Compatibility

| | | | --- | --- | | React | >=18.0.0 <20.0.0 | | @tanstack/react-query | ^5.0.0 | | @proedis/react | ^2.0.0 | | @proedis/react-client / @proedis/client | ^4.0.0 / ^3.0.0 — suspense only | | TypeScript | >=5.5 | | Runtime | ES2022 — Safari 16.4 / Chrome 94 / Node 16.11 |

📄 License

MIT © Proedis S.r.l.