This document defines the serialization, conflict, retry, and failure behavior that every race-safe data-fetching hook in the Credence frontend must obey.
The contract applies to all hooks that perform async data fetching and expose a
refetch() trigger:
| Hook | Source |
|---|---|
useActivity |
src/hooks/useActivity.ts |
useTrustScore |
src/hooks/useTrustScore.ts |
useTransactions |
src/hooks/useTransactions.ts |
useAsync |
src/hooks/useAsync.ts |
Every fetch carries a monotonic fetchId counter. When a new fetch starts, its id
is incremented. On resolution (success or failure), the hook checks whether the id
still matches:
fetchId = ++counter
result = await fetch(...)
if (fetchId !== currentCounter) discard // stale — do not apply
apply(result)
This guarantees that only the latest request's result is applied to state, regardless of the order in which responses arrive. No mutex or queue is needed.
A new refetch() call creates a fresh AbortController and aborts the previous
one:
prevController?.abort()
controller = new AbortController()
await fetch(..., { signal: controller.signal })
The previous request's promise rejects with AbortError. The hook filters out
AbortError — it is never surfaced as user-visible error state.
When a superseded response arrives (either success or failure), it is silently discarded:
- Stale success:
setData()is skipped becausefetchId !== currentCounter. - Stale error:
setError()is skipped for the same reason. - Stale AbortError: always discarded, regardless of
fetchId.
This prevents any overwritten state from a request the user no longer cares about.
When a request fails (network error, 5xx, 4xx, etc.) and the fetch id is still current:
- Data is cleared to the default empty value (
[]for lists,nullfor singletons,undefinedforuseAsync). - Error is set with the
ApiError(or a wrapped unexpected error). - Partial state is never leaked —
setDataandsetErrorare only called when the component is still mounted and the fetch id matches.
This ensures rejected, failed, or invalid operations leave no unauthorized or partial state.
After an error, calling refetch():
- Clears the error (
setError(null)) - Aborts any in-flight request
- Starts a fresh fetch with a new
fetchId
On success, data is populated and error remains null. On failure, data is cleared
again and the new error is surfaced.
// Usage pattern
const { data, error, refetch } = useTrustScore(address)
// After an error:
{
error && <button onClick={refetch}>Try again</button>
}
// → clears error → starts new request → applies result or clears againCalling refetch() multiple times in rapid succession:
- Each call aborts the previous request.
- Each call increments the fetch id.
- Only the last request's result is applied.
This is safe — no duplicate state, no partial leaks, no accumulated errors.
Every hook sets mountedRef = false on unmount. All state updates check
mountedRef.current before applying:
return () => {
mountedRef.current = false
controller?.abort()
}
This prevents:
- "Can't perform a React state update on an unmounted component" warnings.
- Stale data appearing after navigation.
- Memory leaks from lingering promises.
| Criterion | How it is satisfied |
|---|---|
| Serialization / conflict behavior | fetchIdRef monotonic counter; last-writer-wins |
| Retry contract explicit | Documented above; refetch() clears error + starts fresh |
| No partial/unauthorized state | Data cleared on failure; mountedRef guards all updates |
| Stale / repeated / failed operations | Aborted, discarded, or cleared — never applied |
| Regression coverage | useActivity.test.ts (17 tests), serialization-contract.test.ts (11 tests) |
The following test files exercise the contract at the hook integration boundary:
-
src/hooks/useActivity.test.ts— 17 tests covering:- Idle state, empty address, success, error transitions
- Abort on supersede, abort on unmount
- Stale response discard, three-concurrent-request ordering
- AbortError suppression, unexpected error wrapping
- Clean failure state, retry after error
- Correct API endpoint, whitespace address rejection
-
src/hooks/serialization-contract.test.ts— 11 tests covering:useTrustScore: abort on supersede, clean failure, retry clears error, AbortErroruseTransactions: latest-response-wins, clean failureuseActivity: three-concurrent ordering, failure clears previous successuseAsync: stale data discard, retry clears error
- No deduplication across components: Two separate hook instances (e.g. in
different components) each maintain their own
fetchIdandAbortController. They do not share requests. If you need request sharing (e.g. SWR / React Query caching), add a shared cache layer on top. - No automatic retry: The contract defines a manual retry via
refetch(). There is no exponential backoff or automatic retry loop. Add one at the call site if needed. - No optimistic updates: The hooks are read-only. Mutations should use a separate
pattern (e.g.
apiFetch+refetch()).
- No migration required. The new
useActivityhook is additive. Existing consumers ofSAMPLE_ACTIVITYcan optionally switch touseActivityat their own pace. - Rollback: Remove the
useActivityimport and revert toSAMPLE_ACTIVITYor local state.