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.
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
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
| Name | Type | Default | Description |
|---|---|---|---|
views.list | ListViewDefinition | — | The collection. Owns layouts, filters, pageSize, sort, rowActions, selectable and bulkActions. |
views.read | ReadViewDefinition | — | One item. Owns open, expandable and the tabs under the record. |
views.create | CreateViewDefinition | — | The creation form. Owns open, afterSave and optimistic. |
views.update | UpdateViewDefinition | — | The edit form. What the creation form owns, and tabs. |
views.delete | DeleteViewDefinition | — | 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.
| Name | Type | Default | Description |
|---|---|---|---|
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.
| Name | Type | Default | Description |
|---|---|---|---|
fullWidth | boolean | false | At 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#
| Name | Type | Default | Description |
|---|---|---|---|
@idrequired | string | — | First argument. Identifies the resource in the registry, and in URLs. |
name | string | — | Human-readable label. Defaults to the id. |
path | string | — | Collection endpoint — /api/articles. Without it, declare storage: "local" or a repository. |
icon | FC<{ className?: string }> | — | Shown in menus, in tabs and in the header of a list. |
alias | string | — | 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. |
dialect | ApiDialectInterface | — | How this resource's API spells its requests, when it is not the one given to configureApi. See Backends. |
form, fullWidth, titleField, components, description | CommonViewOptions | — | 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. |
hooks | ResourceHooks | — | Code run around the calls to the data. See Shaping requests. |
realtime | boolean | — | Reloads the views when the API pushes a change (Mercure). |
optimistic | boolean | true | Draws 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/articlesandPATCH /api/articles/42on API Platform,GET /api/articles?pagination[page]=1on Strapi,GET /rest/v1/articles?limit=30on Supabase. A resource may carry adialectof 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`),
},
})| Name | Type | Default | Description |
|---|---|---|---|
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.