react-resource-view

Scopes & menu

Grouping resources per area of the application, and building its menu.

What a scope is#

An area of the application: its set of resources, its menu, its landing page, and whether the reader is allowed in it at all. The scope name is the first segment of every URL the views build, which is what makes it a genuine boundary rather than a folder.

  • A namespace. Two resources may answer to the same name in the URL if they live in different scopes.
  • An authorisation boundary. One function, synchronous or async, decides access to everything inside — Authentication wires it to a real session.
  • A code-splitting boundary. Scopes are loaded lazily, so an administration area is never downloaded by a reader who never opens it.
  • A layout boundary. decoratorComponent wraps every view of the scope.

You may not need one

Scopes earn their keep when an application has clearly separate areas — a back office and a customer portal. Below that, pass resources directly and skip the whole mechanism.

// A small application needs no scopes at all.
<ResourceViewProvider
  viewResourceContextParams={{ resourceId: "articles", resourceAction: ActionList.list }}
  configuration={{ resources: [articles, authors] }}
/>

Declaring one#

scopes/admin.ts
import type { ScopeInterface } from "react-resource-view"
import { UnauthorizedError } from "react-resource-view"
import { authClient } from "../auth-client"

export const adminScope: ScopeInterface = {
  name: "admin",
  label: "Administration",
  // The one place a resource's scope is written: their links lead here.
  resources: [articles, authors, categories],
  home: "/admin/articles/list",
  // Resources as they are, or links of your own — "auto" lists `resources`.
  menu: [
    articles,
    authors,
    { name: "Reports", href: "/admin/reports", icon: BarChart },
  ],
  // Synchronous or async — the scope renders once it has answered.
  authorization: async () => {
    const { data } = await authClient.getSession()
    if (!data) throw new UnauthorizedError()
    return true
  },
  decoratorComponent: AdminShell,
}
NameTypeDefaultDescription
namerequiredstring—The first URL segment of every view of the scope.
labelstring—Human-readable name, for a scope switcher.
resourcesViewResource[]—Everything the scope can render — the one place a resource's scope is declared: each resource listed here links within it.
menu"auto" | (ViewResource | MenuItemInterface)[]—The navigation, yours to render. "auto" lists resources in order; a list mixes resources with links and groups of your own.
homestring—Where an entry with no context lands.
authorization() => boolean | Promise<boolean>—Throws or rejects with UnauthorizedError (401) or ForbiddenError (403) to refuse; false counts as a 403. Nothing of the scope renders until it has answered — see Authentication.
authorizationFallbackReactNode—Shown while authorization is pending.
forbiddenFallbackReactNode—Shown on a 403 instead of the scope.
decoratorComponentFC<{ children }>—Wraps every view of the scope — the shell of the area. AdminLayout is a ready-made one.
middleWare() => void—Runs when the scope is entered — analytics, a fetch, a redirect.
defaultViewResourceContextParamsViewResourceContextParams—What the scope opens on when the URL says nothing more.

Wiring them up#

import { ResourceViewProvider } from "react-resource-view"

<ResourceViewProvider
  viewResourceContextParams={parseLink(url)}
  configuration={{
    // Lazily loaded, one per area — each import() is its own chunk.
    scopes: {
      admin: () => import("./scopes/admin").then((m) => m.adminScope),
      portal: () => import("./scopes/portal").then((m) => m.portalScope),
    },
    defaultScope: "portal",
    onUnauthorized: () => router.navigate({ to: "/sign-in" }),
    scopeFallback: <Loader />,
  }}
/>
NameTypeDefaultDescription
scopesRecord<string, () => Promise<ScopeInterface>>—Lazily loaded. The function is the split point, so each area is its own chunk.
resourcesViewResource[]—The flat alternative, when there are no scopes.
defaultScopestring—Used when the URL names none.
decoratorComponentFC<{ children }>—Wraps every view, across every scope.
onUnauthorized() => void—Called when an authorisation throws or rejects with a 401. Where you redirect to sign-in.
authorizationFallbackReactNode—Shown while a scope's authorization is pending, unless the scope sets its own.
forbiddenFallbackReactNode—Shown on a 403, unless the scope sets its own. Defaults to an “Access denied” message.
scopeFallbackReactNode—Shown while a scope's chunk is loading.

What every resource starts from — a shared empty state, edit forms in a drawer — is not part of this configuration but of configureResourceView({ defaults }), called once at startup with the shape of a declaration and merged beneath every resource.

Lazy scopes suspend

A lazily loaded scope suspends on first paint. That is the right trade-off in an application, and the wrong one for an example sitting in a page of prose — which is why the demos below pass resources directly, while the playground, being a real application, declares its back office as a lazily loaded scope.

The menu is data — rendering it is yours, or AdminLayout's, if a collapsible sidebar built from it is enough. A resource in a scope's menu becomes an entry pointing at its list, in the configured routing mode; outside a scope, createItemMenuWithResource({ resource, scope }) builds the same entry.

import { useIsActiveItemMenu } from "react-resource-view"

function Sidebar({ menu }: { menu: MenuItemInterface[] }) {
  const isActive = useIsActiveItemMenu()

  return menu.map((item) => (
    <Link key={item.name} to={item.href} aria-current={isActive(item) ? "page" : undefined}>
      {item.icon && <item.icon />}
      {item.name}
    </Link>
  ))
}

useIsActiveItemMenu, not isActiveItemMenu

isActiveItemMenu reads the address bar directly, so on a server it reports every entry as inactive and the browser then disagrees with the markup. useIsActiveItemMenu asks the router instead and answers the same on both sides. It returns a predicate rather than a boolean, because a hook cannot be called in a loop.

NameTypeDefaultDescription
namerequiredstring—The label.
hrefstring—Where it goes. Built, not written.
iconIconType—Shown beside the label.
itemsMenuItemInterface[]—Children, for a nested menu.
prioritynumber—Ordering, when entries come from several places.
hiddenboolean—Keeps the entry out without removing it.
subNavigationboolean—Renders a sub-navigation bar for the entry's children.
locked() => boolean—Shows a padlock and redirects — for a feature behind a plan.
componentFC<{ menuItem }>—Renders this entry your own way.

One endpoint, two scopes#

The same API collection often needs two different treatments. Declare it twice, under two ids sharing one alias, and list each in its scope, with different permissions, filters and layouts:

// Two resources over one endpoint, one per scope, both answering to "articles".
export const adminArticles = createViewResource("admin_articles", {
  alias: "articles",
  path: "/api/articles",
})
export const portalArticles = createViewResource("portal_articles", {
  alias: "articles",
  path: "/api/articles",
  permissions: "readonly",
})

// scopes/admin.ts → resources: [adminArticles]
// scopes/portal.ts → resources: [portalArticles]

Link building prefers the resource matching the current scope, so generateLinkFromUri keeps a reader inside the area they are already in. And permissions are per declaration, which is what makes the portal copy genuinely read-only in the interface.