What are deep links and why are they useful?
Broadly speaking, Deep Links are a mobile app platform (Android/iOS) concept which allows users to get to a specific in-app location following a hyperlink. When this idea is generalized back to single-page applications (SPAs) on the web, it refers to using a URL enriched with additional information to guide a user to a specific state of the SPA.
It allows users to return to the exact spot they were at, even after closing the app. Users can also share deep links with others to reproduce what the original user sees or refers to.
So we need to persist the UI state in the URL. What states should be persisted in the URL? There is a limitation on how many characters you can put in the URL. Different browsers and server-ware enforce different limits. To be on the safe side, one should not design a URL that exceeds 2048 bytes in length.
We cannot encode every state info a SPA uses into the URL. However, the user interactions applied to the app are in a controllable size; we can encode interaction-related information into the URL, hoping to recover the application state from the point when the user has already initiated the same interactions.
Let's look at two examples:
In Sematic, when a user selects a nested run, we hope to share what the user sees with someone else through a deep link. So when other users open the website using the deep link, they will see the same nested run being selected.
The deep link has a form of
https://sematic.host/pipelines/pipeline_name/[root_run_id]#run=[nested_run_id]
Separately, when the user selects a view-specific tab on the run details page, we also hope the deep link reflects this selection.
For this purpose, we add the tab=tab_name
hash segment into the URL with the following form.
https://sematic.host/pipelines/pipeline_name/[root_run_id]#run=[nested_run_id]&tab=[tab]
For example:
https://sematic.host/pipelines/pipeline_name/864ae3#run=33d7f&tab=output
Why use hashes instead of paths or query strings?
The other alternatives are to encode the information using query strings or as a part of the URL path segments.
URL paths are reserved for React Router usage. We want to keep the deep linking and React Router operations separate for the sake of separating concerns. This way, we simplify React Router's work and avoid potential re-routing due to path changes.
We didn't choose query strings because of a narrow interpretation of the differences between a query string and a hash:
A hash works as an anchor on the webpage; conventionally, changing the URL hash should not trigger UI mutations. However, a navigational shift, like scrolling to a designated location, is typical for hash changes. Turning to a specific page of a business entity fits the semantics of hash changes. So this becomes one reason to adopt hash for deep linking.
Another reason for choosing hash is because the Jotai library we adopted directly supports an easy API for manipulating and syncing with URL hash, which we will elaborate on next.
Thirdly, hash fragments are not sent to the server. The browsers' side is usually more generous on the URL length limitation than the server applications. We have more room to store the deep link information on the hash fragments if we need to.
Meanwhile, we also reserve the query strings to store feature flags, view settings, debug options, etc.
It is also worth noting that choosing query parameters to store deep link information is also reasonable. For example, the Recoil library supports both.
To summarize, in Sematic, we assign the following distinct responsibilities to different URL components:
- Path: reserved for routing
- Query string parameters: feature flags and view settings.
- Hash: deep links
The Jotai and Recoil library
Before talking about Jotai, let us first introduce Recoil, from which the Jotai library gets a lot of inspiration.
An Atom from Recoil is a unit of a state that is declared outside of the React component hierarchy. Different React components can subscribe to the changes of such an atom and can mutate the atom's state. The mutation of the atom will be propagated to each subscribing component and trigger its re-rendering. Using a Recoil atom is a way of sharing a global state across multiple components regardless of their locations in the component tree.
How does this compare to React Context?
React Context API is more verbose in injecting context providers in the ancestry. It sometimes causes unnecessary re-renderings. One way to avoid those is to disintegrate the state groups in smaller, dispersed contexts. But this might also lead to pyramids of doom when writing too many nested provider declarations.
<context1.Provider value={value1}>
<context2.Provider value={value2}>
<context3.Provider value={value3}>
<context4.Provider value={value4}>
<context5.Provider value={value5}>
</context5.Provider>
</context4.Provider>
</context3.Provider>
</context2.Provider>
</context1.Provider>
An Atom is more lightweight. It can be declared individually. As its name suggests, it is atomic.
Example:
const todoListState = atom({
key: 'TodoList',
default: [],
});
function TodoList() {
const todoList = useRecoilValue(todoListState);
return (
<>
{/* <TodoListStats /> */}
{/* <TodoListFilters /> */}
<TodoItemCreator />
{todoList.map((todoItem) => (
<TodoItem key={todoItem.id} item={todoItem} />
))}
</>
);
}
Jotai is another library that shares a lot of features with Recoil. This article will focus on one particular hook atomWithHash()
in the jota-location integration, which allows us to manipulate URL hash segments and subscribe to their changes directly.
Example:
import { useAtom } from 'jotai'
import { atomWithHash } from 'jotai-location'
const countAtom = atomWithHash('count', 1)
const Counter = () => {
const [count, setCount] = useAtom(countAtom)
return (
<div>
<div>count: {count}</div>
<button onClick={() => setCount((c) => c + 1)}>+1</button>
</div>
)
}
This useAtom
hook returns the current value of a hash segment and a state updater function. The hook consumers will be notified of the updated value in a new rendering cycle whenever the hash segment changes. The state updater function updates the atom's value and the URL's corresponding hash fragment.
In Sematic, we think having a global state utility using the atom mechanism is applicable. Jotai is more streamlined with updated ergonomic APIs. We have adopted Jotai in the front-end application of Sematic.
Implementation of deep links
With the help of jotai
, implementing deep-link becomes easy. For instance, when we need to generate a deep link of the selected nested run, we store the selected run ID with a Jotai hash atom. So whenever the atom for storing the selected run ID is changed, the corresponding hash linked to the atom will be automatically updated in the URL. The user will directly pick the updated URL in the browser address bar as the new deep link.
When a user opens a deep link in a new browser tab, the initial state of the atom will be hydrated with the value from the URL hash segment. So the consumer components will retrieve the selected run ID represented by the hash in the deep link URL, then drive the successive rendering.
During the implementation, we also learned about the following caveats, which might be interesting to the readers.
Serialization
atomWithHash()
has a config option to support serializing the state value into the hash string and vice versa. By default, it uses JSON serialization and deserialization. If the atom's value type is string, it will become "value" (quotes included) in the hash string because a JSONified string value has quote marks.
A straight string-to-string conversion will eliminate the quote marks.
With default serialization, we have:
https://sematic.host/pipelines/pipeline_name/864ae3#tab="output"
After using custom serialization/deserialization functions, we have:
https://sematic.host/pipelines/pipeline_name/864ae3#tab=output
Hash change with unwanted intermediate states
After the Jotai takes over certain hash segments atoms, for most cases, one should not touch the hash in the URL directly instead of using the atom updater function to drive the hash change. There are, however, some edge cases when you have to manually change some hash segments when you want to update the URL path component simultaneously. This can happen when you want to navigate to a new page with an alternative hash value. If you use the updater function to change the hash, followed by a URL path change, you get two records in the browser history stack. The problem will surface when a user uses the browser's back button to navigate back, which will lead the user to an invalid intermediate page state.
Analyzing Jotai's source code leads us to a solution. Jotai uses URLSearchParams
to manipulate the hash portion of the URL. This approach can change only a specific sub-component of the hash portion instead of rewriting the entire hash string. When we do page navigation, we will likely only need to change one hash sub-component while keeping other sub-components in the deep link untouched. After understanding its principle, whenever you have to do a path change with a hash change. Do the following:
- Grab the current hash component of the URL
- Utilize
URLSearchParams
to replace the value of a specific hash chip in the entire hash string. - Take the revised hash component by reading
URLSearchParams
again (.toString()
). Combine it with the new path value and update the URL in an atomic operation.
Jotai's atomWithHash
supports an option replaceState
, which controls whether hash updates will create new browser history records. Unfortunately, it is an atom-level configuration. It would perfectly solve the issue above if it could be specified on a per-update basis.
How do you implement deep linking in your project?
Reach out to us on Discord to discuss deep links or other topics.
Top comments (3)
Great article, you got my follow, keep writing!
Nice article! Thank you for sharing.