In-Page Navigation Using Search Params

Complex frontend applications, managing querystring/search params, persisting application state into the URL, and navigating between trees of components within a page

In-Page Navigation Using Search Params
Photo by Timo Volz / Unsplash

What’s in a URL?

When you write a web application (though this is also increasingly true for native applications as well), you access it by visiting a URL.

A URL has multiple parts. For example, the URL https://www.youtube.com/watch?v=dQw4w9WgXcQ has the hostname youtube.com and the path /watch, which are used to direct you to the correct server (the YouTube servers) and the correct page (the video watch page), respectively (this is a massive oversimplification, but serves its purpose for this demonstration).

What about the stuff after the question mark? That’s called the querystring (in this case, ?v=dQw4w9WgXcQ). It is a literal string, and is used to indicate “stuff” about the page itself (again, an oversimplification). In this case, the ?v= tag is used by the page to figure out which video to load.

Within a querystring, there are “search parameters” (you’ll see them being referred to as “search params”, or even just “params”). They are the parts that make up the querystring, and are typically separated by the &. In this case, the querystring contains only one search parameter - ‌v=dQw4w9WgXcQ, but you can have any number of key=value pairs embedded within a querystring, like so:

?param1=val1&param2=val2 (querystring) = param1=val1 + param2=val2 (search params)

Typically, we “break up” these search parameters into an object of said key/value pairs, like so:

{
  "param1": "val1",
  "param2": "val2"
}

Since search parameters can be stored in the URL (as part of the querystring), and can basically encode any “flat” object (like the one above), they are often used to store a page’s “state”.

The way it works is that you read the querystring to form the object of search param key/value pairs, and use that to populate the page’s internal state. Then, when you change the page’s state (and thus the object), you “persist” it into the URL by serializing the object into the querystring.

That’s how you can immediately “set” a non-default state when you load the page - just from the URL - when you copy and paste the link (in the example above, you do not need to first load youtube.com, then go find that video, and then load that page - loading the URL already fills in those details for us).

Search Params for Persisting State

So say you have component A and component B, both of which want to read/write their state via the URL as search params.

If component A persists its state via the search parameter param_a and B via param_b, they can each independently read their state from the querystring in form of ?param_a=val_a&param_b=val_b.

And, if these components are completely independent of each other, they can also independently set their state.

For example, if component A’s state is updated to “foo”, the querystring will now be ?param_a=foo&param_b=val_b. Then, later, if component B updates its state to “bar”, the querystring will then be ?param_a=foo&param_b=bar.

However, note the qualifier above: “if these components are completely independent of each other”.

For simple HTML/CSS/JS pages, that assumption is easier to make. However, for more complicated applications - especially Single Page Applications (SPAs) - with multiple moving parts (i.e. components), it is harder to make that assumption.

And when trying to update the related components’ states at once, various problems can arise.

For instance, if you try to update both component A’s state and component B’s state, independently, at the same time, here’s what can happen:

  • component A reads the querystring ?param_a=val_a&param_b=val_b, and changes it to reflect its updated state: ?param_a=foo&param_b=val_b
  • component B reads the querystring ?param_a=val_a&param_b=val_b, and changes it to reflect its updated state: ?param_a=val_a&param_b=bar

Whose querystring should be accepted? Which component is “right”? In either case, it’s clear that one of the components’ state update will be lost due to the concurrent access to the querystring.

Basically, even though we’d like to think that we’re reading and writing the various components’ state and params independently, they ultimately have to share the same bed - the querystring. And so, concurrent writes of the URL-persisted states become problematic.

Another problem occurs when a search param needs to be cleared.

Obviously, we don’t want to clutter the URL with unlimited search params for components that aren’t even mounted anymore (they look ugly, they can interfere with other pages’ states, etc.), so we need to “clean” it by removing search params that we no longer need.

The question is, how and when?

To illustrate this problem, let’s consider this example component hierarchy:

     A
   / | \
  B--C  D
 /\ / \ /\
...........

In this setup, component A is a tab, which persists its state via ?tab=$selected_tab. A can conditionally render B or C, both of which persist their own state to the URL. D is another child of A that is always mounted, no matter what.

Say that B is the currently selected tab, so the URL has ?tab=B and various other search parameters from B, D, and their children.

When we select C, B and its children are no longer mounted anymore. So how do we clean up the search params from those components without affecting A or D’s?

There are several approaches to this that, while they seem like they should solve the problem, don’t, or are extremely complicated to implement.
For instance, you might think that ordering the querystring in such a way where you can simply “strip off” the bits on the right to drop B’s children would work: ?tab=B&param_b=blah&params_to_drop=blah... -> ?tab=C&param_c=blah.
Unfortunately, this doesn’t work because this approach only works when the querystring structure is “linear” - i.e. when rather than a tree of components, you have a chain of components (A - B - child -> A - C - blah). In the example above, the presence of D makes the component structure a tree, not a chain.
Okay, if the problem is that we need to drop the child components’ params, why don’t we just have the components erase their own search params when they get unmounted? This method is the most intuitive, and yet it doesn’t work because 1. you can’t really control the order in which the useEffect “on unmount” cleanup hooks get called across components in a tree, and more importantly, 2. all of the unmounted components will try to mutate the URL at the same time, which brings us back to the “concurrent writes” problem we discussed above.
So if the problem is with multiple setters going off at once, then we have a place where a single setter is being called - at the component that’s actually doing the navigation! In the above example, rather than trying to work off of B’s children unmounting (resulting in N API calls), we could just “clean up” the URL at A as the user clicks on a different tab, “merging” all of the search param changes into one call. That’s great; however, this singular caller needs to know either 1. all of the search params to keep, or 2. all of the search params to discard.
So let’s consider the first option: the caller knows all of the search params to keep. An obvious thing to do is to have a React context, to which you keep adding the components’ params as you keep “drilling” deeper into the component tree (i.e. A - B - …). However, note that this only allows you to keep track of a component’s direct lineage (i.e. a component’s parent, its parent, and so on), and not the other “branches” of the component tree (for instance, D), so again, it only works when you have a linear path down the tree.
Then what about keeping track of all of the children’s params? This one is even less tenable, because it requires that each component knows about all of its childrens’ search params as components arbitrarily keep getting mounted under it. In React, the data flows down the component tree, not up!

To cut to the chase, the solution involves building a hierarchy of states.

In particular, we build a “tree” object, which consists of various “nodes”. Each “node” represents various “states”, and consists of:

  • an id to uniquely identify the node in the tree
  • a list of search params that is tracked by the node
  • an optional list of child nodes

Put together, a tree object might look something like this:

{
  "node_1": {
    "params": ["param_1", "param_2"]
  },
  "node_2": {
    "params": ["param_3"],
    "children": {
      "node_3": {
        "params": ["param_4"]
      },
      "node_4": {
        "params": ["param_5", "param_6", ...],
        "children": { ... }
      }
    }
  }
}

Note that each node can have more than one params. This solves the concurrency problem outlined above, by basically having each node serve as a “unit” of transaction that updates the querystring (the technical term for this is “Unit of Work”).

In the example showcasing the problem with simultaneous state/param update from two components, we would bunch up the search params param_a (component A) and param_b (component B) into a single node with params: ["param_a", "param_b"], and have A and B share the node. Then, when you update param_a, param_b, or both, you simply make a single update call to the node.

In other words, we can structure groups of search params such that you can make only one update to a single node, but that update can contain changes to multiple params (this is how we solve the concurrency problem).

Incidentally, this means that a tree structure where you might need to update more than one node at a time is a bad design; simply merge the nodes if such a need arises.

Furthermore, the node hierarchy can be used to figure out which search parameters are no longer relevant (without having to rely on the React component tree, which, as we saw above, is basically impossible to traverse freely).

In the component tree example, when we “navigate” from component B to component C by choosing from a tab (component A), we can traverse the tree to find out which node B belongs to, aggregate all of the search params for that node and its children, and automatically remove all of them in the same call that updates A’s state (the currently active tab).

So, what does it look like in practice? Super simple, actually.

While I won’t share any code (for one, this is still experimental, and for another, it’s for work), you would generally have a context provider (ApplicationStatePersistenceLayer) that wraps a React context (ApplicationStateHierarchyContext) and a hook consuming it (usePersistedApplicationState).

First, you define the tree of application state bunched up into a bunch of nodes. Say it’s stored in a variable aptly called treeObj (see above for example of a tree object).

You initialize the context in any page where you want to have in-page navigation via search params, like so:

export default function Page() {
  // The `treeObj` gets passed into the `ApplicationStateHierarchyContext` react context,
  // so that downstream consumers can simply read the context for the tree (without the need for prop drilling).
  return (
    <ApplicationStatePersistenceLayer tree={treeObj}>
      <PageDetails />
    </ApplicationStatePersistenceLayer>
  )
}

Then, within the page (i.e. some descendant of PageDetails), you would be able to consume the current state:

export function ReaderComponent() {
  // We can derive the state from the URL using the id:
  // the hook has access to the `ApplicationStateHierarchyContext`,
  // and from the node id, we can look up the tree and find our node.
  // From the node, we can see which search param keys it tracks,
  // and then pull the relevant values from the querystring,
  // forming an object of key/value pairs.
  const { state } = usePersistedApplicationState(id)
  
  // Do stuff with the state
  console.log("Value for search param 'foo': ", state.foo)
}

Then, when you need to change the state or navigate, use the setter provided by the hook:

export function WriterComponent() {
  const { setState } = usePersistedApplicationState(id)
  
  // Then pass the search params you'd like to set via the setter
  setState({ foo: 'bar' }) // this drops all of the params "below" the node
  
  setState({ foo: 'bar' }, { inplace: true }) // this does an in-place update, to preserve the params "below"
}

Easy, really.

Already, you can see some very nice things about the abstraction layer:

  • You do not need to think about reading/writing to and from the URL. The abstraction allows you to think in terms of states (and we’re already familiar with dealing with “remote states”! See: API requests).
  • You do not need to worry about concurrent writes to the URL. The abstraction “bunches up” changes to multiple search params into a single write (again, this is the “Unit of Work” design pattern).
  • You do not need to worry about what’s happening in other components (in terms of state and/or navigation), whether they’re above or below in the component tree. You can focus on just your current component, because the nodes provide a layer of isolation between the various states and search params that would otherwise all be tangled within the single querystring.
  • The abstraction makes you think in terms of groups of states, rather than at the individual search param level. It’s subtle, but because you need to provide the structure of the tree up front, it forces you to think about which states are related, which should be colocated, and how the states would be used. It’s honestly good design - trying to put states that are often updated together into a “unit” - and feeds back into good component design(TM).

Furthermore, this makes implementing things like tabs or “multi-state” components easy. Let’s demonstrate first with a tab.

Typically, React tab components are fully controlled (meaning the state resides outside of the component - perfect for this type of “remote state” abstraction), and conditionally render their children based on which tab is currently active.

With this approach, persisting the state of the tab - no matter where it is in the component tree (in complex applications, it’s not uncommon to have multiple levels of tabs nested inside each other, alongside other, related, components), is easy:

export function useActiveTab(id, defaultTab) {
  const { state, setState } = usePersistedApplicationState(id)
  
  // For tab states, we expect there to be only one parameter.
  // After all, the whole point of tabs is that there is a *‌mutual exclusivity*!
  const searchParam = useMemo(() => Object.keys(state)[0], [state])
  const activeTab = useMemo(() => Object.values(state)[0] || defaultTab, [state])
  
  const onActiveTabChange = useCallback((newTab) => {
    setState({ [searchParam]: newTab })
  }, [setState])
  
  return { activeTab, onActiveTabChange }
}

Then, you can consume this hook in your tab component of choice:

function ComponentWithTab() {
  const { activeTab, onActiveTabChange } = useActiveTab(id, defaultTab)
  
  return (
    <Tabs tab={activeTab} onChange={onActiveTabChange}>
      <TabHeaders>
        <TabHeader value="foo">Tab 1</TabHeader>
        <TabHeader value="bar">Tab 2</TabHeader>
      </TabHeaders>
      
      <TabBody>
        <Tab value="foo">
          <p>Tab 1 is currently active.</p>
        </Tab>
        <Tab value="bar">
          <p>Tab 2 is currently active.</p>
        </Tab>
      </TabBody>
    </Tabs>
  )
}

You can see how much of a lifesaver this would be if you have complicated state and navigation embedded within the tabs (because you no longer have to worry about cleaning up after their search params when you click a different tab).

And what about multi-state components, such as a bunch of dropdowns or buttons or forms you need to fill out to display a result (and update the URL so you can directly link to it)? With this abstraction, you no longer need to worry about “coordinating” their state (or their updates):

export function MultiStateComponent() {
  const { state, setState } = usePersistedApplicationState(id)
  
  const [dropdownState, setDropdownState] = useState(state.dropdown_param)
  const [buttonState, setButtonState] = useState(state.button_param)
  const [formState, setFormState] = useState(state.form_param)
  
  // Instead of persisting the three states separately into the URL,
  // we want to make sure it is only persisted into the URL when the *combination* of states is correct.
  useEffect(() => {
    if (isValid(dropdownState, buttonState, formState)) {
      // Set all three states at once
      setState({
        dropdown_param: dropdownState,
        button_param: buttonState,
        form_param: formState,
      })
    } else {
      // Clear all three states from the URL at once
      setState({
        dropdown_param: undefined,
        button_param: undefined,
        form_param: undefined,
      })
    }
  }, [dropdownState, buttonState, formState])
}

Conclusion

All of this might seem like an overkill, and it probably is for simple apps/static websites, where each page is relatively simple, and all you need is routing between pages.

But once you start building complex apps (especially enterprise apps, dear god), more and more stuff starts getting displayed in a single page, and in-page state management and navigation becomes more and more important (and harder to do without screwing up something else in the page’s component tree).

And that’s what this type of abstraction is for: it lets you basically tune out everything that’s happening outside of whatever you’re writing - the URLs, the querystring, the search params, the in-page navigation between components, the parent components, the child components, etc.

With it, you can build complex applications more easily, without being weighed down by others’ complexity - one “self-contained” bit of logic/view/component at a time.