Skip to content

feat: DH-21654: Routing hooks#1319

Open
jnumainville wants to merge 6 commits intodeephaven:mainfrom
jnumainville:21654_query_params_hook
Open

feat: DH-21654: Routing hooks#1319
jnumainville wants to merge 6 commits intodeephaven:mainfrom
jnumainville:21654_query_params_hook

Conversation

@jnumainville
Copy link
Copy Markdown
Collaborator

Add basic SPA routing related hooks
The plan contains specs for hooks as well as some more advanced routing behavior. Only hooks will be implemented in this PR. The other behaviors will be implemented in DH-21898.
A router (like the one proposed in #362) is beyond the scope of either of these tickets, but could be built on top of them in the future. This also means we don't have a way to pull out path params automatically, but the data is there.

@jnumainville jnumainville self-assigned this Mar 18, 2026
Comment on lines +16 to +17
| `use_search_params` | Hook | Returns all search params as a dictionary |
| `use_search_param` | Hook | Returns a single search param by key |
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems redundant. just returns all params seems fine to me, extra wrapper seems pointless, harder to grow from one param to multiple.

Copy link
Copy Markdown
Collaborator Author

@jnumainville jnumainville Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not dead set on it, but there are some reasons I added the second hook.
First of all, it helps avoid a problem with one vs multiple values for the same key. There's no great solution here, but I really want to avoid situations such as:

  1. Always having to grab the 0th element of a list (if only a list is the value, as is now)
  2. Having to always check if you return a list or a string (if we make return value dependent on how many values there are, which I considered while iterating)

It's worth noting that URLSearchParams has convenient getters (get and getAll) so I'm trying to emulate that in a natural way without setting up a whole class, which seems like overkill.
It was also specifically asked for in this form in the original request.
Now, this has gotten me thinking that I could just add the arguments into the use_search_params hook. I think I like that more, and does at least force the explicitness of the two. It might be jamming too much into one hook though.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think also important to note that Python language semantics are a bit different than JS. Notably, getting from an array with an index out of bounds (e.g. [][0], fetching the first element of an empty array) simply returns undefined, whereas Python raises an IndexError.

Also, are we going to have a tuple returned with a setter from the use_search_params? Like Router: https://reactrouter.com/api/hooks/useSearchParams

So say you care about the parameter q, which you're going to use as the search query string. That's pretty common, and you don't really care about other parameters or multiple parameters of q. To write that just with use_search_params you would have to write something like:

params, set_params = use_search_params()
q = params.get("q", [""])[0]
# vs
q = use_search_param("q", "")

That's not tooooo bad. But what if you care about a param named foo, you just want the first value, but you want it to be None if it's unset (vs. including it in the query string)?

params, set_params = use_search_params()
q = params.get("q")[0] if params.has("q") else None
# vs
q = use_search_param("q")

Note in JS, the seconds case I described there where you'd want undefined, it'd be:

[params, setParams] = useSearchParams();
q = params.get("q")

So yes, the hook is a bit redundant. But also, I could see the value of a use_search_param hook.

(Side note, kind of funny they don't actually show getting the params from the URLSearchParams on https://reactrouter.com/api/hooks/useSearchParams).

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a setter to the hooks, so I think keeping the hooks separate is best to prevent overcomplicating the hook.

Comment on lines +164 to +170
```python
def navigate(
pathname: str | None = None,
search_params: str | SearchParams | None = None,
fragment: str | None = None,
full: bool = False,
) -> None:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't feel like it fits in. Like it feels like a component but is very different to a component.

Maybe the rest of the ui.router spec needs to be contemplated to see if there's a better spot for this behaviour, as in next.js this lives under the router.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if we need to go full router yet.
We could go with useNavigate hook which is less awkward:
https://reactrouter.com/api/hooks/useNavigate
This as is is kinda like redirect (https://reactrouter.com/api/utils/redirect) but with (I think) a better name.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this would be handled similar to triggering ui.toast... I don't particularly care if it's useNavigate or redirect, up to y'all.

I will note that with the useNavigate hook, you can just call navigate("/path?foo=bar"), which isn't clear from here; you could say that path can also include the query params (since it's the first arg), but then what if you do navigate(pathname="/path?foo=bar", search_params="foo=biz")? Throw? Override? Merge? Some clarity there.

Copy link
Copy Markdown
Collaborator Author

@jnumainville jnumainville Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Made the return value a tuple so switched to a hook.
Added clarification for conflicting path and search_params, etc. I think override is simplest and clearest. Merge is weird because of repeat hook possibilities and throw seems unnecessary vs the clear note.

pathname: str # The path to navigate to (`"/path"`)
search_params: str | SearchParams # Query string (`"?foo=bar"`) or SearchParams dict
fragment: str # URL fragment, e.g. `"section"` (leading `#` optional)
full: bool # If True, navigate to full path instead of relative path to this widget
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Full seems like something humans and AI are going mess up setting correctly.

Copy link
Copy Markdown
Collaborator Author

@jnumainville jnumainville Mar 19, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I don't love the name. I'll try to think of something better. It's hard to get a good name that isn't overloaded already.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link has relative: https://reactrouter.com/api/components/Link#relative
absolute would be fine too I think

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to absolute

mofojed pushed a commit that referenced this pull request Mar 19, 2026
There are a few type issues failing, found in #1319

```python
@overload
def use_search_param(key: str, default: str = "") -> str:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default should be None, e.g. it returns None if no query parameter is set. If it's set (e.g. /myPath?foo), that should return the empty string ''.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Switched to None default. Good catch with the empty set value.

### API

```python
def use_search_params() -> dict[str, list[str]]:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not planning on returning a setter like https://reactrouter.com/api/hooks/useSearchParams does?

Copy link
Copy Markdown
Collaborator Author

@jnumainville jnumainville Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an interesting point. It does it a little more complex in terms of the implementation plan as this is almost a navigate, with things held the same except for search params. But I can definitely see the value. I think I want to consider this for all hooks. It would even then make sense to move navigate into a hook if we did the inverse, add a tuple with all the current data to a tuple.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general I've switched to tuples with value, setters. The use_url_components is the exception. I think it would be weird to have a setter there because we'd have to ignore some of the provided values (scheme and netloc) so it seems like it should be more informational for that one.

Comment on lines +16 to +17
| `use_search_params` | Hook | Returns all search params as a dictionary |
| `use_search_param` | Hook | Returns a single search param by key |
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think also important to note that Python language semantics are a bit different than JS. Notably, getting from an array with an index out of bounds (e.g. [][0], fetching the first element of an empty array) simply returns undefined, whereas Python raises an IndexError.

Also, are we going to have a tuple returned with a setter from the use_search_params? Like Router: https://reactrouter.com/api/hooks/useSearchParams

So say you care about the parameter q, which you're going to use as the search query string. That's pretty common, and you don't really care about other parameters or multiple parameters of q. To write that just with use_search_params you would have to write something like:

params, set_params = use_search_params()
q = params.get("q", [""])[0]
# vs
q = use_search_param("q", "")

That's not tooooo bad. But what if you care about a param named foo, you just want the first value, but you want it to be None if it's unset (vs. including it in the query string)?

params, set_params = use_search_params()
q = params.get("q")[0] if params.has("q") else None
# vs
q = use_search_param("q")

Note in JS, the seconds case I described there where you'd want undefined, it'd be:

[params, setParams] = useSearchParams();
q = params.get("q")

So yes, the hook is a bit redundant. But also, I could see the value of a use_search_param hook.

(Side note, kind of funny they don't actually show getting the params from the URLSearchParams on https://reactrouter.com/api/hooks/useSearchParams).


| Feature | Type | Description |
| ------------------- | --------------------- | ----------------------------------------- |
| `use_pathname` | Hook | Returns current pathname |
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In ReactRouter they don't have a usePath*, they just have useResolvedPath and useLocation. Location doesn't really exist in Python...
In general we probably should use urlsplit in Python (urlparse says to use urlsplit)...
In those tuples that are returned, they just call the path path, not pathname.
All that to say, I think this should just be use_path.

And maybe we need something similar to useLocation that just returns the SplitResult... use_split_url ? use_url_components?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All the different structures react router allows for really makes things complicated, there is no one source of truth... for reference, I got pathname from the Path object here, and that specific parameter is what I am going for here. I don't ming the rename given the python context though. I'll add a split result hook too.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed and added the new use_url_components hook.

Comment on lines +164 to +170
```python
def navigate(
pathname: str | None = None,
search_params: str | SearchParams | None = None,
fragment: str | None = None,
full: bool = False,
) -> None:
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me this would be handled similar to triggering ui.toast... I don't particularly care if it's useNavigate or redirect, up to y'all.

I will note that with the useNavigate hook, you can just call navigate("/path?foo=bar"), which isn't clear from here; you could say that path can also include the query params (since it's the first arg), but then what if you do navigate(pathname="/path?foo=bar", search_params="foo=biz")? Throw? Override? Merge? Some clarity there.

pathname: str # The path to navigate to (`"/path"`)
search_params: str | SearchParams # Query string (`"?foo=bar"`) or SearchParams dict
fragment: str # URL fragment, e.g. `"section"` (leading `#` optional)
full: bool # If True, navigate to full path instead of relative path to this widget
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Link has relative: https://reactrouter.com/api/components/Link#relative
absolute would be fine too I think

Copy link
Copy Markdown
Member

@mofojed mofojed left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the biggest hangup for me is whether the hooks should return setters or not. It seems different frameworks do different things? Can you take a look at a few different frameworks and briefly summarize how each does it? React-router, next.js router, tanstack router I think were the paradigms we were looking at.
I had just looked at React-router, but honestly I don't reallllly like having a setter in each one, and then you could have people call like set_path, then set_query_params instead of encouraging them to just do one call like set_url or navigate or whatever. And then there's a bunch of read-only hooks and only one navigation/way to change the URL.

Unsure if @dsmmcken has a pref there. Or what's your pref @jnumainville

@jnumainville
Copy link
Copy Markdown
Collaborator Author

jnumainville commented Mar 23, 2026

I personally like the getter/setter situation because of locality, allowing you to focus on what you want.
It's not a huge amount of overhead to manage for us. I'm just wrapping the navigate function in the current implementation for some of these hooks. You could argue that means we shouldn't do it, because it is somewhat easy to do, but then again it reduces boilerplate, and it's very easy to ignore the setter.
The setter for use_search_param is more complicated, but also the most useful I think.
Looking at the case you propose from a different lens, we're forcing someone to use the use_navigate hook when all they need to do is update a search param, for example.
Here is what the comparative libraries do:

Hook react-router returns next.js returns TanStack returns
params object containing matching path params object containing dynamic params object containing matching path params
search params tuple of URLSearchParams and setter read-only URLSearchParams object of the search query parameters
pathname/location location object (similar to use_url_components) pathname string location object (similar to use_url_components)

So, in summary, only react-router does the getter and setter and only for search params.
But, I'm not sure that we should be basing this solely on what these existing frameworks do. These are routing libraries built for general routing use cases. So, I think it's worth weighting more heavily how to simplify common workflows. That's what the setters with the proposed hooks do.
Now, if we think that the most common use case (by far) is just going to be linking to a widget (and to be fair, that's what the original ticket asks for) then it's probably not worth providing the setters. But, if it's going to be fairly common to also interact with the component in a way that needs to change the url, then bundling the setters with the hooks is helpful.
I am conjecturing here, but I like the setters because I think it will be quite common to interact with them. I'm thinking things like a dropdown where I select a symbol to filter on, see something interesting in my dashboard, then can easily share the link with a coworker. This can be done either way, but building that sort of workflow into a dashboard is optimized for when we present the setter with the hook return value.

@dsmmcken
Copy link
Copy Markdown
Contributor

My preference is closer to next.js. I do not care for the setters at all, that part belongs under ui.router to me, there's a difference between pushing and changing the url. This why I only care about this spec in full context, and it needs to be constructed as such.

@mofojed
Copy link
Copy Markdown
Member

mofojed commented Mar 23, 2026

@jnumainville We need to flesh out a couple of real-world examples to build confidence we have the "right" API. In particular, in this plan, we don't have any "real" examples of what we're trying to achieve. We should imagine a few scenarios about how we may want to use routing (could probably get Claude to just come up with a few).

In the ticket, the customer is just asking for read. Please clarify with Paul if we can get any more details from the customer, specifically if they'll want to change the URL as well from some controls.

@jnumainville
Copy link
Copy Markdown
Collaborator Author

jnumainville commented Mar 23, 2026

TanStack Router has push vs replace under NavigateOptions.
That is passed into the navigate function both from useNavigate and router.navigate
To me that seems much more appropriate than a top level function like next.js does it and easy enough to add to the spec if we want it.

@jnumainville
Copy link
Copy Markdown
Collaborator Author

jnumainville commented Mar 24, 2026

I had Claude generate a bunch of short representative snippets then determine which hook was better for each case. A nice way to prototype, and it’s conclusions seem reasonable per example.

The summary it generated:
use_path (read-only in every case): breadcrumbs, path-based routing dispatch, conditional banners, back button derivation, login redirect context, shareable link construction, strategy identity display, full URL debug panel.
use_search_params (read-only in every case): building a multi-filter table query, snapshotting current state for bookmarking, serializing a shareable URL, full URL debug panel.
use_search_param (tuple setter used in every case): ticker input, date picker, exchange dropdown, sort toggle, tab strip, row limit picker, asset class multi-select, theme toggle, page increment/decrement, status picker, and any single-filter widget that should compose independently of surrounding URL state.
use_navigate (write surface for all holistic changes): section navigation bars, login redirects, row-click detail drill-down, preset filter application, bulk search param reset, fragment-only scroll navigation, form submit navigation, back button.

I can flesh out bigger examples (or show the examples it generated here, but there is 25) if needed, but that does seem to cover a lot of ground, and the findings were pretty definite. Tuple with setter for use_search_param and the read only for the other hooks. The key theme I noticed in the examples is that use_search_param with the setter can hook nicely into simple events (on_press, on_change, etc.). Additionally, the use_search_param hook adds the most functionality that doesn't overlap with use_navigate. So I could be content with only that one returning a setter. Of course, if the customer isn't interested in the setter in that case even maybe we shouldn't bother, but it would still seem like a missed opportunity to simplify simple use cases in general.

Now to just solidify my thoughts on the routing concerns, I do generally like TanStack router’s approach. We can provide a push/replace param to the use_navigate hook. We'd probably want it in use_search_param as an option too so I could tweak the types there to minimize maintenance. The hook isn’t mutually exclusive with router.navigate in that case, so we don’t need an immediate solution there.

@dsmmcken
Copy link
Copy Markdown
Contributor

Here is what I want to be able to do:

Think about building interconnecting dashboards, and how to make that work nicely across the same and different PQs.

I have a pq with 2 or 3 dashboards exported for it. I want to be able to define routes that point to dashboard.

I want to have pretty-urls, where there's segments that can be used from from it, next I believe uses :segment to denote it.

I want urls to be easily constructable, probably a new route for /pq//

Then user can do something like:
/pq//<ui.router to ui.dashboard>/:exchange/:sym

and have code that does something like

@ui.component
def my_dashboard
exchange = some hook
sym = some hook

dash that uses those nice paths.

ui.table(
on_click ( go to some other dash for same or different pq with whatever params)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants