11

I want to make my web app works like a mobile app. This means when a user presses back, they expect pops to close, not entire pages to change.

My end goal is to make it so when a modal opens the Back button will now close the modal and if they click it again it will go back.

I've tried several methods and although close they never respond consistently. https://codesandbox.io/s/github/subwaymatch/react-disable-back-button-example-v2

Anyone with a PROVEN working version of what I'm looking for?

AmerllicA
  • 29,059
  • 15
  • 130
  • 154
notElonMusk
  • 314
  • 1
  • 4
  • 21
  • 1
    You can't just override a browser's button's behavior as mentioned here : https://stackoverflow.com/questions/24033040/override-browser-back-button. – Abe Caymo Mar 27 '20 at 02:13
  • Found something I think similar ? to what you're trying to achieve but it's on react native https://reactnavigation.org/docs/modal/ – Abe Caymo Mar 27 '20 at 02:16
  • @AbeCaymo ``` window.onpopstate = e => { ... } ``` My main issue is removing the event handler consistently. It does really remove in a unmount as it should – notElonMusk Mar 28 '20 at 08:36
  • I don't recommend you do this to the back button, users expect the back button to be "the previous page", your users probably won't think about clicking the browser's back button to close a modal !!! – niceman Mar 28 '20 at 19:10
  • But they could try ESC which you can hook to with a simple key down event – niceman Mar 28 '20 at 19:11
  • 1
    Maybe someone thinks, your question is not very important, but I believe this problem accrue several times in many projects. I leave an upvote for this awesome post. well done. – AmerllicA Mar 30 '20 at 02:57
  • I may be misunderstanding your question, what you are looking for is the default behavior of react-router-dom...except that the state does not get preserved. Are you actually looking to keep the state ? – Incepter Apr 01 '20 at 20:54
  • @MohamedELAYADI The state closes the popup. So yes. Basically like a mobile app – notElonMusk Apr 02 '20 at 02:59
  • I am not a mobile developer, but how do you presume the state got mounted again if you return to the popup ? just make the `open` prop depending from the url ... – Incepter Apr 02 '20 at 08:24

7 Answers7

13

Actually, I believe the back functionality is useful for user experience but for modal open/close you are right. the browsers back button should close the modal in both desktops and mobile devices. I offer you to write two helper functions, one for neutralize the browser back button then run your own functionality and one for revival the browser back button. use neutralizeBack function when a modal is opened and use revivalBack function when that opened modal is closed. using the second comes back to my attitude of user experience of a browser back button functionality.

  • The neutralizeBack should run a callback function. this callback function is what you want to do:

    const neutralizeBack = (callback) => {
      window.history.pushState(null, "", window.location.href);
      window.onpopstate = () => {
        window.history.pushState(null, "", window.location.href);
        callback();
      };
    };
    
  • The revivalBack should run when you wanna revival the browser back button functionality:

    const revivalBack = () => {
      window.onpopstate = undefined;
      window.history.back();
    };
    

An example of usage:

handleOpenModal = () =>
  this.setState(
    { modalOpen: true },
    () => neutralizeBack(this.handleCloseModal)
  );

handleCloseModal = () =>
  this.setState(
    { modalOpen: false },
    revivalBack
  );
AmerllicA
  • 29,059
  • 15
  • 130
  • 154
8

You can try to use hash in your URL. Hash is the URL segment starting with a hashtag. Navigating between hash usually didn't trigger any page load, but still, push an entry to browser history that enables the back button to close modal/popup.

// www.example.com#modal
window.location.hash // -> "#modal"

Your modal state of showing and hiding is based on the window.location.hash.

You can create a hook something like this (only for abstraction)

function useHashRouteToggle(modalHash) {
  const [isOpen, toggleOpen] = useState(false);

  const toggleActive = (open) => {
    if (open) {
      window.location.assign(modalHash); // navigate to same url but with the specified hash
    } else {
      window.location.replace('#'); // remove the hash
    }
  }

  useEffect(() => { 
    // function for handling hash change in browser, toggling modal open 
    const handleOnHashChange = () => {  
      const isHashMatch = window.location.hash === modalHash;   
      toggleOpen(isHashMatch);  
    };  

    // event listener for hashchange event
    window.addEventListener('hashchange', handleOnHashChange);  
    
    return () => window.removeEventListener('hashchange', handleOnHashChange);  
  }, [modalHash]);

  return [isActive, toggleActive];
} 

Then use it on your popup/modal.

const [isActive, toggleActive] = useHashRouteToggle('#modal');

const openModal = () => toggleActive(true);

<Modal isShow={isActive} />

This way, you can achieve your needs without modifying or overriding browser behavior. The codes above are just for an abstraction of what you can do. You can refine it to your needs. Hope it gives you some ideas.

Dimitrij Agal
  • 375
  • 1
  • 9
2
if (isOpen) {
  // push to history when modal opens
  window.history.pushState(null, '', window.location.href)
  
  // close modal on 'back'
  window.onpopstate = () => {
    window.onpopstate = () => {}
    window.history.back()
    setIsOpen(false)
  }
}

return <Modal open={isOpen} />
Florian Ludewig
  • 4,338
  • 11
  • 71
  • 137
1

Inorder for the back button to work on Modal close, you need to push a route when opening the modal and onclose you can use history.goBack(). May be this example can be helpful.

import React from "react";
import {
  BrowserRouter as Router,
  Switch,
  Route,
  Link,
  useHistory,
  useLocation,
  useParams
} from "react-router-dom";

export default function ModalGalleryExample() {
  return (
    <Router>
      <ModalSwitch />
    </Router>
  );
}

function ModalSwitch() {
  let location = useLocation();
  let background = location.state && location.state.background;
  return (
    <div>
      <Switch location={background || location}>
        <Route exact path="/" children={<Gallery />} />
        <Route path="/img/:id" children={<ImageView />} />
      </Switch>
      {background && <Route path="/img/:id" children={<Modal />} />}
    </div>
  );
}

const IMAGES = [
  { id: 0, title: "Dark Orchid", color: "DarkOrchid" },
  { id: 1, title: "Lime Green", color: "LimeGreen" },
  { id: 2, title: "Tomato", color: "Tomato" },
  { id: 3, title: "Seven Ate Nine", color: "#789" },
  { id: 4, title: "Crimson", color: "Crimson" }
];

function Thumbnail({ color }) {
  return (
    <div
      style={{
        width: 50,
        height: 50,
        background: color
      }}
    />
  );
}

function Image({ color }) {
  return (
    <div
      style={{
        width: "100%",
        height: 400,
        background: color
      }}
    />
  );
}

function Gallery() {
  let location = useLocation();

  return (
    <div>
      {IMAGES.map(i => (
        <Link
          key={i.id}
          to={{
            pathname: `/img/${i.id}`,
            // This is the trick! This link sets
            // the `background` in location state.
            state: { background: location }
          }}
        >
          <Thumbnail color={i.color} />
          <p>{i.title}</p>
        </Link>
      ))}
    </div>
  );
}

function ImageView() {
  let { id } = useParams();
  let image = IMAGES[parseInt(id, 10)];

  if (!image) return <div>Image not found</div>;

  return (
    <div>
      <h1>{image.title}</h1>
      <Image color={image.color} />
    </div>
  );
}

function Modal() {
  let history = useHistory();
  let { id } = useParams();
  let image = IMAGES[parseInt(id, 10)];

  if (!image) return null;

  let back = e => {
    e.stopPropagation();
    history.goBack();
  };

  return (
    <div
      onClick={back}
      style={{
        position: "absolute",
        top: 0,
        left: 0,
        bottom: 0,
        right: 0,
        background: "rgba(0, 0, 0, 0.15)"
      }}
    >
      <div
        className="modal"
        style={{
          position: "absolute",
          background: "#fff",
          top: 25,
          left: "10%",
          right: "10%",
          padding: 15,
          border: "2px solid #444"
        }}
      >
        <h1>{image.title}</h1>
        <Image color={image.color} />
        <button type="button" onClick={back}>
          Close
        </button>
      </div>
    </div>
  );
}

For reference please check react router modal gallery example

kv kishore
  • 57
  • 3
0

Here is my version of Dimitrij Agal answer , with actual working code and not just pseudocode. It uses "react-router-dom": "^6.0.0-beta.0"

import { useEffect, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";





export function useHashRouteToggle(hash) {

  const navigate = useNavigate();
  const location = useLocation();

  const [isActive, setIsActive] = useState(false);

  const toggleActive = (bool) => {
    if (bool !== isActive) {   // needed if there are multiple modals with close-on-esc-keyup in the same page
      if (bool) {
        navigate(location.pathname + "#" + hash)
      } else {
        navigate(-1);
      }
      setIsActive(bool);
    }
  }

  useEffect(() => { 
    const handleOnHashChange = () => {  
      setIsActive(false);
    };  

    window.addEventListener('hashchange', handleOnHashChange);  
    
    return () => window.removeEventListener('hashchange', handleOnHashChange);  
  });

  return [isActive, toggleActive];
} 

And you use it like this:

const [showModalDelete, setShowModalDelete] = useHashRouteToggle("delete")

// ...

<CoreModal
  isActive={showModalDelete}
  setIsActive={setShowModalDelete}
  title={t("deleteProduct")}
  content={modalContent}
/>

However there are at least 2 problems:

  • If the user uses the "go forward" button after closing the modal, he/she will have to press the "go back" button twice.
  • I tried to pass the initial state of the modal as a parameter, in case the programmer wants to initiate the modal as opened (isActive === true) but I couldn't make it work, althought i didn't explore that possibility much because all my modals are started as closed.

Any feedback will be appreciated

RamiroPastor
  • 1,085
  • 1
  • 7
  • 18
0

My version based on the answer by AmerllicA.

Basically, when you open the modal, you pushState (as if the modal is a different page) and then when you close the modal you pop it unless it was already popped by navigation.

onModalOpen() {
    window.history.pushState(null, '', window.location.href)
    window.onpopstate = () => {
        this.onModalClose(true)
    }
}

onModalClose(fromNavigation) {
    if(!fromNavigation)
        window.history.back()

    window.onpopstate = () => {
        // Do nothing
    }
}
0

I was tried to do exact same thing with modal open/close and make user open modal by forward button and close it by back button.

I see all answers but I think its better to do it with hook

This is a hook I end up with it.

When the modal state set to open, I replaces the current history state because popstate event give you state of the current page and it called after page loaded (see here), I also push a new state when modal opened,

So now we have 2 states in history, first one is closeModal and second one is openModal, now when the user changed the history we can know what we need to do (opening or closing the modal).

export function useModalHistory(
  id: string,
  isOpen: boolean,
  onChange: (open: boolean) => void,
) {
  useEffect(() => {
    if (id && isOpen) {
      // set new states to history when isOpen is true
      // but we need to check isOpen happened from `popstate` event or not
      // so we can prevent loop
      if (window.history.state?.openModal !== id) {
        window.history.replaceState({closeModal: id}, '');
        window.history.pushState({openModal: id}, '', window.location.href);
      }

      return () => {
        // only close modal if the closing is not from `popstate` event
        if (window.history.state?.closeModal !== id) window.history.back();
      };
    }
  }, [id, isOpen]);

  useEventListener('popstate', event => {
    if (event.state?.closeModal === id) {
      onChange(false);
    }
    if (event.state?.openModal === id) {
      onChange(true);
    }
  });
}

Also note I used useEventListener from https://usehooks-ts.com/react-hook/use-event-listener, you can either create your hook or use it from package.

If you use react-router you can write it like this


export function useModalHistory(
  id: string | undefined,
  isOpen: boolean,
  onChange: (open: boolean) => void,
) {
  const history = useHistory<{openModal?: string; closeModal?: string}>();

  useEffect(() => {
    if (id && isOpen) {
      if (history.location.state?.openModal !== id) {
        history.replace({state: {closeModal: id}});
        history.push({state: {openModal: id}});
      }
      return () => {
        if (history.location.state?.closeModal !== id) history.goBack();
      };
    }
  }, [id, isOpen, history]);

  useEventListener('popstate', event => {
    if (id) {
      if (event.state.state?.closeModal === id) {
        onChange(false);
      }
      if (event.state.state?.openModal === id) {
        onChange(true);
      }
    }
  });
}

Usage

const [isModalOpen, setIsModalOpen] = useState(false);
// be aware id need to be unique for each modal
useModalHistory('my_modal', isModalOpen, setIsModalOpen);
Ali Akbar Azizi
  • 3,272
  • 3
  • 25
  • 44