Skip to content
TALA
Esc
navigateopen⌘Jpreview
On this page

API client

fetchJSON and its single-flight 401 refresh, the typed facade in api/tala.ts, and the per-feature hooks that predate it — including their known gaps.

fetchJSON

Every request goes through one function in src/utils/api.ts. It attaches credentials, retries once on 401, and normalises errors.

export async function fetchJSON<T>(
  url: string,
  options: RequestInit = {},
  retryOnAuthFailure = true,
): Promise<T>;

What it does, in order:

  1. Sets Content-Type: application/json when there is a string body and no explicit header.
  2. Attaches Authorization: Bearer <token> from localStorage.
  3. Attaches x-organization-id from localStorage when an active org is set.
  4. On a 401 with a refresh token present, refreshes once and replays the request.
  5. On a failed refresh, clears both tokens so the app can re-authenticate.
  6. On a 2xx, returns {} for a 204 and the parsed JSON otherwise.
  7. On any other status, throws ApiError carrying status and the parsed body.

The retry reuses the built headers

// Reuse the headers we already built (which include Content-Type for
// JSON bodies) and just swap in the fresh token — rebuilding from
// options.headers would drop Content-Type and 400 every retried write.
headers.set("Authorization", `Bearer ${newToken}`);
return fetchJSON<T>(url, { ...options, headers }, false);

The false argument prevents an infinite retry loop. The header reuse is the subtle part — rebuilding from options.headers would lose the Content-Type that step 1 added, and every retried POST or PATCH would 400.

Refresh is single-flight

Many requests can 401 simultaneously. Only one may call the refresh endpoint, because replaying a rotated token revokes every session. A module-scoped promise dedupes them, and is cleared in a finally so a failed refresh does not poison later attempts.

Token storage

Key Holds
tala_token access token
tala_refresh_token refresh token
tala_organization_id active organization
getAccessToken();
getRefreshToken();
setTokens(accessToken?, refreshToken?);   // only writes truthy values
clearTokens();                            // clears both
getActiveOrganizationId();
setActiveOrganizationId(id | null);       // also fires tala:organization-change

The typed facade

src/api/tala.ts wraps the collaboration surface and unwraps the envelope, so callers deal in domain objects:

type Envelope<T> = { data: T; message?: string };

const api = async <T>(path: string, options?: RequestInit) =>
  (await fetchJSON<Envelope<T>>(`${API_BASE_URL}/api${path}`, options)).data;

const json = (method: string, body?: unknown): RequestInit => ({
  method,
  body: body === undefined ? undefined : JSON.stringify(body),
});

It exports interfaces for Organization, Member, Invitation, Project, Task, TaskComment, Collection, AssetDetail, AssetVersion, Plan, and DashboardSummary, plus the functions that return them.

The hooks

Hook Endpoints Notes
useRemoteData any generic fetch-state hook; used by Home, Library, Timeline, Teams, Workspace, Invitation
useCollections list, create, rename, delete favourites are localStorage-only
useCollectionDetails assets by collection, asset update, asset delete sends collectionId on update and delete
useAssetUpload initiate, PUT, complete, abort 5 MB chunks, reads ETag per part
useAssetLogs GET /api/assets/:id/asset-logs
useLogin, useSignUp, useForgotPassword, useResetPassword auth forms call fetchJSON directly

Collection favourites disagree with the server

const toggleFavorite = async (id: string) => {
  const favs = getFavs();
  const newFavs = favs.includes(id) ? favs.filter((f) => f !== id) : [...favs, id];
  saveFavs(newFavs);
  // …no network call
};

PATCH /api/collections/:id/favorite exists and tala.ts exports a working favoriteCollection(). Neither is called. Asset favourites do hit the server, so the two behave differently: favourite an asset and it follows you to another device; favourite a collection and it does not.

Was this page helpful?