skip to content

In a React Router v7 dashboard, switching from /projects/1/tasks/7 to /projects/1/tasks/8 leaves the task form showing task 7's draft; why, and how do you fix it?

level: seniorimportance: should knowfreq 46%

answer

  1. same route, different params
  2. same element at the same position
  3. React reuses the instance
  4. a key tied to the id

basics

~20 s

Both URLs match the same tasks/:taskId route, so React Router renders the same element in the same place and React keeps the component instance and its state. Key the form by the task id, or make effects depend on it.

solid answer

~40 s

React Router renders matched routes without a URL-based key, so moving between two URLs that match the same route, and differ only in a dynamic segment, re-renders the existing component with new params. React reconciles by element type and position, so `TaskForm` keeps its `useState` values, its `useState` initialisers do not run again, and effects with an empty dependency list do not re-fire. The draft from task 7 stays on screen. For state that must start fresh per task, render the form with `key={taskId}`, which makes React unmount and remount it. For data that should follow the id, include the id in the effect or query dependencies. Keep the key as low as possible: keying the whole outlet would also remount layouts that should survive.

code

tsx · 18 lines
tsx
import { useParams } from "react-router";
import { useState } from "react";

export function TaskScreen() {
  const { taskId } = useParams();
  if (!taskId) return null;
  // a new key per task gives TaskForm fresh state
  return <TaskForm key={taskId} taskId={taskId} />;
}

function TaskForm({ taskId }: { taskId: string }) {
  const [draft, setDraft] = useState(""); // starts empty for every task
  return (
    <form aria-label={`Edit task ${taskId}`}>
      <textarea value={draft} onChange={(e) => setDraft(e.target.value)} />
    </form>
  );
}

go deeper

for a junior

Recall that moving between two URLs of the same route reuses the component, so local state stays until you change its key.

for a middle

Explain that React Router does not key route elements by URL and that React keeps instances by type and position, so initialisers and mount-only effects do not re-run.

for a senior

Diagnose stale per-entity state after a param change, pick the lowest component to key, and fix effect dependencies without remounting shared layouts.

for a principal

Set a team convention for per-entity state in routed screens, such as keyed editors and id-keyed data, so this class of bug does not recur.

## The symptom A dashboard shell renders projects and tasks with nested routes: the shell, a `projects/:projectId` frame and a `tasks/:taskId` screen whose `TaskForm` keeps a local draft. A user edits task 7, then clicks task 8 in the sidebar. The URL and the heading change, but the form still shows task 7's draft text. Sometimes saving then writes task 7's draft onto task 8. ## Why it happens Two facts combine. 1. **React Router reuses the element for the same route.** `/projects/1/tasks/7` and `/projects/1/tasks/8` match the same branch of the route tree. React Router renders the same route elements at the same positions, and it does not key them by URL; only the params change. 2. **React preserves state by type and position.** When a component of the same type renders at the same place in the tree, React updates the existing instance instead of creating a new one. So: - `useState(initialDraft)` keeps its current value; the initial value is only read on mount; - `useEffect(() => { ... }, [])` does not run again; - refs keep their contents; uncontrolled inputs keep what the user typed. The same rule is what keeps the dashboard shell and the sidebar mounted, which is desirable there. For the task form, it means the component never noticed that it now represents a different task. ## Fix 1: key the stateful part by the id Give the component that owns per-task state a `key` derived from the id. A changed key tells React that this is a different component instance, so it unmounts the old one and mounts a fresh one: ```tsx function TaskScreen() { const { taskId } = useParams(); return <TaskForm key={taskId} taskId={taskId!} />; } ``` This is the right fix when the component's local state is **per entity**: drafts, wizard steps, expanded sections, scroll inside a panel. ## Fix 2: make effects and derived data depend on the id When the component should stay mounted but refresh what it shows, put the id in the dependency list of the effect or the data hook, and reset or derive state from the new data. Fetching task details in an effect with `[taskId]` dependencies, or reading them from a data layer keyed by the id, keeps the view in sync. Mixing both is common: key the draft form, keep the surrounding panel mounted. ## Where to put the key | Placement | Effect | |---|---| | `key={taskId}` on `TaskForm` | only the form remounts; shell, frame and panel survive | | `key={taskId}` on `TaskScreen`'s outer element | the whole task screen remounts | | a key on the shell's `<Outlet />`, such as the pathname | the project frame and everything below remount on every navigation | Keying high in the tree fixes the bug but throws away state that should survive and repeats work on every click, such as re-running the frame's effects. Keep the key at the lowest component that owns per-task state. ## What not to do - **Copying params into state in an effect** (`useEffect(() => setDraft(load(taskId)), [taskId])`) works but renders once with the stale draft before the effect corrects it, and can overwrite a draft the user just started. - **Adding `reloadDocument` to the sidebar links.** A full page load hides the symptom and discards the entire app's state. - **Expecting the router to remount** by adding more routes or duplicating the task route for each id pattern. ## Checklist when a screen "does not update" on navigation - Does the old and new URL match the **same route**? Then the component is being reused. - Which state is **per entity**? Key the component that owns it. - Which effects read the param? Their dependency lists must include it. - Is anything stored in a ref or an uncontrolled input? Those also persist until remount. ## Summary - Same route plus new params means the same component instance. - React keeps state, refs and mount-only effects across that navigation. - Key per-entity state by the id, as low in the tree as possible. - Put the id in the dependency lists of effects that read it.

  • In React Router v7, why not put key={location.pathname} on the dashboard shell's <Outlet />?
    It forces everything below the shell to remount on every navigation, including the project frame, its effects and any state such as an open panel. That fixes the stale draft by brute force while adding remount work and losing state that should survive. Key only the component that owns per-task state.
  • In React Router v7, does navigating from /projects/1/tasks/7 to /projects/2/tasks/7 remount the project frame?
    No. Both URLs match the same `projects/:projectId` route, so the frame component is reused with a new `projectId`, exactly like the task screen. Any per-project state in the frame needs the same treatment: a key on the component that owns it or dependency lists that include the project id.

A route element is like a hotel room, not a guest: when the next booking arrives for the same room, the room is reused as it stands. Unless housekeeping is called, which is what a new key does, the previous guest's notes are still on the desk.

saying these in an interview costs you the question

  • React Router remounts the route component whenever the URL changes
  • useState re-reads its initial value when the params change
  • The fix is to add reloadDocument to the sidebar links
  • An effect with an empty dependency list re-runs on every navigation
  • Keying the outer <Outlet /> by pathname is the cleanest fix