Repository navigation
How data flows
Summary: This page explains the complete data flow cycle in StartER, from fetching data to mutating it and refreshing the UI.
What you'll learn:
- Understand the cache, render, mutate, refresh cycle
- Use
mutate()with explicit cache path refresh - Understand targeted cache invalidation and reactivity with
useRefresh()
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 → refresh → re-fetch.
The two key files are:
-
src/react/helpers/cache.tsfor caching, invalidation, and reactivity -
src/react/helpers/mutate.tsfor writing data
┌──────────────────────────────────────────────────────────────────┐
│ React Component │
│ │
│ const items = use(getOrFetch<Item[]>("/api/items")); │
│ │ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ getOrFetch("/api/...") │──── Cache HIT ──→ return │
│ └────────────────────────┘ │
│ │ Cache MISS │
│ ▼ │
│ ┌─────────────────────┐ │
│ │ fetch("/api/...") │──→ Express API ──→ SQLite │
│ └─────────────────────┘ │
│ │ │
│ ▼ │
│ Store in cache │
│ Render component │
└──────────────────────────────────────────────────────────────────┘
User triggers mutation
│
▼
┌──────────────────────────────────────────────────────────────────┐
│ mutate() │
│ │
│ 1. fetch(url, { method, body }) │
│ └── Includes CSRF token (cookie + header) │
│ │
│ 2. cache.refresh(paths) │
│ ├── evicts stale cache entries matching paths │
│ └── notifies matching subscribers (useRefresh) │
│ └── Subscribed components re-suspend │
│ └── React calls use(getOrFetch(...)) again │
│ └── Cache MISS → fresh fetch → re-render │
└──────────────────────────────────────────────────────────────────┘
The getOrFetch helper in src/react/helpers/cache.ts stores API responses as Promises in an in-memory Map. When a component calls getOrFetch("/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 getOrFetch = <T>(url: string): Promise<T> => {
const cachedPromise = promisesByUrl.get(url);
if (cachedPromise) {
return cachedPromise as Promise<T>;
}
const promise: Promise<T> = fetch(url).then((response) => {
if (!response.ok) {
throw new Error(`${response.status}: ${response.statusText}`);
}
return response.json();
});
promisesByUrl.set(url, promise);
return promise;
};Note
The cache stores Promises, not resolved values. This is required by React's use hook, which relies on Promise identity to suspend correctly.
Cached entries expire after a configurable TTL (DEFAULT_TTL = 5 * 60 * 1000, 5 minutes) and rejected promises are automatically evicted so subsequent retries fetch fresh data.
React 19's use() hook suspends the component until the Promise resolves. Combined with getOrFetch(), 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 { getOrFetch, useRefresh } from "../../helpers/cache";
function ItemList() {
useRefresh("/api/items");
const items = use(getOrFetch<Item[]>("/api/items"));
return (
<ul>
{items.map(item => <li key={item.id}>{item.title}</li>)}
</ul>
);
}Tip
The simple signature getOrFetch<Item[]>("/api/items") is shown here for clarity. For paginated resources, getOrFetch accepts an optional options parameter with request headers and custom parsing. See Pagination.
The mutate function (src/react/helpers/mutate.ts) orchestrates two steps in sequence:
- API call: sends the mutation request (POST, PUT, or DELETE) with automatic CSRF token attachment.
-
Targeted cache eviction & refresh via
cache.refresh(paths): ifpathsare provided, evicts specified paths from the in-memory cache and notifies matching subscribers (useRefresh(paths)) to re-suspend and re-fetch.
export const mutate = async (
url: string,
method: "post" | "put" | "delete",
body?: unknown,
paths?: string | string[],
) => {
const headers: Record<string, string> = {
"X-CSRF-Token": await csrfToken(),
};
const init: RequestInit = { method, headers };
if (body != null) {
if (body instanceof FormData) {
init.body = body;
} else {
headers["Content-Type"] = "application/json";
init.body = JSON.stringify(body);
}
}
const response = await fetch(url, init);
if (!response.ok) {
throw new Error(`${response.status}: ${response.statusText}`);
}
if (paths != null) {
cache.refresh(paths);
}
return response;
};import { mutate } from "../helpers/mutate";
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 from the cache byrefresh(). - Matching subscribers were notified.
- Any component calling
useRefresh("/api/items")re-suspends, triggering a fresh GET request viause(getOrFetch(...))and re-rendering with the updated list.
Cache invalidation and reactivity are built directly into src/react/helpers/cache.ts with zero context provider overhead:
- Components subscribe via
useRefresh(paths)to receive refresh notifications for a specific path, multiple paths, or all paths (defaulting to"*"). -
cache.refresh(paths)evicts matching entries and triggers re-renders only in components subscribed to those paths. - Unrelated pages, menus, and layouts remain completely untouched.
The refresh function handles both cache eviction and targeted notification:
-
Path prefix:
refresh("/api/items")removes all entries whose key starts with"/api/items"(including"/api/items/1","/api/items/2", etc.) and notifies matching subscribers. -
Multiple paths:
refresh(["/api/items", "/api/users"])removes matching entries and notifies subscribers across multiple paths. -
Wildcard / Default:
refresh("*")or callingrefresh()with no arguments clears the entire cache and notifies wildcard subscribers.
-
Always specify paths to refresh: omitting paths in
mutate()means the user sees stale data after a mutation. -
Use
refresh("*")sparingly: it clears the entire cache, forcing all subscribed components to re-fetch everything. -
Keep components atomic: each component should own its
use(getOrFetch(...))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