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:
dataisT | null, null until the first load resolves. Guard it; do not assume the array.erroris astring | null, not anError— the hook has already reduced the thrown value tocause.message, falling back to"Request failed". That meansApiError.statusandbodyare 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
scopeis 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
