The Subtle React Router API Difference That Broke Our Pagination

I ran into a frustrating bug on a work project. We had a paginated table where users could apply filters. When someone was on page 2 or later and applied a filter, they'd see "No Records Found", even though filtered results existed on page 1. They had to manually click back to page 1 to see anything.

The problem was how we were reading URL state during updates. When filters were applied, we needed to reset pagination to page 1 and apply the filters at the same time. If we read the URL state wrong, the second update would read stale values and lose the pagination reset.

This bug led me to a subtle but important distinction in React Router: history.location vs location from useLocation(). Knowing when to use each one matters when you need multiple URL updates to happen in sequence.

The Two Different Use Cases

1. Reading State for Components (Reactive)

Use location from useLocation()

const location = useLocation()

// ✅ Use location.search for reactive state
const state = useMemo(
  () => deserializeState(location.search, { keys, prefix }),
  [location.search, keys, prefix] // Re-renders when URL changes
)

Why this works:

  • location from useLocation() is part of React Router's state management
  • When it changes, React automatically re-renders components that depend on it
  • This is what you want for components that need to react to URL changes

Use this when:

  • Deriving component state from URL params
  • Displaying current URL state in UI
  • Conditional rendering based on URL

2. Reading State During Updates (Synchronous)

Use history.location

// Inside a function that updates the URL
function updateUrl(newState) {
  // ✅ Use history.location.search to read current state
  const currentSearch = history.location?.search ?? location.search
  const currentParams = new URLSearchParams(currentSearch)

  // Merge new state with current params
  Object.entries(newState).forEach(([key, value]) => {
    currentParams.set(key, value)
  })

  history.replace({ search: currentParams.toString() })
}

Why this works:

  • history.location updates synchronously when history.replace() is called
  • When multiple URL updates happen in sequence, you need this
  • It guarantees you read the most up-to-date URL state during mutations

Use this when:

  • Merging URL params during updates
  • Reading current state before applying changes
  • Coordinating multiple URL state updates

Decision Tree

Are you reading URL state?
│
├─ Is it for a component that should re-render when URL changes?
│  └─ YES → Use `location` from `useLocation()`
│     Example: const state = useMemo(() => parse(location.search), [location.search]);
│
└─ Is it inside a function that's updating the URL?
   └─ YES → Use `history.location`
      Example: const current = history.location?.search ?? location.search;

Key Differences

Aspectlocation (useLocation)history.location
Update timingAsynchronous (next render)Synchronous (immediate)
Use caseReactive component stateReading during mutations
Re-rendersTriggers React re-rendersDoes not trigger re-renders
When to useDisplaying URL state in UICoordinating URL updates

Real-World Example: The Pagination Issue

Remember the bug from the beginning where users on page 2+ would see "No Records Found" after applying filters? Here's the pattern that fixed it:

// Component that displays current page
function PageDisplay() {
  const location = useLocation() // ✅ Reactive
  const page = new URLSearchParams(location.search).get('page')
  return <div>Current page: {page}</div> // Re-renders when URL changes
}

// Function that resets pagination and applies filters
function setFiltersWithPaginationReset(newFilters) {
  // Step 1: Reset pagination to page 1
  setPagination({ pageIndex: 0 })

  // Step 2: Apply filters (happens immediately after, same tick)
  setFilters(newFilters)

  // Inside replaceHistorySearchParams (called by both):
  // ✅ Need synchronous read using history.location
  const currentSearch = history.location?.search ?? location.search
  const params = new URLSearchParams(currentSearch)
  // ... merge params ...
  history.replace({ search: params.toString() })

  // Because history.location updates synchronously, the second call
  // can read the updated page=1 from the first call
}

When filters are applied while on page 2+, both updates happen in the same tick. Using history.location means the second update reads the updated page number from the first update. Without this, users would stay on page 2 with filters that only have results on page 1.

Why Not Always Use history.location?

If history.location updates synchronously and gives us the most current state, why not use it everywhere? Because components wouldn't re-render when the URL changes.

The Critical Difference: React Re-renders

The Problem with Always Using history.location

If we used history.location everywhere, components wouldn't re-render when the URL changes.

// ❌ BAD: Using history.location for component state
function MyComponent() {
  const history = useHistory()

  const page = useMemo(() => {
    const params = new URLSearchParams(history.location.search)
    return params.get('page') || '1'
  }, [history.location.search]) // ❌ This won't trigger re-renders!

  return <div>Page: {page}</div> // ❌ Won't update when URL changes!
}

Why location from useLocation() is Needed

// ✅ GOOD: Using location for component state
function MyComponent() {
  const location = useLocation()

  const page = useMemo(() => {
    const params = new URLSearchParams(location.search)
    return params.get('page') || '1'
  }, [location.search]) // ✅ Triggers re-renders when URL changes!

  return <div>Page: {page}</div> // ✅ Updates when URL changes!
}

How React Router's Reactivity Works

location from useLocation() - Reactive

import { useLocation } from 'react-router-dom'

const location = useLocation()

// This is part of React Router's context
// When the URL changes:
// 1. React Router updates its internal state
// 2. Components using useLocation() re-render
// 3. useMemo dependencies trigger recalculation

Flow:

URL changes → React Router state updates → useLocation() triggers re-render → Component updates

history.location - Not Reactive

import { useHistory } from 'react-router-dom'

const history = useHistory()

// This is the raw History API object
// When the URL changes:
// 1. history.location is updated synchronously
// 2. BUT React doesn't know about it
// 3. Components don't re-render

Flow:

URL changes → history.location updates → ❌ No React re-render → Component stays stale

Real Example: What Would Break

Current Implementation (Correct)

import { useLocation, useHistory } from 'react-router-dom'

export const useUrlState = (keys) => {
  const location = useLocation()
  const history = useHistory()

  // ✅ REACTIVE: Components re-render when URL changes
  const state = useMemo(() => {
    const params = new URLSearchParams(location.search)
    return keys.reduce((acc, key) => {
      acc[key] = params.get(key)
      return acc
    }, {})
  }, [location.search, keys]) // ← Triggers re-render

  const setState = useCallback(
    (newState) => {
      // ✅ SYNCHRONOUS: Reads updated state during mutations
      const currentSearch = history.location?.search ?? location.search
      const params = new URLSearchParams(currentSearch)

      Object.entries(newState).forEach(([key, value]) => {
        params.set(key, value)
      })

      history.replace({ search: params.toString() })
    },
    [history, location]
  )

  return { state, setState }
}

If We Used history.location Everywhere (Broken)

import { useHistory } from 'react-router-dom'

export const useUrlState = (keys) => {
  const history = useHistory()

  // ❌ NOT REACTIVE: Components won't re-render!
  const state = useMemo(() => {
    const params = new URLSearchParams(history.location.search)
    return keys.reduce((acc, key) => {
      acc[key] = params.get(key)
      return acc
    }, {})
  }, [history.location.search, keys]) // ← Won't trigger re-render!

  const setState = useCallback(
    (newState) => {
      const params = new URLSearchParams(history.location.search)

      Object.entries(newState).forEach(([key, value]) => {
        params.set(key, value)
      })

      history.replace({ search: params.toString() })
    },
    [history]
  )

  return { state, setState }
}

// Component using this:
function DataGrid() {
  const { state } = useUrlState(['page'])
  // ❌ This component won't re-render when URL changes!
  // ❌ UI will show stale data!
  return <div>Current page: {state.page}</div>
}

The Two Use Cases Are Different

1. Reading for Display (Reactive) → Use location

Purpose: Components need to update when URL changes

import { useLocation } from 'react-router-dom'

function PageDisplay() {
  const location = useLocation()
  const page = new URLSearchParams(location.search).get('page')

  // ✅ This component re-renders when URL changes
  // ✅ Shows current page number
  return <div>Page {page}</div>
}

2. Reading During Mutations (Synchronous) → Use history.location

Purpose: Need current state during update operations

import { useHistory, useLocation } from 'react-router-dom'

function useUpdateUrl() {
  const history = useHistory()
  const location = useLocation()

  return (newState) => {
    // ✅ Read current state synchronously
    const currentSearch = history.location?.search ?? location.search
    const params = new URLSearchParams(currentSearch)

    // ✅ Merge with new state
    Object.entries(newState).forEach(([key, value]) => {
      params.set(key, value)
    })

    // ✅ Update URL
    history.replace({ search: params.toString() })
  }
}

Why React Router Designed It This Way

React Router separates two concerns:

  1. history.location - Raw History API

    • Synchronous updates
    • For imperative operations
    • Doesn't trigger React re-renders
  2. location from useLocation() - React state

    • Asynchronous updates (next render)
    • For declarative components
    • Triggers React re-renders

This separation gives you both:

  • Components that reactively update (declarative)
  • Functions that read current state synchronously (imperative)

Summary

When to use history.location vs location from useLocation():

  • location from useLocation(): Use for reactive reads in components. It triggers React re-renders when the URL changes, so it's what you want for deriving component state from URL params.

  • history.location: Use for synchronous reads during URL mutations. When multiple URL updates happen in sequence (like resetting pagination and applying filters), history.location updates synchronously, so each update reads the most current state.

Both serve different purposes:

  • location → Reactive component state (triggers re-renders)
  • history.location → Synchronous reads during mutations (no re-renders, but immediate updates)

In the pagination bug I hit, using history.location when reading URL state during updates meant that when we reset pagination to page 1 and then applied filters, the second update could read the updated page number from the first update. This fixed the "No Records Found" issue.