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",
  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#

The root of the declaration is what every action starts from; views.<action> adjusts it for one action, and the two are merged deeply. 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: inputs: { notes: false } removes an inherited field, components: { top: null } an inherited component.

createViewResource("articles", {
  name: "Articles",
  path: "/api/articles",
  // Shared by every view…
  form: { inputs: { title: { label: "Title" }, body: { label: "Body" } } },
  // …and adjusted per action: only what differs, merged deeply.
  views: {
    list: { title: "All articles", pageSize: 20 },
    create: {
      title: "New article",
      form: { inputs: { title: { required: true } } },
    },
  },
})

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

NameTypeDefaultDescription
views.listListViewDefinition—The collection. Owns layouts, filters, pageSize, sort, rowActions, selectable and bulkActions.
views.readReadViewDefinition—One item. Owns open, expandable and the tabs under the record.
views.createCreateViewDefinition—The creation form. Owns open, afterSave and optimistic.
views.updateUpdateViewDefinition—The edit form. What the creation form owns, and tabs.
views.deleteDeleteViewDefinition—The delete confirmation. Owns open and optimistic.

The list has no separate column definition

The table renders one column per field of the resource's 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. A layout that shows fewer picks them with fields: ["title", "status"].

Where an action opens#

An action is a screen, a dialog or a drawer — the same view either way, drawn in a different frame. views.<action>.open is the whole decision, and it is taken per action: a list is read full page, and the form that edits one of its rows does not have to be.

createViewResource("articles", {
  path: "/api/articles",
  views: {
    // A screen of its own, at /articles/read/42 — the default.
    read: { open: "page" },
    // A dialog over the list.
    create: { open: "dialog" },
    // A panel sliding in from the right — up from the bottom on a phone.
    update: { open: "drawer" },
  },
})
  • page — the view replaces the page, at its own URL. Shareable, bookmarkable, and where a list belongs.
  • dialog — a dialog centred over the page. Short forms and delete confirmations, where what is behind is the context the reader is keeping.
  • drawer — a panel sliding in from the right on a desktop, up from the bottom on a phone, where the thumb is. As tall as the screen, so a record with a dozen fields is filled in without the list going anywhere.
NameTypeDefaultDescription
open"page" | "dialog" | "drawer" | OpenFrame"page"Set per action, on views.<action>, or once for the whole application through configureResourceView({ defaults }). The delete confirmation is the one action that starts as a dialog. A frame of one's own is built with open.custom().

Create and edit in a drawer — narrow the window to see it change sides

The drawer is two shapes, not one

Which side it comes in from is decided at 768px — the same breakpoint the views size against — and the side it arrives from is the side it is swiped away towards. So a drawer is dismissed rightwards on a desktop and downwards on a phone, with no configuration either way.

A view drawn over the page is still written into the URL, under the list it was opened from — …/articles/list/articles/update/42, filters and page included — so a link, Back and a reload find it as it was. What happens once the write lands is afterSave: "close" (the default in a dialog or a drawer) goes back to the list, "stay" (the default on a page) keeps the form, "reload" fills it again with what the API answered, and { redirect: (item) => string } goes elsewhere.

Full width#

Under AdminLayout, views are drawn in a column of constrained width — the right measure for a form or a record, too narrow for a board, a calendar, a timeline or a table with a dozen columns. fullWidth hands the page's whole width to the views that need it, and to those only.

createViewResource<Task>("tasks", {
  path: "/api/tasks",
  // Every action of the resource takes the page's whole width…
  fullWidth: true,
  views: {
    list: {
      layouts: [
        layout.board({ groupBy: "status", columns: STATUSES }),
        layout.table(),
      ],
    },
    // …but its forms, which read better narrow.
    create: { fullWidth: false },
    update: { fullWidth: false },
  },
})

// Or a single layout of a list, the others keeping the column:
layouts: [
  layout.board({ groupBy: "status", columns: STATUSES, fullWidth: true }),
  layout.table(),
]

It is read at three levels, and the most specific one wins: a list layout, then the action's entry in views, then the root of the resource. So a resource declared full width keeps its forms narrow with a fullWidth: false on those actions, and a board can be the one wide layout of a list whose table stays in the column.

NameTypeDefaultDescription
fullWidthbooleanfalseAt the root, every action of the resource; on views.<action>, that action; on a layout built by layout.*(), that layout. The side margins stay, and a narrow screen is laid out as before.

Resource reference#

NameTypeDefaultDescription
@idrequiredstring—First argument. Identifies the resource in the registry, and in URLs.
namestring—Human-readable label. Defaults to the id.
pathstring—Collection endpoint — /api/articles. Without it, declare storage: "local" or a repository.
iconFC<{ className?: string }>—Shown in menus, in tabs and in the header of a list.
aliasstring—A second identifier the router also accepts.
storage"local"—Keeps the records in localStorage instead of an API — declared, so a forgotten path does not look like a working API.
dialectApiDialectInterface—How this resource's API spells its requests, when it is not the one given to configureApi. See Backends.
form, fullWidth, titleField, components, descriptionCommonViewOptions—What every action starts from, adjusted per action in views — fullWidth included, see Full width.
views{ list, read, create, update, delete }—What is particular to one action, merged over the root.
permissions"all" | "readonly" | Action[] | { [action]: boolean | fn }"all"What the users may do — everything unless restricted here. See Permissions.
hooksResourceHooks—Code run around the calls to the data. See Shaping requests.
realtimeboolean—Reloads the views when the API pushes a change (Mercure).
optimisticbooleantrueDraws every write before the API answers, and puts it back if the API refuses.
quota{ get, fallback }—A creation quota, with a fallback rendered once reached.
repository{ getCollection, getItem, createItem, updateItem, removeItem }—Replaces some or all of the calls to the data, one method at a time.

Where the data comes from#

createViewResource picks a repository from what it declares: a path, or storage: "local".

  • 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.
  • With storage: "local" — a localStorage repository keyed on the resource id, with the same interface. Neither declared, development throws an error naming the resource.
// `storage: "local"` → localStorage. Every demo on this site is declared this way.
const drafts = createViewResource("local_drafts", {
  name: "Drafts",
  storage: "local",
  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 in repository 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#

hooks sit between the views and the repository. Each receives { viewResourceContext }, and may answer asynchronously, 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",
  hooks: {
    // Shape the query before a collection is fetched.
    beforeList: (params, context) => ({
      ...params,
      "order[publishedAt]": "desc",
    }),

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

    beforeUpdate: (data) => ({ ...data, updatedAt: new Date().toISOString() }),

    // Told what the API answered.
    afterSave: (article, { action }) => notify(`Article ${action}d`),
  },
})
NameTypeDefaultDescription
beforeList(params, context) => params—Before a collection is fetched. Sorting, extra query parameters, a forced filter.
beforeRead(params, context) => params—Before a single item is fetched.
beforeCreate(data, context) => data—Before a create. Injecting an owner or a tenant.
beforeUpdate(data, context) => data—Before an update.
beforeDelete(item, context) => item—Before a delete.
afterSave(item, context & { action }) => void—After a create or an update, with the record the API answered.
afterDelete(item, context) => void—After a delete.

The filter currently applied is merged into the collection request after beforeList 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.