-
Notifications
You must be signed in to change notification settings - Fork 7
How data flows
Summary: This page explains the complete data flow cycle in StartER, from fetching data to mutating it and refreshing the UI.
StartER uses a lightweight, framework-native approach to data management. There are no external state management libraries (Redux, Zustand, etc.). Instead, data flows through a cycle of fetch → cache → render → mutate → invalidate → re-fetch.
The three key files are:
-
src/react/helpers/cache.tsfor reading data -
src/react/helpers/mutate.tsfor writing data -
src/react/components/DataRefreshContext.tsxfor bridging mutations with re-renders
┌──────────────────────────────────────────────────────────────────┐
│ React Component │
│ │
│ const items = use(cache<Item[]>("/api/items")); │
│ │ │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ cache("/api/...") │──── Cache HIT ──→ return │
│ └─────────────────────┘ │
│ │ Cache MISS │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ fetch("/api/...") │──→ Express API ──→ SQLite │
│ └─────────────────────┘ │
│ │ │
│ ▼ │
│ Store in cache │
│ Render component │
└──────────────────────────────────────────────────────────────────┘
User triggers mutation
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ useMutate() │
│ │
│ 1. apiMutate() ──→ fetch(url, { method, body }) │
│ └── Includes CSRF token (cookie + header) │
│ │
│ 2. invalidateCache(["/api/items"]) ──→ removes stale entries │
│ │
│ 3. refresh() ──→ increments DataRefreshContext tick │
│ └── All components using invalidated paths re-suspend │
│ └── React calls use(cache(...)) again │
│ └── Cache MISS → fresh fetch → re-render │
└──────────────────────────────────────────────────────────────────┘
The cache helper (src/react/helpers/cache.ts) stores API responses as Promises in an in-memory Map. When a component calls cache("/api/items"):
- Cache hit: returns the stored Promise immediately (no network call).
-
Cache miss: creates a
fetch()Promise, stores it in the Map, and returns it.
export const cache = <T extends Json>(url: string): Promise<T> => {
if (!cacheData.has(url)) {
cacheData.set(
url,
fetch(url).then<T>((response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${response.statusText}`);
}
return response.json();
}),
);
}
return cacheData.get(url) as Promise<T>;
};Note
The cache stores Promises, not resolved values. This is required by React's use hook, which relies on Promise identity to suspend correctly.
React 19's use() hook suspends the component until the Promise resolves. Combined with cache(), this means:
- First render: component suspends → fetch fires → data arrives → component renders.
- Subsequent renders: cache hit → no suspension → instant render.
import { use } from "react";
import { cache } from "../../helpers/cache";
function ItemList() {
const items = use(cache<Item[]>("/api/items"));
return (
<ul>
{items.map(item => <li key={item.id}>{item.title}</li>)}
</ul>
);
}The useMutate hook (src/react/helpers/mutate.ts) orchestrates three steps in sequence:
-
API call via
apiMutate(): sends the mutation request (POST, PUT, or DELETE) with automatic CSRF token attachment. -
Cache invalidation via
invalidateCache(): removes specified paths from the in-memory Map so the next read triggers a fresh fetch. -
Refresh signal via
refresh(): increments a global counter (DataRefreshContext) that forces all affected components to re-suspend and re-fetch.
export function useMutate() {
const { refresh } = useRefresh();
return async (
url: string,
method: "post" | "put" | "delete",
body?: unknown,
invalidatePaths: string[] = [],
) => {
const response = await apiMutate(url, method, body);
for (const path of invalidatePaths) {
invalidateCache(path);
}
refresh();
return response;
};
}const mutate = useMutate();
const navigate = useNavigate();
const addItem = async (partialItem: Omit<Item, "id" | "user_id">) => {
await mutate("/api/items", "post", partialItem, ["/api/items"]);
navigate("/items");
};After mutate() completes:
- The POST request was sent and succeeded.
- The cached Promise for
"/api/items"was deleted. -
DataRefreshContext.tickwas incremented. - Any component calling
use(cache<Item[]>("/api/items"))will re-suspend, triggering a fresh GET request and re-rendering with the updated list.
For this system to work, the application must be wrapped in a <DataRefreshProvider>. This is already configured in src/react/routes.tsx.
It provides a "global signaling" mechanism: as soon as a mutation succeeds, all components using cached (and invalidated) data will automatically update to display the most recent data.
The invalidateCache function supports two modes:
-
Path prefix:
invalidateCache("/api/items")removes all entries whose key starts with"/api/items"(including"/api/items/1","/api/items/2", etc.). -
Wildcard:
invalidateCache("*")clears the entire cache.
- Always specify invalidation paths: forgetting to invalidate means the user sees stale data after a mutation.
-
Use
invalidateCache("*")sparingly: it clears the entire cache, forcing all components to re-fetch everything. -
Keep components atomic: each component should own its
use(cache(...))call rather than passing data through deep prop chains. -
Explicit invalidation over implicit: it's better to list every affected path (e.g.,
["/api/items", "/api/items/1"]) than to rely on wildcard invalidation.
AI co-creation
Getting started
Explanations
How-To Guides
Reference
Digging deeper