Skip to content

How data flows

rocambille edited this page Sep 10, 2026 · 7 revisions

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()

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 → refresh → re-fetch.

The two key files are:

  • src/react/helpers/cache.ts for caching, invalidation, and reactivity
  • src/react/helpers/mutate.ts for writing data

The data flow cycle

Reading

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

Writing

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

Reading data in detail

getOrFetch(path)

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

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

use(promise)

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.

Writing data in detail

mutate()

The mutate function (src/react/helpers/mutate.ts) orchestrates two steps in sequence:

  1. API call: sends the mutation request (POST, PUT, or DELETE) with automatic CSRF token attachment.
  2. Targeted cache eviction & refresh via cache.refresh(paths): if paths are 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;
};

Example: creating an item

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:

  1. The POST request was sent and succeeded.
  2. The cached Promise for "/api/items" was deleted from the cache by refresh().
  3. Matching subscribers were notified.
  4. Any component calling useRefresh("/api/items") re-suspends, triggering a fresh GET request via use(getOrFetch(...)) and re-rendering with the updated list.

Targeted Reactivity: useRefresh()

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.

refresh(paths)

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 calling refresh() with no arguments clears the entire cache and notifies wildcard subscribers.

Best practices

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

See also

Clone this wiki locally