Skip to content

How data flows

rocambille edited this page Jun 4, 2026 · 1 revision

Summary: This page explains the complete data flow cycle in StartER, from fetching data to mutating it and refreshing the UI.

Overview

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.ts for reading data
  • src/react/helpers/mutate.ts for writing data
  • src/react/components/DataRefreshContext.tsx for bridging mutations with re-renders

The data flow cycle

Reading

┌──────────────────────────────────────────────────────────────────┐
│                        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                              │
└──────────────────────────────────────────────────────────────────┘

Writing

                    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              │
└──────────────────────────────────────────────────────────────────┘

Reading data in detail

cache(path)

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"):

  1. Cache hit: returns the stored Promise immediately (no network call).
  2. 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.

use(promise)

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>
  );
}

Writing data in detail

useMutate()

The useMutate hook (src/react/helpers/mutate.ts) orchestrates three steps in sequence:

  1. API call via apiMutate(): sends the mutation request (POST, PUT, or DELETE) with automatic CSRF token attachment.
  2. Cache invalidation via invalidateCache(): removes specified paths from the in-memory Map so the next read triggers a fresh fetch.
  3. 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;
  };
}

Example: creating an item

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:

  1. The POST request was sent and succeeded.
  2. The cached Promise for "/api/items" was deleted.
  3. DataRefreshContext.tick was incremented.
  4. Any component calling use(cache<Item[]>("/api/items")) will re-suspend, triggering a fresh GET request and re-rendering with the updated list.

DataRefreshContext

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.

invalidateCache(basePath)

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.

Best practices

  • 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.

See also

Clone this wiki locally