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",
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
| Name | Type | Default | Description |
|---|---|---|---|
views.list | ViewListInterface | — | The collection. Owns viewVariants, formFilter, defaultFilter and itemsPerPage. |
views.read | ViewInterface | — | One item. Owns the sub-views and the export button. |
views.create | ViewUpdateInterface | — | The creation form. |
views.update | ViewUpdateInterface | — | The edit form. |
views.delete | ViewUpdateInterface | — | The 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#
| 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. Left out, the resource is backed by localStorage. |
icon | FC<{ className?: string }> | — | Shown in menus and tabs. |
scope | string | — | Which area of the application it belongs to. See Scopes. |
alias | string | — | A second identifier the router also accepts. |
view | ViewListInterface | — | The description every action starts from. |
views | { list, read, create, update, delete } | — | Per-action overrides. |
canList / canRead / canCreate / canUpdate / canDelete | boolean | (() => boolean) | — | Permissions, evaluated on render. See Permissions. |
limit | LimitInterface | — | A creation quota, with a fallback rendered once reached. |
decoratorComponent | FC<{ children }> | — | Wraps every view of this resource. |
onChange | PubSub<{ data, action }> | — | Publishes after every successful write. |
getCollection / getItem / createItem / updateItem / removeItem | functions | — | The 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/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. - 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() }),
})| Name | Type | Default | Description |
|---|---|---|---|
preGetCollection | (params, context) => params | — | Before a collection is fetched. Sorting, extra query parameters, a forced filter. |
preGetItem | (params, context) => params | — | Before a single item is fetched. |
preCreate | (data, context) => data | — | Before a create. Injecting an owner or a tenant. |
preUpdate | (data, context) => data | — | Before 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.