skip to content

In a React Router v7 settings area, a Back button and the post-save redirect both call navigate(-1); what breaks for deep-linked users, and how do you fix it?

level: seniorimportance: should knowfreq 42%

answer

  1. not every visit has an in-app previous entry
  2. a bookmark or a new tab
  3. location.key on the first entry
  4. fallback route, replace after saving

basics

~20 s

navigate(-1) steps back in the browser history, which is outside the app when the user opened the edit URL directly, so they are sent away. Go back only when location.key is not "default"; otherwise navigate to a known parent route with replace.

solid answer

~40 s

`navigate(-1)` is `history.go(-1)`: it goes wherever the previous browser entry points. After in-app clicking, that is the settings page, but a user who opened `/settings/profile/edit` from a bookmark, an email or a new tab has no in-app previous entry, so Back takes them to another site or does nothing. The React Router docs warn about this. React Router marks the first location of a fresh page load with `location.key === "default"`, so a `useGoBack(fallback)` helper can call `navigate(-1)` only when the key differs, and otherwise `navigate(fallback, { replace: true })`. For the post-save step the same helper works: going back pops the form off the stack, and the fallback replaces the form entry so Back from the profile does not reopen a submitted form.

code

tsx · 28 lines
tsx
import { useLocation, useNavigate } from "react-router";

export function useGoBack(fallback: string) {
  const navigate = useNavigate();
  const location = useLocation();
  return () =>
    location.key !== "default"
      ? navigate(-1)
      : navigate(fallback, { replace: true });
}

export function EditProfileActions({ onSave }: { onSave: () => Promise<void> }) {
  const goBack = useGoBack("/settings/profile");
  return (
    <>
      <button type="button" onClick={goBack}>Back</button>
      <button
        type="button"
        onClick={async () => {
          await onSave();
          goBack();
        }}
      >
        Save
      </button>
    </>
  );
}

go deeper

for a junior

Recall that navigate(-1) behaves like the browser Back button and can leave the app when there is no earlier in-app page.

for a middle

Explain location.key "default" on the first entry, and why a pushed post-save redirect leaves the form reachable with Back.

for a senior

Design one back helper with a parent-route fallback and replace, and test deep links and new tabs, not only click-through flows.

for a principal

Make safe back navigation a shared primitive with a fallback convention, so every team's forms behave the same for deep-linked and returning users.

## The scenario A nested settings area has a read-only profile page at `/settings/profile` and an edit form at `/settings/profile/edit`. The form has a **Back** button, and after a successful save the form also returns the user. Both were implemented as: ```tsx const navigate = useNavigate(); <button onClick={() => navigate(-1)}>Back</button> // ...after save: await saveProfile(values); navigate(-1); ``` In testing this works, because testers always arrive at the form by clicking **Edit** on the profile page. ## What breaks `navigate(-1)` delegates to the browser's history (`history.go(-1)`). It does not know where "back" should be inside the app; it goes to whatever entry precedes the current one. That fails in three common situations: 1. **Deep link or bookmark**: the user opens `/settings/profile/edit` directly. The previous entry is the page they came from, possibly another site, or there is none. Back leaves the app or does nothing. 2. **New tab**: the form was opened with Cmd-click from the profile page. The new tab's history starts at the form, so Back does nothing. 3. **Arrival from elsewhere in the app**: the user reached the form from a notification link on the dashboard. Back after save returns them to the dashboard rather than the profile they just edited, which may be acceptable or not, depending on the product. The React Router docs say it directly: be cautious with `navigate(number)`, because there may be no entry to go back to, or it may go somewhere unexpected; only use it when you are sure the entry exists. ## Detecting "no in-app history" Every React Router location has a `key`. The first location of a fresh page load gets the key `"default"`; every entry the router pushes or replaces gets a generated key stored in `history.state`. That gives a practical test: - `location.key !== "default"`: this entry was created by in-app navigation, so the previous entry is very likely in the app; - `location.key === "default"`: the user landed here from outside, so going back would leave. It is a heuristic. For example, a user who lands on the profile page and moves to the form through a `replace` navigation gets a generated key on the form entry, yet the entry before it is still outside the app. It is still the signal React Router itself provides for this, and it covers bookmarks, shared links and new tabs. ## The fix Wrap the decision in one hook and use it for both buttons: ```tsx function useGoBack(fallback: string) { const navigate = useNavigate(); const location = useLocation(); return () => location.key !== "default" ? navigate(-1) : navigate(fallback, { replace: true }); } ``` - **Back button**: `const goBack = useGoBack("/settings/profile")`. In-app users get a true browser Back; deep-linked users land on the profile page instead of leaving. - **Post-save**: call the same `goBack()` after `await saveProfile(...)`. Popping back returns to the entry the user came from, leaving the form only as a Forward entry that disappears on their next navigation; the fallback **replaces** the form entry, so pressing Back from the profile page does not reopen a form that was already submitted. Why not always `navigate("/settings/profile")` after saving? A **push** leaves the history as profile → edit → profile, so Back from the fresh profile page reopens the edit form showing the pre-save values. Using `{ replace: true }` on the fallback avoids that. ## Checklist for review - No `navigate(-1)` without a fallback on any screen that can be deep-linked. - Post-mutation navigations either pop back or **replace** the form entry. - The fallback is a real in-app route, preferably the logical parent. - Test by opening the form URL in a fresh tab, not only by clicking through. ## Approaches that look simpler and fail - **`window.history.length > 1`.** The length counts entries from other sites in the same tab, so it is above 1 for a user who arrived from a search engine; it cannot tell in-app history from outside history. - **`document.referrer`.** It is empty for bookmarks and typed URLs, often stripped by referrer policies, and does not change on client-side navigations, so it describes the first document load rather than the in-app path. - **Always navigating to a fixed parent.** Correct for deep links, but it discards the real in-app Back for users who came from somewhere else, such as a dashboard notification. - **Storing a custom back stack in a global store.** It duplicates what browser history already records and drifts from it as soon as the user presses the browser's own Back or Forward buttons. ## Summary - `navigate(-1)` follows browser history, which may point outside the app. - `location.key === "default"` marks a fresh landing. - Go back when the key says you can; otherwise navigate to a parent route with `replace`.

  • In React Router v7, why does the fallback use replace instead of a push?
    A push after saving leaves the edit form as the previous entry, so Back from the profile reopens the form with stale values. Replacing the form's entry with the profile route removes it from the stack, so the user's Back button goes to wherever they were before the form.
  • In React Router v7, does location.key stay "default" after the user reloads a page they reached by in-app navigation?
    No. React Router stores the generated key in `history.state`, which survives a reload, so the reloaded entry keeps its in-app key. Only an entry the router did not create, such as the first URL typed or opened in a tab, reads `"default"`.

saying these in an interview costs you the question

  • navigate(-1) always returns to the previous page within the app
  • React Router keeps its own back stack separate from browser history
  • Pushing the profile route after saving is harmless for the Back button
  • location.key is "default" on every entry until the user reloads
  • Opening the edit URL in a new tab behaves like clicking through