12 KiB
Web data interaction refactor plan
Executive summary
The current web app has solid typed API helpers, but it does not yet have an application data layer. Most pages call backend endpoints directly inside local useEffect blocks, so route changes repeatedly reload the same user/profile/points/history data. This is especially visible when the backend is far away because every avoidable request becomes a user-visible wait.
The recommended refactor is to keep the UI unchanged and introduce a lightweight, project-owned data layer that centralizes:
- request in-flight dedupe
- TTL cache and stale-while-revalidate behavior
- explicit invalidation after writes
- route-level prefetch
- shared resource state for pages
- optimistic local updates where safe
Do not start by sprinkling localStorage or ad hoc memoization into components. That would reduce some requests but preserve the deeper problem: every component still owns its own data lifecycle.
Current architecture diagnosis
1. API helpers are typed, but not stateful
web/src/lib/api.ts provides typed functions such as getUserProfile, getPointsBalance, getAgentHistory, and getNotifications. Except for points, those functions always hit the backend.
This is clean as a transport layer, but incomplete as a frontend data layer.
2. Components own network lifecycle
Examples:
AppShellfetches profile globally, butSettingsPage,GeneralSettingsPage, andProfileDetailPagerefetch profile.Dashboardfetches points/history/unread count on mount.HistoryListPagefetches history again even if dashboard just fetched it.StorePagefetches packages on mount even though packages are stable config-style data.NotificationPageowns its own list and mark-read updates without updating dashboard unread-count cache.
Each page is locally correct, but globally wasteful.
3. Loading states are page-local
Because data state is local to each page, the UI often transitions from a populated page to a blank/loading page even if relevant data was just fetched elsewhere. This makes high-latency backend access feel worse than it needs to.
4. Mutations do not have a shared invalidation model
Examples:
updateUserSettingsreturns updated profile, but only the calling component updates local state.updateUserProfile/uploadAvatarcan leaveAppShellprofile stale unless context is manually updated.markNotificationReadupdates the notification page list, but dashboard unread count is not centrally invalidated.enqueueDivinationRunshould invalidate points and history after success, but that is not represented in a central policy.
Target architecture
Layer 1: transport stays simple
Keep authFetch, authFetchRaw, apiUrl, api-routes, and RFC7807 parsing as the low-level transport boundary.
Responsibilities:
- auth refresh and 401 handling
- JSON request/response parsing
- SSE raw response support
- no UI cache knowledge
Layer 2: API functions remain typed commands
Keep web/src/lib/api.ts as the typed backend API command layer.
Responsibilities:
- endpoint-specific payload construction
- response types
- backend data mapping helpers
- no React hooks
Change:
- Remove ad hoc points-only cache from this file.
- Move caching into a dedicated data/cache layer.
Layer 3: new app data client
Add web/src/lib/data-client.ts or web/src/lib/query-client.ts.
Core capabilities:
type CacheKey = readonly string[];
interface CacheEntry<T> {
data?: T;
error?: unknown;
updatedAt: number;
expiresAt: number;
promise?: Promise<T>;
}
interface QueryOptions<T> {
key: CacheKey;
ttlMs: number;
fetcher: () => Promise<T>;
staleWhileRevalidate?: boolean;
}
Required operations:
query<T>(options): Promise<T>: returns fresh data, dedupes in-flight request.peek<T>(key): T | undefined: synchronous read for instant UI seeding.set<T>(key, data, ttlMs): update cache after mutations.invalidate(keyPrefix): remove or mark stale.prefetch(options): warm cache without forcing UI loading.clearAll(): call on logout or terminal auth failure.
Why local helper first:
- Project currently has no query library.
- The data model is small enough to solve with a typed helper.
- Avoid adding dependency and architecture churn before measuring gains.
Future option:
- If data flows grow, migrate the same key model to TanStack Query later. The refactor should name cache keys and invalidation policies in a way that would map cleanly.
Layer 4: resource-specific services
Add resource wrappers, for example:
web/src/lib/resources/profile.tsweb/src/lib/resources/points.tsweb/src/lib/resources/store.tsweb/src/lib/resources/history.tsweb/src/lib/resources/notifications.ts
Each resource owns:
- cache key
- TTL
- fetch function
- invalidation after writes
- local patch helpers
Example:
export const profileKey = ['profile'] as const;
export function getProfileResource() {
return query({
key: profileKey,
ttlMs: 5 * 60_000,
fetcher: getUserProfile,
staleWhileRevalidate: true,
});
}
export async function updateProfileResource(input: UpdateProfileRequest) {
const updated = await updateUserProfile(input);
set(profileKey, updated, PROFILE_TTL);
return updated;
}
Layer 5: React provider and hooks
Add AppDataProvider under the authenticated app shell.
Hooks:
useResource(key, loader, options)useProfile()usePoints()useHistorySummary()useHistoryThread(threadId)useNotifications(locale)useUnreadCount()
Hook behavior:
- seed UI from
peek() - return cached data immediately when available
- refresh in background when stale
- expose
{ data, loading, refreshing, error, reload } - never bypass auth 401 behavior
This keeps UI unchanged: pages still render the same cards/lists/forms, but data source shifts from local useEffect to shared hooks.
Cache policy by data domain
| Domain | Key | TTL | Stale UI allowed | Invalidate on |
|---|---|---|---|---|
| Auth session | localStorage meeyao_auth |
token expiry | no | logout, 401 refresh failure |
| Profile/settings | ['profile'] |
5 min | yes | profile update, settings update, avatar upload, logout |
| Points balance | ['points', 'balance'] |
30-60 sec | yes | divination run accepted/success, purchase, manual reload, logout |
| Packages | ['points', 'packages'] |
30 min | yes | app reload, explicit admin/package changes if added later |
| Dashboard history summary | ['history', 'summary'] |
60 sec | yes | divination run success, follow-up success, session delete |
| History full list | ['history', 'list'] |
60 sec | yes | divination run success, follow-up success, session delete |
| History thread | ['history', 'thread', threadId] |
2-5 min | yes | follow-up success for same thread, session delete |
| Notifications list | ['notifications', locale] |
60 sec | yes | mark read/all read, new notification polling if added |
| Unread count | ['notifications', 'unread-count'] |
30 sec | yes | mark read/all read, notification list refresh |
Display-state model
Every page should distinguish:
loading: no cached data yet and initial request is pendingrefreshing: cached data is visible while background request updates iterror: no usable data, or background refresh failedstale: data is visible but may be older than TTL
UI stays visually the same, but behavior improves:
- If cached data exists, do not show full-page loading.
- Use existing skeleton/spinner only for first load.
- On refresh failure, keep visible stale data and show a small non-blocking error only where useful.
Route prefetch strategy
In AppShell, prefetch cheap likely-next data:
- After auth/profile load:
- points balance
- unread notification count
- dashboard history summary
- On sidebar hover/focus/click intent:
- Store: packages + points
- History: history list
- Settings/Profile/General: profile
- Divination manual/auto: points
- Notifications: notification list + unread count
Prefetch must be bounded:
- only authenticated routes
- no SSE prefetch
- no mutation prefetch
- no repeated prefetch while in-flight or fresh
Mutation and invalidation policy
Profile/settings
updateUserProfileanduploadAvatar- set
['profile']to returned profile - update
AppShelluser context immediately
- set
updateUserSettings- set
['profile']to returned profile - redirect language only after cache/context update
- set
Points/store
- Purchase flow or future payment success:
- invalidate
['points', 'balance']
- invalidate
- Divination run:
- optimistically mark points stale when run is accepted
- invalidate points and history after run finishes
History
- Divination run success:
- set result in sessionStorage/router state as today
- invalidate history summary/list
- optionally seed
['history', 'thread', threadId]if enough data exists
- Follow-up success:
- invalidate
['history', 'thread', threadId] - invalidate summary/list
- invalidate
Notifications
- Mark one read:
- patch
['notifications', locale] - decrement
['notifications', 'unread-count']locally
- patch
- Mark all read:
- patch list to all read
- set unread count to 0
Implementation phases
Phase 1: measurement and guardrails
Deliverables:
- Add a dev-only request logger around
authFetch/apiRequest. - Capture before counts for:
- login -> dashboard
- dashboard -> settings -> profile -> general
- dashboard -> manual/auto divination
- dashboard -> history
- dashboard -> store
- Keep UI unchanged.
Acceptance:
- We can prove before/after request count improvements.
Phase 2: data client foundation
Deliverables:
- Add typed cache/data client.
- Move existing points TTL cache into the data client.
- Add clear-on-logout/auth-failure hook.
- Add unit-light self checks where possible, or targeted browser checks if test infra is absent.
Acceptance:
- In-flight duplicate calls collapse into one request.
- Cache invalidation is explicit and inspectable.
Phase 3: profile/settings context refactor
Deliverables:
- AppShell uses profile resource.
- SettingsPage/ProfileDetailPage/GeneralSettingsPage use profile resource/context instead of unconditional fetch.
- Mutations update profile cache and shell state.
Acceptance:
- Dashboard -> settings -> profile -> general should not issue repeated
GET /users/me/profilewhile profile is fresh.
Phase 4: points, packages, dashboard
Deliverables:
- Points uses data client TTL + in-flight dedupe.
- Packages cached with long TTL.
- Dashboard data uses shared resources.
- Store/divination/settings reuse points cache.
Acceptance:
- Dashboard -> store -> divination does not repeatedly fetch points/packages while fresh.
Phase 5: history and notifications
Deliverables:
- History list/summary/thread resources.
- Dashboard and HistoryListPage share cached history.
- Notification list/unread resources with local patch after mark-read.
Acceptance:
- Dashboard -> history reuses fresh history data.
- Mark-read updates unread count without waiting for dashboard reload.
Phase 6: perceived-performance polish
Deliverables:
- Replace full-page loading on cached routes with stale data + subtle refreshing state.
- Add bounded route prefetch in AppShell.
- Add regression browser checks for mobile/desktop.
Acceptance:
- UI remains visually unchanged at rest.
- Route transitions feel instant when data is cached.
Risks and mitigations
| Risk | Mitigation |
|---|---|
| Stale user-visible data | Short TTL for volatile data; explicit invalidation after writes. |
| Auth cache leaking between users | Clear all data cache on logout, missing auth, refresh failure, and user id change. |
| Hidden background refresh errors | Keep stale data visible but expose reload/error state for first-load failures. |
| Over-prefetch increasing traffic | Prefetch only small GET endpoints and rely on in-flight/fresh checks. |
| Refactor touching too many pages at once | Ship by phases with request-count verification after each phase. |
| UI drift | Do not alter markup/layout except replacing data source/loading conditions. |
Recommended final architecture
React Page
-> useProfile/usePoints/useHistory/useNotifications
-> Resource wrapper (keys, TTL, invalidation)
-> DataClient (cache, in-flight dedupe, stale-while-revalidate)
-> typed API functions in api.ts
-> authFetch/apiRequest transport
-> backend
The core principle: components describe what data they need; resource wrappers decide when the backend actually needs to be called.