react-resource-view

Declaring a resource

createViewResource, its views, and the repository behind them.

The smallest declaration#

An identifier, a path and a form. That is enough for a list, a detail page, a create form, an edit form and a delete confirmation.

articles.ts
import { createViewResource } from "react-resource-view"

export const articles = createViewResource("articles", {
  name: "Articles",
  path: "/api/articles",
  view: {
    form: {
      inputs: {
        title: { label: "Title", required: true },
        author: { label: "Author" },
      },
    },
  },
})

Declare resources at module scope

createViewResource registers the resource in the shared registry. Calling it inside a component would register it again on every render — declare it once, in a module, and import it.

Five views from one description#

view is the description every action starts from; views overrides it per action. So the common case — one form for creating and editing, one set of columns for the list — is written once, and only the differences are stated.

createViewResource("articles", {
  name: "Articles",
  path: "/api/articles",
  // Shared by every view…
  view: {
    form: { inputs: { title: { label: "Title" }, body: { label: "Body" } } },
  },
  // …and overridden per action.
  views: {
    [ActionList.list]: { name: "All articles", itemsPerPage: 20 },
    [ActionList.create]: {
      name: "New article",
      form: { inputs: { title: { label: "Title", required: true } } },
    },
    [ActionList.read]: { behavior: { canExport: true } },
  },
})

One declaration, five actions — open a row, edit it, create one

NameTypeDefaultDescription
views.listViewListInterfaceThe collection. Owns viewVariants, formFilter, defaultFilter and itemsPerPage.
views.readViewInterfaceOne item. Owns the sub-views and the export button.
views.createViewUpdateInterfaceThe creation form.
views.updateViewUpdateInterfaceThe edit form.
views.deleteViewUpdateInterfaceThe delete confirmation.

The list has no separate column definition

The table renders one column per field of view.form, in the order the fields are declared, skipping those marked generatedValue. Adding a column means adding a field — and that field is then editable everywhere the form appears.

Resource reference#

NameTypeDefaultDescription
@idrequiredstringFirst argument. Identifies the resource in the registry, and in URLs.
namestringHuman-readable label. Defaults to the id.
pathstringCollection endpoint — /api/articles. Left out, the resource is backed by localStorage.
iconFC<{ className?: string }>Shown in menus and tabs.
scopestringWhich area of the application it belongs to. See Scopes.
aliasstringA second identifier the router also accepts.
viewViewListInterfaceThe description every action starts from.
views{ list, read, create, update, delete }Per-action overrides.
canList / canRead / canCreate / canUpdate / canDeleteboolean | (() => boolean)Permissions, evaluated on render. See Permissions.
limitLimitInterfaceA creation quota, with a fallback rendered once reached.
decoratorComponentFC<{ children }>Wraps every view of this resource.
onChangePubSub<{ data, action }>Publishes after every successful write.
getCollection / getItem / createItem / updateItem / removeItemfunctionsThe repository. Supplied for you, and replaceable one method at a time.

Where the data comes from#

createViewResource picks a repository from one thing: whether the resource has a path.

  • With a path — an HTTP repository speaking the configured dialect: GET /api/articles and PATCH /api/articles/42 on API Platform, GET /api/articles?pagination[page]=1 on Strapi, GET /rest/v1/articles?limit=30 on Supabase. A resource may carry a dialect of its own, for an application reading two backends at once.
  • Without one — a localStorage repository keyed on the resource id, with the same interface.
// No `path` → localStorage. Every demo on this site is declared this way.
const drafts = createViewResource("local_drafts", {
  name: "Drafts",
  view: { form: { inputs: { title: { label: "Title" } } } },
})

The second is not only for demos: it is a genuine offline store, and it is what makes a resource testable without a server.

Or bring your own

Any of the five methods can be given directly on the declaration and takes precedence — a resource reading from IndexedDB, from a GraphQL endpoint, or from an in-memory fixture is the same declaration with five functions on it.

Shaping requests#

Three hooks sit between the views and the repository. Each receives the current context, so a value can be derived from the surrounding view — the parent item of a sub-view, the active filter, the current scope.

createViewResource("articles", {
  path: "/api/articles",

  // Shape the query before a collection is fetched.
  preGetCollection: (params, context) => ({
    ...params,
    "order[publishedAt]": "desc",
  }),

  // Shape the payload before it is written.
  preCreate: (data, context) => ({
    ...data,
    workspace: context?.viewResourceContext?.filter?.workspace,
  }),

  preUpdate: (data) => ({ ...data, updatedAt: new Date().toISOString() }),
})
NameTypeDefaultDescription
preGetCollection(params, context) => paramsBefore a collection is fetched. Sorting, extra query parameters, a forced filter.
preGetItem(params, context) => paramsBefore a single item is fetched.
preCreate(data, context) => dataBefore a create. Injecting an owner or a tenant.
preUpdate(data, context) => dataBefore an update.

The filter currently applied is merged into the collection request after preGetCollection runs, so a hook cannot accidentally erase what the reader typed.

Reacting to writes#

Every successful create, update or delete publishes on the resource's onChange. That is how a sub-view refreshes when its parent changes, and where analytics or cache invalidation belong.

// Every write publishes, so a sibling view can refresh itself.
articles.onChange.subscribe(({ data, action }) => {
  if (action === ActionList.delete) analytics.track("article.deleted", data)
})

Next

With the resource declared, the interesting question is how its list is laid out — which is the next page.