Skip to content

Internal resource-hook factory: deduplicate query-keys/hooks boilerplate across plugins #129

Description

@olliethedev

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions