03

Actions Are Hyperlinks (HATEOAS-Lite)

Each section arrives carrying the exact URLs it may call as `{ url, method }` links; the frontend follows them and never builds a route by hand.

The actions map: a named set of hrefs

Each section carries an actions object: a named map where each key is an intent (load, read_multiple, create_one, update_one, delete_one) and each value is a { url, method } href. This is the whole data-fetching contract for the section — nothing more, nothing less.

{
  "id": "images_section",
  "options": { "selectable": true },
  "actions": {
    "load":          { "url": "/folders/42/sections/images", "method": "GET" },
    "read_multiple": { "url": "/folders/42/images",          "method": "GET" },
    "create_one":    { "url": "/folders/42/images",          "method": "POST" },
    "update_one":    { "url": "/images/<int:id>",            "method": "PATCH" },
    "delete_one":    { "url": "/images/<int:id>",            "method": "DELETE" }
  }
}

The load href fetches the section itself; then the read/create/update/delete hrefs drive the data inside it. Because the names are stable intents, the frontend component asks for actions.read_multiple without ever knowing or caring what the URL behind it is — the backend can move the route and the client does not change.

IntentMethodWhat it does
loadGETFetch the section and its option bag
read_multipleGETRead the list of items the section shows
create_onePOSTAdd one item
update_onePATCHChange one item (URL carries a placeholder)
delete_oneDELETERemove one item (URL carries a placeholder)
 images_section
 ├── load           GET     /folders/42/sections/images
 ├── read_multiple  GET     /folders/42/images
 ├── create_one     POST    /folders/42/images
 ├── update_one     PATCH   /images/<int:id>
 └── delete_one     DELETE  /images/<int:id>

One shared httpClient: follow, never build

An httpClient is a single shared wrapper around the raw network call that every section reuses. Instead of each section writing its own fetch with its own auth header and its own error handling, they all pass their href through this one client. Centralizing it means the cross-cutting work happens in exactly one place: the httpClient attaches the auth token, unwraps the response envelope (the standard { data, meta } wrapper the backend returns), and routes errors — so the section components stay tiny.

// READ a list — spread the GET href straight into the request
useRequest({ args: { ...actions.image_read_multiple } });

// CREATE — follow the POST href, attach the body
await httpClient.request({ ...actions.image_create_one, data: payload });

Notice there is no URL anywhere in the section code. ...actions.image_read_multiple spreads the { url, method } the backend gave us directly into the call. The section says what intent it wants; the httpClient handles how the request is made.

Typed placeholders and parseActions

Some hrefs cannot be complete when the backend writes them, because they need a value only the running page knows — the id of the row you clicked. So the backend leaves a typed placeholder in the URL: <int:folder_id> means "an integer named folder_id goes here". A placeholder is a labeled hole in a template; the frontend fills it from the current route's parameters before sending the request.

The rule is one line and it is the same on every page: take the route's params, replace each matching placeholder in the URL, and whatever param has no matching placeholder becomes a query-string argument.

// UPDATE one — inject the row id into the href's placeholder first
const url = actions.image_update_one.url.replace(RoutePlaceholder.Id, id);
await httpClient.request({ ...actions.image_update_one, url, data });

Because this resolution rule is shared, a section author never thinks about it. They reference actions.image_update_one, and parseActions guarantees the placeholder is filled the same way it is on every other page.

Write, then revalidate with mutate()

A write changes data on the server, but the list the user is looking at was fetched earlier and is now stale. To revalidate means to re-fetch that data so the on-screen view matches the server again. The convention is mutate(): after a successful write, call it to re-read the read_multiple href and refresh the list.

await httpClient.request({ ...actions.image_create_one, data: payload });
await mutate(); // re-read read_multiple so the new item appears

This keeps the flow honest: the client does not optimistically guess what the server now holds and paint it locally; it writes, then asks the server again by following the same read href it already had. One write, one revalidate — the list is always what the backend actually returned.

The end-to-end round trip

Putting it together, a whole screen is one conversation over the JSON seam. The frontend follows the page's load href; the backend answers with sections, each carrying its own action hrefs; each section follows its own hrefs to fill itself; and a write is a POST followed by a mutate() re-read. The frontend owns only how each section looks — the backend owns what to render and where the data lives.

Every arrow in that diagram is the client following a link the backend handed it. There is no place where the frontend invented a route — which is exactly why adding a new endpoint on the backend, or moving an existing one, changes nothing on the client.

Key takeaways

  • A href is a { url, method } link the server hands the client to follow; the client follows links instead of constructing URLs (HATEOAS-lite).
  • Every section carries an actions map of named intents — load, read_multiple, create_one, update_one, delete_one — each an href.
  • Section code references intents (actions.read_multiple), never raw URLs, so the backend can move a route without touching the client.
  • One shared httpClient attaches auth, unwraps the envelope, and routes errors centrally, so section components stay tiny.
  • Typed placeholders like <int:folder_id> are filled from route params by one shared rule (url.replace); leftover params become a query string.
  • After a write, mutate() re-reads the list so the view matches the server — write, then revalidate.
  • A whole screen is one round trip over the JSON seam: follow page.load, get sections + hrefs, follow them, and POST-then-mutate to write.

Checklist

  • [ ] I can explain what a href is and what HATEOAS-lite means in this context.
  • [ ] I can read a section's actions map and name what each intent does.
  • [ ] I understand why section code references intents instead of raw URLs.
  • [ ] I can describe the three jobs of the shared httpClient (auth, envelope, errors).
  • [ ] I can resolve a typed placeholder like <int:folder_id> from route params and explain where leftover params go.
  • [ ] I know why a write is followed by mutate() and what revalidation means.
  • [ ] I can trace the end-to-end round trip from page.load to a POST-then-mutate write.