WebMriya
Back to articles

React URL State Without Synchronization Bugs

Keep filters, sorting, and pagination shareable by treating the URL as state—with clear rules for parsing, updates, and browser history.

React
Frontend Architecture
Software Architecture
W
Frontend Architecture & Legacy Modernization
October 15, 2026 · 5 min read

A user filters a large table, opens a record, then presses Back. Instead of returning to the previous filter, the app exits the page—or shows a URL that no longer matches the visible results. This usually happens because the same state is being stored in several places: component state, a data-fetching cache, and query parameters, each updated on a slightly different schedule.

For state that should survive refreshes, be shareable, or respond to Back and Forward, the URL is more than a navigation detail. It is a durable interface between the user, the browser, and the application. The key is to decide which state belongs there and establish one predictable route for reading and changing it.

Put durable view state in the URL

Search terms, filters, sort order, pagination, and selected tabs often affect what a page displays. Users reasonably expect to copy a link and see the same view later. These values are good candidates for query parameters. Temporary interface details—whether a tooltip is open, which field currently has focus, or an unsaved draft—usually are not.

  • URL state: shareable, refresh-safe view inputs such as `?status=open&page=2`.
  • Local UI state: transient interaction details that should disappear when the component or task ends.
  • Server state: records returned by the API, with freshness and caching managed separately.

Separating these categories avoids treating the URL as a second copy of every piece of application state. It also complements explicit UI modeling: React state machines help describe the interface's valid states, while the URL can identify the durable inputs that select a view.

Parse parameters into a validated view model

Query parameters are untrusted input. A link can contain an unknown sort key, a negative page number, or repeated values where the app expects one. Read them at the boundary, apply defaults, and normalize invalid values before they reach rendering or a data request.

ts
type Sort = "name" | "created";

type TableView = {
  page: number;
  status: "all" | "open" | "closed";
  sort: Sort;
};

function parseTableView(search: string): TableView {
  const params = new URLSearchParams(search);
  const page = Number(params.get("page"));
  const status = params.get("status");
  const sort = params.get("sort");

  return {
    page: Number.isInteger(page) && page > 0 ? page : 1,
    status: status === "open" || status === "closed" ? status : "all",
    sort: sort === "created" ? "created" : "name",
  };
}

This gives the rest of the page a small, typed model instead of repeated string checks. Keep parsing and serialization close together, and test defaults, malformed values, and repeated parameters. In Next.js or another routing framework, use its navigation APIs where appropriate; the important principle is the same: the framework adapter should preserve this boundary rather than scattering URL parsing through components.

Update one parameter without erasing the rest

A common bug is rebuilding the whole query string when one control changes. Updating the status filter accidentally removes the sort order; changing the page drops an unrelated campaign parameter. Start from the current parameters, modify only the keys the feature owns, and remove defaults when a shorter canonical URL is preferable.

ts
function updateQuery(updates: Record<string, string | null>) {
  const url = new URL(window.location.href);

  for (const [key, value] of Object.entries(updates)) {
    if (value === null) url.searchParams.delete(key);
    else url.searchParams.set(key, value);
  }

  window.history.pushState(null, "", url);
  window.dispatchEvent(new PopStateEvent("popstate"));
}

The example illustrates the update rule, but production code should match the router's contract. Framework routers may need their own navigation method to trigger rendering, data loading, or scroll behavior. Avoid mixing direct History API calls and router navigation for the same state unless that interaction is deliberate and tested.

Make browser history match user intent

Not every URL change deserves a new history entry. If typing into search creates an entry for every character, Back becomes unusable. Debounced search can use replacement while the user is still editing, then a deliberate submission can create a navigable checkpoint. Filter changes and pagination often deserve a history entry because users may want to step back through those choices.

  • Use push when the change represents a meaningful step the user may want to undo with Back.
  • Use replace for intermediate or normalized values that should not create extra history entries.
  • When updating a URL, preserve unrelated parameters and avoid resetting scroll unless the interaction calls for it.

The right policy depends on the screen, not a universal rule. Test the sequence users actually perform: set a filter, change a page, open a detail view, and return. Browser history should restore a coherent view, not just a string.

Keep the URL, rendered controls, and data request aligned

When a URL changes, the controls and results must derive from the same parsed view model. Avoid maintaining a separate copy of filter values in component state unless there is a clear reason, such as an unapplied form draft. For applied filters, parse the URL and use that model both to render the controls and to form the request. This reduces the chance that a request uses new filters while the controls still display old ones.

The URL is not the server-data cache. A query library still needs a cache key that includes every input affecting the response, such as status, sort, and page. That keeps distinct views from sharing the wrong result. For mutation-driven freshness, use targeted cache updates or invalidation rather than making URL changes trigger broad refetches; the same principle is covered in React Query cache invalidation.

A practical review checklist

  1. List which view inputs must survive refresh, sharing, and browser navigation.
  2. Define allowed values and defaults for each query parameter.
  3. Parse and validate parameters once at the route boundary.
  4. Update only owned parameters, preserving unrelated query values.
  5. Choose push or replace according to whether Back should revisit the change.
  6. Verify that controls, requests, and cache keys use the same normalized view model.

Test direct entry to a filtered URL, refresh, malformed parameters, Back and Forward, and links copied from the page. Also check empty results and changing page size: if the current page becomes invalid, decide whether to clamp it or return to page one, then update the URL and results together.

When URL state is the wrong tool

Do not put every interaction in the address bar. Password visibility, a hover state, or an unfinished multi-step form can create noisy or sensitive URLs without making the experience more useful. URL state earns its place when the view should be reproducible, navigable, or shareable. For all other state, use the narrowest owner that matches its lifetime.

In complex applications, this small boundary makes deep links, browser navigation, and data fetching easier to reason about as features grow. WebMriya builds and modernizes these kinds of React interfaces as part of its complex web application development work.

Frequently asked questions

Should every filter be stored in the URL?

No. Put filters there when users should be able to refresh, share, or revisit the resulting view. Keep temporary or unapplied choices local.

Should filter changes use pushState or replaceState?

Use push when Back should revisit a meaningful choice, such as a submitted filter or page change. Use replace for intermediate edits, such as each keystroke in a search box.

Does URL state replace a React Query cache?

No. The URL identifies the view inputs; the cache stores server responses. Include the normalized URL-derived inputs in the query key when they affect the response.