Context
Part of the Refine-inspired core simplification effort (#127 ✅, #128, #130 ✅, #131, #132, #133). Refine gets this from generic useList/useOne hooks over a DataProvider; BTST keeps its typed better-call layer and instead generates the repetitive React Query plumbing internally.
Scope note: this issue covers the core primitives only (phase 1). Per-plugin adoption across all 8 plugins is tracked in #136 (phase 2); removal of legacy dual paths in #137 (phase 3).
Problem
Every data plugin repeats the same plumbing:
isErrorResponse / toError helpers copy-pasted into each query-keys.ts
SHARED_QUERY_CONFIG (SSR-safe refetch settings) redefined per plugin
- Every
queryFn repeats the fetch → error-check → unwrap dance (packages/stack/src/plugins/blog/query-keys.ts is 279 lines of mostly this)
- Every hooks file repeats
useQuery/useSuspenseQuery/useMutation + invalidation wiring (blog-hooks.tsx is ~700 lines)
- Every form component hand-wires fetch record → defaults → submit → invalidate → toast → navigate
- Server-side Zod validation errors are flattened into a generic message instead of landing on form fields
- Relation pickers (kanban assignees, cms references, media picker) are re-solved ad hoc per plugin
Proposal
createResource factory
In packages/stack/src/plugins/client/, owning error handling, unwrapping, query keys, and shared config once:
const blog = createResource<BlogApiRouter>(client, {
posts: {
list: { path: "/posts", select: (d) => d.items, infinite: true },
detail: { path: "/posts", query: (slug: string) => ({ slug, limit: 1 }), select: (d) => d.items[0] ?? null },
},
tags: { list: { path: "/tags" } },
});
export const useSuspensePost = (slug: string) => blog.posts.detail.useSuspense(slug);
export const useCreatePost = () => blog.posts.useCreate("/posts", { invalidates: ["posts"] });
- Generates: plain + suspense queries, infinite queries, mutations with invalidation, query-key factory entries compatible with SSG
prefetchForRoute / query-key-defs.ts
isErrorResponse, toError, SHARED_QUERY_CONFIG move to core
- Types flow from the better-call router generics — no stringly-typed resources
StackError — standardized error shape with field-level validation
interface StackError extends Error {
statusCode?: number;
errors?: Record<string, string | string[]>; // field name → message(s)
}
better-call endpoints already validate with Zod; preserve Zod's field-level issues in error responses so useForm can map them onto form fields.
useForm — create/edit lifecycle
const form = blog.posts.useForm({
action: slug ? "edit" : "create",
id: slug,
redirect: "detail", // navigates via router adapter (#127)
successMessage: "Post saved", // flows through notification provider (#131)
});
// server Zod failure on "title" → form.fieldErrors.title, not a generic toast
Bundles: fetch record, defaults, submit the right mutation, invalidate, notify, redirect, field-error mapping. Depends on #131 for notify; #127 (done) for redirect.
useSelect — relation pickers
Debounced server-side search + current-value preloading + loading states, per resource. Existing override escape hatches stay where identity comes from outside the stack (e.g. kanban users).
Scope (core only)
Acceptance criteria
- Factory +
useForm + useSelect fully unit-tested against the memory adapter
- A server-side Zod validation failure surfaces as
StackError.errors and lands on the matching form field via useForm (unit test)
- SSG
prefetchForRoute / query-key-defs.ts compatibility demonstrated (blog keys reproducible through the factory)
- Public hook name convention (
usePosts, useSuspensePost) expressible as one-liners over the factory
Plugin adoption (all 8 plugins) → #136. Legacy deletion → #137.
Context
Part of the Refine-inspired core simplification effort (#127 ✅, #128, #130 ✅, #131, #132, #133). Refine gets this from generic
useList/useOnehooks over aDataProvider; BTST keeps its typed better-call layer and instead generates the repetitive React Query plumbing internally.Scope note: this issue covers the core primitives only (phase 1). Per-plugin adoption across all 8 plugins is tracked in #136 (phase 2); removal of legacy dual paths in #137 (phase 3).
Problem
Every data plugin repeats the same plumbing:
isErrorResponse/toErrorhelpers copy-pasted into eachquery-keys.tsSHARED_QUERY_CONFIG(SSR-safe refetch settings) redefined per pluginqueryFnrepeats the fetch → error-check → unwrap dance (packages/stack/src/plugins/blog/query-keys.tsis 279 lines of mostly this)useQuery/useSuspenseQuery/useMutation+ invalidation wiring (blog-hooks.tsxis ~700 lines)Proposal
createResourcefactoryIn
packages/stack/src/plugins/client/, owning error handling, unwrapping, query keys, and shared config once:prefetchForRoute/query-key-defs.tsisErrorResponse,toError,SHARED_QUERY_CONFIGmove to coreStackError— standardized error shape with field-level validationbetter-call endpoints already validate with Zod; preserve Zod's field-level issues in error responses so
useFormcan map them onto form fields.useForm— create/edit lifecycleBundles: fetch record, defaults, submit the right mutation, invalidate, notify, redirect, field-error mapping. Depends on #131 for notify; #127 (done) for redirect.
useSelect— relation pickersDebounced server-side search + current-value preloading + loading states, per resource. Existing override escape hatches stay where identity comes from outside the stack (e.g. kanban users).
Scope (core only)
createResourcefactory + unit tests (memory adapter, see btst-testing skill)isErrorResponse/toError/SHARED_QUERY_CONFIGexported from coreStackErrorcontract intoError; preserve Zod field issues in better-call error responsesuseFormwith invalidation, notify (Notification provider: pluggable notify() instead of hardcoded sonner toasts in every plugin #131), redirect (Top-level router adapter on StackProvider (framework presets for Link/navigate/refresh/Image) #127), field-error mapping + unit testsuseSelectwith debounced search + current-value preloading + unit testscreateResourcefrom the startAcceptance criteria
useForm+useSelectfully unit-tested against the memory adapterStackError.errorsand lands on the matching form field viauseForm(unit test)prefetchForRoute/query-key-defs.tscompatibility demonstrated (blog keys reproducible through the factory)usePosts,useSuspensePost) expressible as one-liners over the factoryPlugin adoption (all 8 plugins) → #136. Legacy deletion → #137.