---
title: Adding a feature
description: 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](/status/integration) lists the 19 routes currently waiting.

## The four steps

1. **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:

    ```ts title="frontend/src/api/tala.ts"
    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](/frontend/types).

2. **Load it with useRemoteData**

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

    ```tsx
    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.

3. **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:

    ```ts
    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.",
      );
    }
    ```

4. **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](/api/conventions#scope).

## 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:

```tsx
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

<Accordion>
  <AccordionItem title="Returning a bare payload instead of the envelope" icon="alert-triangle">

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`.

  </AccordionItem>
  <AccordionItem title="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`.

  </AccordionItem>
  <AccordionItem title="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.

  </AccordionItem>
  <AccordionItem title="Storing a presigned URL in state">

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

  </AccordionItem>
  <AccordionItem title="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.

  </AccordionItem>
  <AccordionItem title="Catching only ApiError">

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

  </AccordionItem>
</Accordion>

## Adding a page

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

```tsx title="frontend/src/App.tsx"
<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

```bash
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
```

:::warning[Nothing runs the tests but you]
Frontend CI has no test step. `npm run build` is the only blocking check, and lint is `continue-on-error`. `noSyntheticData.test.ts` guards against mock data returning to `src/pages/` — but only if someone runs it locally.

That guard exists because the SPA was once roughly 4,800 lines of convincing fake. If your page has nothing real to show yet, render an empty state, not invented rows.
:::
