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

orval-data-handler

v0.1.0

Published

Data policy for Orval-generated React Query clients: unwraps paginated GET responses and decides per write whether to update the cache, refetch from the server, or apply the change optimistically.

Readme

orval-data-handler

A data policy for the react-query clients generated by orval.

For every write, the page decides what happens to the cache: the record from the response is merged over the cached one, another GET is made, or the change is applied optimistically, from the payload.

It is not a wrapper around orval: the generated hooks stay visible in the pages, with their native types.

Architecture

The package is split into layers (clean architecture, ports and adapters). Dependencies only point inward:

src/
  domain/           pure rules, no react-query: records, response shapes,
                    write plans, resource rules, API errors
  application/      the use case (WriteSynchronizer) and its ports:
                    CacheStore, MutationNotifier, WriteLifecycle
  infrastructure/   the adapters: react-query (cache, mutation options),
                    http (toApiError), auth (access token), logging
  data-handler.ts   composition root: the app's policy, wired to the adapters
  index.ts          the public API

| Layer | May import from | |---|---| | domain | nothing outside itself | | application | domain | | infrastructure | application, domain, @tanstack/react-query | | data-handler.ts | everything |

DataHandler is still the only class the app extends, exactly once. It holds the policy (resources, rules, override points), and the mechanics are delegated: the cache is touched only through the CacheStore port, so WriteSynchronizer can be tested with a fake cache, without a QueryClient.

What it assumes about the API

| Assumption | Why | |---|---| | Query keys start with the resource name | orval with shouldSplitQueryKey: true | | Records have an id (string or number) | that is how they are found in the cache | | Responses are lists, paged lists ({ data: [] }) or a single record | the three recognized shapes |

A response that doesn't fit — a report ({ imported, skipped }), a compound one ({ task, deal, company }) — leads to the resource being re-fetched. That's not an error case, it's the safety net. If the record is in there after all, pick it out yourself with DataHandler.writeFromResponse((response) => response.task).

Installation

npm install orval-data-handler

Peer dependencies: @tanstack/react-query v5 and react >= 18.


1. What happens on a write

With no code at the call site, a generated mutation already gets: an error message, a cache update, and a success message if the page asked for one.

const updateContact = useUpdateContact({
  mutation: { meta: { successMessage: "Contact saved." } },
});

By default, if the server returned the record, it is merged over the cached one — zero GETs. If not, the resource is re-fetched.

When you want something else, say so in meta.cache, with one of the static methods of DataHandler:

| Plan | When | Requests | |---|---|---| | writeFromResponse(pick?) | the server returns the record, possibly nested in a compound response | 0 | | reloadAfterWrite(...keys?) | the server computes more than it sends back | 1 | | writeOptimistically(pick) | the change is exactly what you sent and you want it visible immediately | 0–1 | | skipCacheWrite() | the write has no data to display | 0 |

They type the response and the payload at the call site: meta is Record<string, unknown> for react-query and can't be typed per mutation, so you write the type yourself, from the generated models.

writeFromResponse — the record comes from the server

// POST /tasks/{taskId}/complete-call -> { task, deal, company, followUpTask }
const completeCall = useCompleteCallTask({
  mutation: {
    meta: {
      cache: DataHandler.writeFromResponse(
        (response: CompleteCallTaskResponse) => response.task,
      ),
    },
  },
});

Without pick, the response is taken as-is if it has a top-level id — which is exactly the default behavior.

The record isn't replaced, it's merged: { ...fromCache, ...fromServer }. That way the denormalized fields that the list has and the write doesn't send back survive (CompanyListItem = Company & { primaryContactEmail, ... }).

Watch out when the server creates something else too. In the example above, completeCallTask may also create a followUpTask — a new row that isn't in any list. With writeFromResponse you'd see the updated task, but not the new one. There the default behavior (re-fetching) is the right answer.

reloadAfterWrite — one more GET

cache: DataHandler.reloadAfterWrite()                              // the whole resource: lists, details, sub-routes
cache: DataHandler.reloadAfterWrite(getGetUsersQueryKey(params))   // only the GET with these filters

For writes where the server computes more than it sends back: a recomputed total, a sort order that changes, rows entering or leaving the current filter. With explicit keys, the rest of the resource is left untouched.

writeOptimistically — write from the payload, before the response

const updateNotes = useUpdateTaskNotes({
  mutation: {
    meta: {
      cache: DataHandler.writeOptimistically(
        (variables: { taskId: string; data: UpdateTaskNotesRequest }) => ({
          id: variables.taskId,
          notes: variables.data.notes,
        }),
      ),
    },
  },
});

pick receives the mutation's variables themselves, so the type comes from the generated code. It must return the id: that's how the record is found. For a delete operation the id is enough — the row disappears from lists right away.

What happens, in order:

  1. In-flight GETs for the resource are cancelled, so a late response can't wipe out the write
  2. The cache state is copied, and what pick returned is written over it
  3. If the request succeeds, normal syncing continues: the response is written over the optimistic version, or the resource is re-fetched. What the page guessed is replaced by the server's truth, not left as-is
  4. If it fails, the copy is restored and only the error toast remains

The copy lives in a WeakMap keyed on the mutation's variables object, not in its context. That way the page's onMutate keeps its context untouched, and two parallel writes don't get mixed up.

skipCacheWrite — don't touch the cache

For a ping, a follow. Success and error messages still go out: the plan is only about the cache.

What stays with the page

mutation.onSuccess / onError / onMutate are called after ours, not replaced, and receive their own context, untouched:

const updateContact = useUpdateContact({
  mutation: {
    meta: { successMessage: "Contact saved." },
    onSuccess: () => setEditingCompany(null),   // UI only
  },
});

2. Wiring it into the app

2.1 Extend DataHandler

Only one member is required: resourceNames. The rest have defaults.

// src/api-data-handler.ts
import { DataHandler, type ResourceRules } from "orval-data-handler";

type Resource = "companies" | "contacts" | "deals";

class ApiDataHandler extends DataHandler<Resource> {
  protected readonly resourceNames = ["companies", "contacts", "deals"] as const;

  // Only the exceptions. Default: fresh for one minute, no other resource re-fetched.
  protected readonly rules: ResourceRules<Resource> = {
    contacts: { alsoChanges: ["companies", "deals"] },
  };
}

export const apiDataHandler = new ApiDataHandler();

alsoChanges are the resources the server modifies at the same time and that can't be inferred from the response. They are always re-fetched, whatever the plan.

You can also override defaultRules, getErrorMessage, isDeleteOperation and onWriteCompleted. By default, getErrorMessage reads the message from the error body (see Security), and onWriteCompleted logs to the console, in development only.

2.2 Write the custom hook orval receives

The class doesn't call hooks; it receives them. This is where React is wired to it.

// src/mutation-options.ts
import { useQueryClient, type UseMutationOptions } from "@tanstack/react-query";

import { apiDataHandler } from "./api-data-handler";

export function useApiMutationOptions<TData, TError, TVariables, TContext>(
  options: UseMutationOptions<TData, TError, TVariables, TContext>,
  endpoint: { url: string },
  operation: { operationId: string; operationName: string },
) {
  const queryClient = useQueryClient();
  const { notify } = useNotifications();

  return apiDataHandler.buildMutationOptions(options, endpoint, operation, {
    notify,
    queryClient,
  });
}

It must be a literal declaration with three parameters. Orval parses the file and counts the parameters to know how many arguments to pass. With fewer, it stops passing the third argument — so the operation name is lost, and with it delete detection.

notify is optional: without it, meta.successMessage and error messages are ignored.

If you need nothing extra, useDataHandlerMutationOptions(apiDataHandler, options, endpoint, operation, { notify }) does exactly this.

2.3 Wire it in orval.config.ts

output: {
  client: "react-query",
  override: {
    query: {
      shouldSplitQueryKey: true,
      mutationOptions: {
        path: "./src/mutation-options.ts",
        name: "useApiMutationOptions",
      },
    },
  },
}

2.4 Apply freshness to the QueryClient

const queryClient = new QueryClient();
apiDataHandler.applyQueryDefaults(queryClient);

2.5 Typed meta (optional)

import type { WriteMeta } from "orval-data-handler";

declare module "@tanstack/react-query" {
  interface Register {
    mutationMeta: WriteMeta;
  }
}

The library doesn't do the augmentation for you, so you can add fields of your own.


Debugging

Every write goes through onWriteCompleted. By default, one console line, only when NODE_ENV is development:

[data-handler] updateDeal: merged in "deals" (fromResponse) | reloaded: tasks
[data-handler] updateTaskNotes: merged in "tasks" (optimistic, applied early) | reloaded: -
[data-handler] createCompany: reloaded in "companies" (fromResponse) | reloaded: companies, tasks

| Symptom | Likely cause | Where to fix | |---|---|---| | Another page keeps stale data | the resource is missing from alsoChanges | rules | | No log line appears | the URL doesn't start with a resource from resourceNames | resourceNames | | reloaded on a simple update | the response has no top-level id | writeFromResponse(pick) | | Too many requests | alsoChanges too broad | rules | | A new row doesn't show up | only the response was written, not the list | reloadAfterWrite() |

Override onWriteCompleted to change the format. It receives the whole event:

type WriteEvent<TResource> = {
  operationName: string;
  resourceName: TResource;
  outcome: "merged" | "removed" | "reloaded" | "skipped";
  strategy: "fromResponse" | "reload" | "optimistic" | "skip";
  wasOptimistic: boolean;
  updatedQueryKeys: readonly QueryKey[];
  reloadedResources: readonly TResource[];
  reloadedQueryKeys: readonly QueryKey[];
};

API

DataHandler<TResource> (abstract — the app extends it once)

Instance — the policy:

| Member | What it does | |---|---| | resourceNames (abstract) | the resource names | | rules, defaultRules | the exceptions and the defaults | | getErrorMessage, isDeleteOperation, onWriteCompleted | override points | | buildMutationOptions(options, endpoint, operation, context) | the options given to generated mutations | | applyQueryDefaults(queryClient) | binds staysFreshFor to the client | | getRulesFor(resource), resolveResourceName(urlOrQueryKey) | introspection |

Static — the mechanics, no configuration:

| Method | What it does | |---|---| | writeFromResponse, reloadAfterWrite, writeOptimistically, skipCacheWrite | the plans for meta.cache | | writeRecordToCache(queryClient, resource, record) | merges the record into every query of the resource | | removeRecordFromCache(queryClient, resource, id) | removes it from lists | | invalidateResource(queryClient, resource) | marks the resource as stale | | invalidateQueries(queryClient, queryKeys) | same, but only for the given keys | | readRecords(response), readPageInfo(response) | read a response, whatever its shape |

writeRecordToCache and removeRecordFromCache return the modified query keys; an empty array means "the record wasn't in the cache". The static methods also work outside generated mutations — an import running in batches, for example: DataHandler.invalidateResource(queryClient, "companies").

useDataHandlerMutationOptions(dataHandler, options, endpoint, operation, { notify }) — the hook from 2.2, ready-made.

Errors and authentication:

| Function | What it does | |---|---| | toApiError(errorOrResponse) | turns a failed Response (or anything else) into an ApiError with status and body | | createApiError(message, { status, body }) | builds an ApiError | | getErrorMessage(error), getErrorStatus(error) | the message to show in the UI and the HTTP status | | getAccessToken, setAccessToken, clearAccessToken | the access token, kept in memory |

Exported types: ApiError, CacheSnapshot, MutationNotifier, RecordWithId, ResourceRules, WriteEvent, WriteMeta, WritePlan.

Security

| What | How | |---|---| | Access token | lives only in browser memory. On the server setAccessToken throws: there the module is shared by all requests, so the token would reach other users | | Error messages | server text reaches the UI only for 4xx, truncated to 500 characters. For 5xx the message is generic; details (SQL, stack traces) stay only in error.body | | Empty or invalid error bodies | toApiError keeps the status, so getErrorStatus can still recognize a 401/403 | | meta.cache | is validated: an incomplete plan ({ strategy: "reload" } without keys) is ignored, not executed | | A successful write | stays successful even when pick throws: the resource is re-fetched and the error is reported with reportError | | Debug log | development only, because query keys may contain filters with user data |

License

MIT