Research sample · React 19

React request races and effect cleanup

Research question

Modernize this React data-loading flow. Identify real request races, stale state writes, cancellation weaknesses, and unnecessary effects. Distinguish current React guidance from optional framework-level alternatives.

Topics covered: React effect cleanup, fetch race conditions, AbortController

Default research

React 19 · 8,942 characters

Research complete

Question

Modernize this React data-loading flow. Identify real request races, stale state writes, cancellation weaknesses, and unnecessary effects. Distinguish current React guidance from optional framework-level alternatives.

Submitted code · project-workspace.tsxTypeScript / React
"use client"

import {
  startTransition,
  useCallback,
  useEffect,
  useMemo,
  useOptimistic,
  useRef,
  useState,
  useTransition,
} from "react"

type Project = {
  id: string
  name: string
  ownerId: string
  revision: number
}

type Activity = {
  id: string
  projectId: string
  message: string
  createdAt: string
}

type Draft = {
  name: string
  ownerId: string
}

type LoadState =
  | { status: "idle" }
View all 294 lines
"use client"

import {
  startTransition,
  useCallback,
  useEffect,
  useMemo,
  useOptimistic,
  useRef,
  useState,
  useTransition,
} from "react"

type Project = {
  id: string
  name: string
  ownerId: string
  revision: number
}

type Activity = {
  id: string
  projectId: string
  message: string
  createdAt: string
}

type Draft = {
  name: string
  ownerId: string
}

type LoadState =
  | { status: "idle" }
  | { status: "loading"; projectId: string }
  | { status: "ready"; projectId: string }
  | { status: "error"; projectId: string; message: string }

async function readJson<T>(response: Response): Promise<T> {
  if (!response.ok) {
    throw new Error(`Request failed with HTTP ${response.status}.`)
  }
  return await response.json() as T
}

async function getProject(projectId: string, signal?: AbortSignal): Promise<Project> {
  const response = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, {
    signal,
    headers: { accept: "application/json" },
  })
  return await readJson<Project>(response)
}

async function getActivity(projectId: string, signal?: AbortSignal): Promise<Activity[]> {
  const response = await fetch(
    `/api/projects/${encodeURIComponent(projectId)}/activity`,
    { signal, headers: { accept: "application/json" } },
  )
  return await readJson<Activity[]>(response)
}

async function saveProject(project: Project, draft: Draft): Promise<Project> {
  const response = await fetch(`/api/projects/${encodeURIComponent(project.id)}`, {
    method: "PUT",
    headers: {
      "content-type": "application/json",
      "if-match": String(project.revision),
    },
    body: JSON.stringify(draft),
  })
  return await readJson<Project>(response)
}

async function postActivity(projectId: string, message: string): Promise<Activity> {
  const response = await fetch(
    `/api/projects/${encodeURIComponent(projectId)}/activity`,
    {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ message }),
    },
  )
  return await readJson<Activity>(response)
}

type ActivityAction =
  | { type: "append"; activity: Activity }
  | { type: "replace"; activity: Activity }
  | { type: "remove"; id: string }

function activityReducer(current: Activity[], action: ActivityAction): Activity[] {
  switch (action.type) {
    case "append":
      return [action.activity, ...current]
    case "replace":
      return current.map((item) => item.id === action.activity.id ? action.activity : item)
    case "remove":
      return current.filter((item) => item.id !== action.id)
  }
}

export function ProjectWorkspace({ initialProjectId }: { initialProjectId: string }) {
  const [projectId, setProjectId] = useState(initialProjectId)
  const [project, setProject] = useState<Project | null>(null)
  const [draft, setDraft] = useState<Draft>({ name: "", ownerId: "" })
  const [activity, setActivity] = useState<Activity[]>([])
  const [loadState, setLoadState] = useState<LoadState>({ status: "idle" })
  const [saveError, setSaveError] = useState<string | null>(null)
  const [isNavigating, startNavigation] = useTransition()
  const [isSaving, startSaving] = useTransition()
  const [optimisticActivity, updateOptimisticActivity] = useOptimistic(
    activity,
    activityReducer,
  )
  const selectedProjectRef = useRef(projectId)

  useEffect(() => {
    selectedProjectRef.current = projectId
  }, [projectId])

  useEffect(() => {
    if (!projectId) {
      setProject(null)
      setActivity([])
      setLoadState({ status: "idle" })
      return
    }

    const controller = new AbortController()
    setLoadState({ status: "loading", projectId })
    setSaveError(null)

    void getProject(projectId, controller.signal)
      .then((nextProject) => {
        setProject(nextProject)
        setDraft({ name: nextProject.name, ownerId: nextProject.ownerId })
        return getActivity(nextProject.id, controller.signal)
      })
      .then((nextActivity) => {
        setActivity(nextActivity)
        setLoadState({ status: "ready", projectId })
      })
      .catch((error: unknown) => {
        if (error instanceof DOMException && error.name === "AbortError") return
        setLoadState({
          status: "error",
          projectId,
          message: error instanceof Error ? error.message : "Project loading failed.",
        })
      })

    return () => controller.abort("project changed")
  }, [projectId])

  const dirty = useMemo(() => {
    if (!project) return false
    return draft.name !== project.name || draft.ownerId !== project.ownerId
  }, [draft.name, draft.ownerId, project])

  const chooseProject = useCallback((nextProjectId: string) => {
    startNavigation(() => {
      setProjectId(nextProjectId)
    })
  }, [])

  const save = useCallback(() => {
    if (!project || !dirty || isSaving) return
    const previous = project
    const optimistic: Project = {
      ...project,
      ...draft,
      revision: project.revision + 1,
    }
    setProject(optimistic)
    setSaveError(null)

    startSaving(async () => {
      try {
        const saved = await saveProject(previous, draft)
        if (selectedProjectRef.current !== saved.id) return
        setProject(saved)
        setDraft({ name: saved.name, ownerId: saved.ownerId })
      } catch (error) {
        setProject(previous)
        setDraft({ name: previous.name, ownerId: previous.ownerId })
        setSaveError(error instanceof Error ? error.message : "Save failed.")
      }
    })
  }, [dirty, draft, isSaving, project])

  const addActivity = useCallback((message: string) => {
    if (!project || !message.trim()) return
    const temporary: Activity = {
      id: `optimistic-${crypto.randomUUID()}`,
      projectId: project.id,
      message: message.trim(),
      createdAt: new Date().toISOString(),
    }
    startTransition(async () => {
      updateOptimisticActivity({ type: "append", activity: temporary })
      try {
        const saved = await postActivity(project.id, temporary.message)
        setActivity((current) => [saved, ...current])
      } catch {
        updateOptimisticActivity({ type: "remove", id: temporary.id })
      }
    })
  }, [project, updateOptimisticActivity])

  const reloadActivity = useCallback(async () => {
    if (!project) return
    const nextActivity = await getActivity(project.id)
    setActivity(nextActivity)
  }, [project])

  return (
    <main className="workspace">
      <aside aria-label="Projects">
        {[["alpha", "Payments"], ["beta", "Search"], ["gamma", "Accounts"]].map(([id, label]) => (
          <button
            key={id}
            type="button"
            aria-pressed={projectId === id}
            disabled={isNavigating}
            onClick={() => chooseProject(id)}
          >
            {label}
          </button>
        ))}
      </aside>

      <section aria-busy={loadState.status === "loading" || isNavigating}>
        {loadState.status === "loading" ? <p>Loading {loadState.projectId}…</p> : null}
        {loadState.status === "error" ? (
          <div role="alert">
            <p>{loadState.message}</p>
            <button type="button" onClick={() => setProjectId(loadState.projectId)}>
              Try again
            </button>
          </div>
        ) : null}

        {project ? (
          <form onSubmit={(event) => { event.preventDefault(); save() }}>
            <label>
              Project name
              <input
                value={draft.name}
                onChange={(event) => setDraft((current) => ({
                  ...current,
                  name: event.target.value,
                }))}
              />
            </label>
            <button type="submit" disabled={!dirty || isSaving}>
              {isSaving ? "Saving…" : "Save project"}
            </button>
            {saveError ? <p role="alert">{saveError}</p> : null}
          </form>
        ) : null}

        <ActivityComposer onAdd={addActivity} />
        <button type="button" onClick={() => void reloadActivity()}>
          Refresh activity
        </button>
        <ol>
          {optimisticActivity.map((item) => (
            <li key={item.id} data-pending={item.id.startsWith("optimistic-") || undefined}>
              <p>{item.message}</p>
              <time dateTime={item.createdAt}>{new Date(item.createdAt).toLocaleString()}</time>
            </li>
          ))}
        </ol>
      </section>
    </main>
  )
}

function ActivityComposer({ onAdd }: { onAdd(message: string): void }) {
  const [message, setMessage] = useState("")
  return (
    <form onSubmit={(event) => {
      event.preventDefault()
      onAdd(message)
      setMessage("")
    }}>
      <label>
        Activity message
        <textarea value={message} onChange={(event) => setMessage(event.target.value)} />
      </label>
      <button type="submit" disabled={!message.trim()}>Add activity</button>
    </form>
  )
}

Result

TypeScript / React3 priority findings4 displayed sources

Abort cleanup is necessary, but not sufficient

The loading effect cancels cooperative fetch work, yet save, activity, and reload callbacks can still write stale state after a project change. Request identity guards close the race without forcing a framework migration.

Priority findings

03
  1. 01

    Guard activity writes by project and request

    Fix now

    A reload or mutation can finish after navigation and replace the selected project’s state. Check both project identity and request order before every success or failure write.

    project-workspace.tsx:210-213

    const nextActivity = await getActivity(project.id) setActivity(nextActivity)
    S1 · S6
  2. 02

    Pass cancellation through every request

    Fix next

    The initial load receives an AbortSignal, but save, post, and manual reload do not. Cooperative cancellation should cover each obsolete request path.

    project-workspace.tsx:199-203

    const saved = await postActivity(project.id, temporary.message)
    S1
  3. 03

    Keep the network effect; remove the ref-sync effect

    Keep

    The loading effect synchronizes with an external system. The separate effect that only mirrors projectId into a ref is unnecessary when request-local identities are used.

    project-workspace.tsx:115-119

    selectedProjectRef.current = projectId
    S9

Pair cancellation with request identity

The token rejects late completions even if the request has already resolved or does not observe abort.

Modernized techniqueTypeScript
const token = ++latest.current
const controller = new AbortController()

try {
  const value = await getActivity(projectId, controller.signal)
  if (token !== latest.current || projectId !== selectedId) return
  setActivity(value)
} catch (error) {
  if (controller.signal.aborted) return
  if (token === latest.current) setError(String(error))
}

Authoritative sources

What this React request-race audit covers

This React 19 workspace combines dependent Fetch requests, project navigation, manual refresh, optimistic activity updates, asynchronous transitions, and effect cleanup. Those interactions create realistic stale-response races that are easy to miss when each callback is reviewed in isolation.

Hattrick separates React guidance from optional framework choices. It verifies the cancellation contract against the web platform, checks effect usage against the React documentation, and grounds the highest-priority state-integrity findings in the supplied component rather than recommending a wholesale data-layer rewrite.

Engineering questions answered

  • Can state still become stale after an AbortController cleanup runs?
  • Which save, reload, and optimistic callbacks need request identity guards?
  • Which React effect is necessary, and which effect only mirrors state into a ref?

How Hattrick approaches the question

  1. 01

    Cross-callback race detection

    Connects navigation, reload, mutation, and optimistic-update paths that would look harmless when read separately.

  2. 02

    Standards-backed cancellation

    Uses the WHATWG abort model to explain why cooperative cancellation does not replace stale-write guards.

  3. 03

    Proportional modernization

    Keeps the useful network effect and treats framework data loaders as optional rather than mandatory.

Practical takeaway

Cancellation and request identity solve different problems

AbortController can stop cooperative network work, but it cannot retract a callback that already resolved. React data flows stay correct when cancellation is paired with a project ID or monotonically increasing request token before every state write.