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-handlerPeer 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,
completeCallTaskmay also create afollowUpTask— a new row that isn't in any list. WithwriteFromResponseyou'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 filtersFor 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:
- In-flight GETs for the resource are cancelled, so a late response can't wipe out the write
- The cache state is copied, and what
pickreturned is written over it - 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
- 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
