Things are different when building a progressive web app compared to a regular website because you have to treat every part of it like a native app.
I have built several progressive web apps, and the most important thing to take care of is the mobile navigation. Once a user installs your PWA (hopefully), they expect it to behave exactly like an app.
Have you ever visited a website on a mobile device, then clicked something that triggered a modal, but the moment you pressed or swiped back, the entire site closed?
Yes! Not even the ChatGPT website handles mobile navigation properly. After clicking the login button, a dialog appears. The pattern for closing a dialog on mobile is by pressing or swiping back, but that wasn’t considered because you were expected to click on the overlay or the close button.
Compared to this progressive web app that handles the navigation properly - Pluxscore

We’ve been building the web with so much focus on mobile responsiveness while ignoring mobile navigation.
Mobile users (especially Android users) are used to swiping back to close modals/dialogs, navigate to another page, or interact with the website/PWA in different cases.
It was during the development of Pluxscore, the first progressive web app I worked on, that I encountered this problem.
In plain JavaScript, when a user swipes back on mobile, the current location is removed from the history stack. The solution is to control UI elements by storing their visibility state in a new navigation.
This means when a user interacts with a dialog trigger, we navigate to the same URL but with a different state.
I had two options for this: either by using the history.state API or URLSearchParams.
https://developer.mozilla.org/en-US/docs/Web/API/History/pushState#examples
/* When using history.state */
function openSidebar(){
const state = { open_sidebar: true };
history.pushState(state, "");
}
/* When using URLSearchParams */
function openSidebar(){
const url = new URL(location);
url.searchParams.set("open_sidebar", "true");
history.pushState({}, "", url);
}
I tried URLSearchParams at first, but it makes the URL look like this:
https://example.com?open_sidebar=true
Using history.state is much better, as the URL doesn’t change and you can store serializable values in it.
Because I use React for my progressive web apps, I have to control this state through React Router.
A basic example of that would look like this:
import { useLocation, useNavigate } from "react-router";
import { useCallback } from "react";
export default function App() {
const navigate = useNavigate();
const location = useLocation();
/** Track sidebar state */
const isSidebarOpened = location.state?.isSidebarOpened || false;
/* Open sidebar */
const openSidebar = useCallback(() => {
navigate(location, {
state: {
...location.state,
isSidebarOpened: true,
},
});
}, [location, navigate]);
/* Close sidebar */
const closeSidebar = useCallback(() => {
navigate(-1);
}, [navigate]);
return (
<>
<button onClick={openSidebar}>Open sidebar</button>
{isSidebarOpened ? (
<div>
{/* Sidebar content */}
<button onClick={closeSidebar}>Close sidebar</button>
</div>
) : null}
</>
);
}
But in a complex UI, there are usually more elements that trigger a modal/dialog within a single page, e.g. a sidebar dialog, login dialog, post edit dialog, etc.
So I created two hooks specifically for this: useLocationState.ts and useLocationToggle.ts
useLocationState.ts
This hook provides an API similar to useState that allows one to push any serializable value into the history stack. It accepts a default value if the item doesn’t exist in the current location state.
Removing an item is done by simply navigating back or setting the value to undefined. In a scenario where the app was reloaded, it would navigate to the root.
//useLocationState.ts
import { useCallback, useMemo } from "react";
import { useLocation, useNavigate, type NavigateOptions } from "react-router";
export default function useLocationState<T>(
key: string,
defaultValue: T,
): [T, (value?: T, options?: NavigateOptions) => void] {
const navigate = useNavigate();
const location = useLocation();
/** Current value */
const value =
typeof location.state?.[key] !== "undefined"
? location.state?.[key]
: defaultValue;
/** Set value */
const setValue = useCallback(
(newValue?: T, options?: NavigateOptions) => {
if (typeof newValue !== "undefined") {
navigate(location, {
...options,
state: {
...location.state,
...options?.state,
[key]: newValue,
},
});
} else {
if (location.key !== "default") {
navigate(-1);
} else {
navigate("/", { ...options, replace: true });
}
}
},
[key, navigate, location],
);
return useMemo(() => [value, setValue], [value, setValue]);
}
useLocationToggle.ts
This is a wrapper around the useLocationState hook. It stores a boolean and provides a method to toggle the state.
//useLocationToggle.ts
import { useCallback, useMemo } from "react";
import { type NavigateOptions } from "react-router";
import useLocationState from "./useLocationState";
export default function useLocationToggle(
key: string,
): [boolean, (status: boolean, options?: NavigateOptions) => void] {
/** Current State */
const [show, setShow] = useLocationState(key, false);
/** Toggle Location */
const toggle = useCallback(
(status: boolean, options?: NavigateOptions) => {
if (status) {
setShow(true, options);
} else {
setShow(undefined);
}
},
[key, setShow],
);
return useMemo(() => [show, toggle], [show, toggle]);
}
Putting it in use, we will have something similar to useState(), which makes it easier to replace existing code that uses useState().
export default function App(){
const [opened, setOpened] = useLocationToggle("homepage-sidebar");
return (
<>
<button onClick={()=>setOpened(true)}>Open sidebar</button>
{isSidebarOpened ? (
<div>
{/* Sidebar content */}
<button onClick={()=>setOpened(false)}>Close sidebar</button>
</div>
) : null}
</>
);
}
In the demonstration above, the sidebar is controlled by its own state in the location, the same applies to the matches displayed. We can have a state that looks like this:
/* Sidebar */
{
sidebar: true,
}
/* Match details */
{
['match-1234-details']: true,
}
Ever since then, I started implementing this across all the progressive web apps I build.
As the creator of PWABucket, I believe we can build progressive web apps that are fast, responsive, non-distinguishable from native apps and have good UX across platforms.
Same implementation can be seen in Parcel: (https://parcel.pwabucket.com).
Another example shown in the Tracker PWA: (https://tracker.pwabucket.com/)
The modern web has a lot of rich APIs that, if you build a progressive web app really well and pay extreme attention to detail, you can create truly engaging apps.
I build pixel-perfect progressive web apps. If you’re looking for someone to bring your app ideas to life, I’m currently open for work. Hire me: https://sadiqsalau.com.



Top comments (0)