Skip to content
TALA
Esc
navigateopen⌘Jpreview
On this page

Adding a feature

Wiring a new endpoint into the SPA end to end — where the call goes, how to load it, how to handle every failure, and the six mistakes that cost the most time.

Most frontend work on TALA is the same shape: an endpoint exists, nothing calls it, and a page needs it. This is that path. What’s wired lists the 19 routes currently waiting.

The four steps

Add the call to api/tala.ts

Everything goes through the typed facade, never a bare fetch. The api() helper prefixes /api, attaches credentials, and unwraps the envelope; json() builds the request:

export const updateTask = (id: string, input: Partial<Task>) =>
  api<Task>(`/tasks/${id}`, json("PATCH", input));

export const deleteTask = (id: string) =>
  api<void>(`/tasks/${id}`, json("DELETE"));

Give it a return type. api<Task> is what makes the unwrapped shape visible to callers — see Data shapes.

Load it with useRemoteData

For reads, the generic hook handles loading and error state so pages do not each reinvent it:

const { data, setData, error, loading, reload } = useRemoteData(
  () => projectTasks(projectId),
  [projectId],
);

Two things to know about what comes back:

  • data is T | null, null until the first load resolves. Guard it; do not assume the array.
  • error is a string | null, not an Error — the hook has already reduced the thrown value to cause.message, falling back to "Request failed". That means ApiError.status and body are gone by the time you see it. If a page needs to branch on the status code, call the API function directly and catch it yourself.

The dependency array matters: it must include everything the callback closes over, or the hook serves a stale result after a prop changes. Note the hook disables the exhaustive-deps lint rule internally, so nothing warns you when it is wrong.

Handle both failure kinds

An HTTP failure throws ApiError; a transport failure throws a bare TypeError. Cover both or offline users get a silent dead end:

try {
  await updateTask(id, { title });
  await reload();
} catch (error) {
  setMessage(
    error instanceof ApiError
      ? error.body?.message ?? "Something went wrong"
      : "Could not reach the server. Check your connection.",
  );
}

Reload rather than hand-patching state

There is no cache layer, so the server is the only source of truth. After a write, call the hook’s reload(). Mutating local state to match what you think the server did is how the two drift — the existing collection-favourites bug is exactly that mistake, already shipped.

Which errors a page must handle

Every authenticated call can produce these, whatever the endpoint:

Status Means What the page should do
401 session is dead Nothing. fetchJSON already refreshed once and failed; tokens are cleared and the guard will redirect.
403 no active membership, or insufficient OrgRole Tell the user they lack permission. Do not retry.
404 not found or not yours Treat as gone. The two are deliberately indistinguishable.
409 conflict — seat limit, duplicate, ID collision Retryable for ID collisions; for seat limits and duplicates, surface body.message verbatim.
429 over 60 requests/60s from this IP Back off. Usually means a loop, not a busy user.
500 unexpected Generic message. The real cause is server-side only.

Two endpoint-specific exceptions worth knowing before you write a global handler:

  • Login returns 400, not 401, for bad credentials, unverified, and suspended alike.
  • An unrecognised scope is not rejected — it silently returns the wrong set. See Conventions.

Organization scoping is automatic — until it isn’t

fetchJSON attaches x-organization-id from localStorage on every request, so a new call is org-scoped without you doing anything.

What you do have to handle is the switch. setActiveOrganizationId() fires a tala:organization-change window event, and any view holding org-scoped data must refetch:

useEffect(() => {
  const onSwitch = () => reload();
  window.addEventListener("tala:organization-change", onSwitch);
  return () => window.removeEventListener("tala:organization-change", onSwitch);
}, [reload]);

Skip this and the page keeps showing the previous organization’s data until something else triggers a fetch.

The six that cost the most time

Returning a bare payload instead of the envelope

If you add a backend endpoint too, it must return { message, data }. api<T>() unwraps .data unconditionally, so an endpoint returning T directly hands every caller undefined with no error anywhere. POST /api/auth/refresh-token and GET /api/auth/user are the two endpoints that legitimately omit message.

Passing a user id where a membership id belongs

Member.id and Member.user.id are different values one level apart. Assignees, member updates, and member removal all take the membership id. A user id there returns 404 Member not found.

Writing tokens to localStorage directly

Always setTokens(). It skips falsy values, so a partial response cannot blank the other token. Writing the keys by hand is how a client ends up replaying a rotated refresh token — which the backend treats as theft and answers by revoking every session that user has.

Storing a presigned URL in state

Download URLs last five minutes. Fetch at the moment of use, not at render.

Assuming pagination metadata exists

Paginated responses are { message, data: [] } with no total and no pageCount, and page is zero-based. You cannot build a page-number control against the current envelope — infinite scroll or a plain “load more” is what fits. Adding a count to the envelope is a backend change.

Catching only ApiError

A network failure is a raw TypeError. An instanceof ApiError check with no fallback branch shows the user nothing at all.

Adding a page

Routes live in src/App.tsx. A private page wraps in both the guard and the chrome:

<Route
  path="/reports"
  element={
    <RequireAuth>
      <AuthenticatedLayout open={sidebarExpanded} onOpenChange={setSidebarExpanded}>
        <ReportsPage />
      </AuthenticatedLayout>
    </RequireAuth>
  }
/>

Omitting RequireAuth renders the page to anonymous visitors; omitting AuthenticatedLayout renders it without the sidebar and header. Add the sidebar entry in DashboardSidebar.tsx and the title mapping in AuthenticatedLayout.tsx — neither is derived from the route.

Before you open the PR

npm run build     # tsc -b && vite build — the only blocking CI check
npm run lint      # reports only; ~64 pre-existing errors, do not add to them
npm test          # vitest run — three test files plus a setup file

Was this page helpful?