Default research
React 19 · 8,942 characters
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.
"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
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- 01S1 · S6
Guard activity writes by project and request
Fix nowA 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) - 02S1
Pass cancellation through every request
Fix nextThe 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) - 03S9
Keep the network effect; remove the ref-sync effect
KeepThe 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
Pair cancellation with request identity
The token rejects late completions even if the request has already resolved or does not observe abort.
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

