Bad UX every website/PWA overlooks — the mobile navigation

Bad UX every website/PWA overlooks — the mobile navigation

posted Originally published at medium.com 4 min read

Building a progressive web app is different from building a regular website, because every part of it has to be treated like a native app.

I’ve built several progressive web apps, and the most important thing to get right is the mobile navigation. Once a user installs your PWA (hopefully), they expect it to behave exactly like an app.

Have you ever been on a website on a mobile device, tapped something that opened a modal, and then the moment you pressed or swiped back, the whole site just closed?

ChatGPT Site

Yes! Not even the ChatGPT website handles mobile navigation properly. After clicking the login button, a dialog appears. On mobile, the expected way to close a dialog is by pressing or swiping back, but that wasn’t considered, you’re expected to tap the overlay or the close button instead.

Compare that to this progressive web app, which handles navigation properly: Pluxscore.

Pluxscore

We’ve been building the web with a huge focus on mobile responsiveness, while quietly ignoring mobile navigation.

Mobile users, especially Android users, are used to swiping back to close modals or dialogs, navigate between pages, or interact with a website or PWA in different situations.

It was while building Pluxscore, the first progressive web app I worked on, that I ran into this problem.

In plain JavaScript, when a user swipes back on mobile, the current location is removed from the history stack. The fix is to control UI elements by storing their visibility state as part of navigation.

That means when a user triggers a dialog, we navigate to the same URL, but with a different state.

I had two ways to approach this: using the history.state API, or using 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, because the URL doesn’t change and you can store serializable values in it.

Since I use React for my progressive web apps, I have to manage 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 exposes an API similar to useState, allowing you 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 as simple as navigating back or setting the value to undefined. If the app is reloaded in this state, it falls back to navigating 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]);
}

When put into use, it behaves much like useState(), which makes it easy to replace existing code that already relies on useState().

export default function App(){
  const [opened, setOpened] = useLocationToggle("homepage-sidebar");
  return (
    <>
      <button onClick={()=>setOpened(true)}>Open sidebar</button>
      {opened ? (
        <div>
          {/* Sidebar content */}
          <button onClick={()=>setOpened(false)}>Close sidebar</button>
        </div>
      ) : null}
    </>
  );
}

Pluxscore

In the demonstration above, the sidebar is controlled by its own state in the location, and the same applies to the matches being displayed. We can end up with a state that looks like this:

/* Sidebar */
{
    sidebar: true,
}

/* Match details */
{
    ['match-1234-details']: true,
}

Ever since then, I’ve been 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, indistinguishable from native apps, and deliver a solid user experience across platforms.

You can see the same implementation in Parcel (https://parcel.pwabucket.com
).

Parcel PWA

Another example shown in the Tracker PWA (https://tracker.pwabucket.com/)

Tracker PWA

The modern web offers a ton of rich APIs, and if you build a progressive web app carefully and pay close attention to detail, you can create truly engaging experiences.

I specialize in 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.

1 Comment

2 votes

More Posts

TypeScript Complexity Has Finally Reached the Point of Total Absurdity

Karol Modelskiverified - Apr 23

Sovereign Intelligence: The Complete 25,000 Word Blueprint (Download)

Pocket Portfolioverified - Apr 1

I’m a Senior Dev and I’ve Forgotten How to Think Without a Prompt

Karol Modelskiverified - Mar 19

Just completed another large-scale WordPress migration — and the client left this

saqib_devmorph - Apr 7

5 Web Dev Pitfalls That Are Silently Killing Your Projects (With Real Fixes)

Dharanidharan - Mar 3
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

1 comment
1 comment
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!