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

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions