Skip to content

Instantly share code, notes, and snippets.

Embed
What would you like to do?

Предположим, у меня есть список айтемов, где у каждого, например, есть кнопка удаления. По нажатию на кнопку просто вызывается какая-то функция, которая работает с backend API.

function ListItem({ data }) {
  return (
    <li className="list-item">
      {/* ... */}
      <button
        type="button"
        onClick={() => {
          deleteItem(data.id);
        }}
      >
        Delete
      </button>
    </li>
  );
}

В жизни каждой кнопки Delete наступает момент когда нужно сделать так, чтобы пользователь не удалил что-то важное совершенно случайно. Самым простым решением будет использовать window.confirm():

onClick={() => {
  let confirmed = window.confirm('Are you sure?');
  if (confirmed) {
    deleteItem(data.id);
  }
}}

Хорошее начало, но, возможно, не финальное. Допустим, хорошим UX решением для проекта будет использовать кастомные диалоги, ради дизайна и, например, вменяемой кастомной кнопки подтверждения, которая вместо Ok будет говорить что именно произойдёт по нажатию.

В таком случае, для меня очевидным решением будет использовать @reach/alert-dialog: кастомный лэйаут, доступность, компонентный API.

Поскольку это компонентный API, сама кнопка удаления разростается в асинхронный флоу:

function ListItem({ data }) {
  let [deleting, setDeleting] = useState(false);
  return (
    <li className="list-item">
      {/* ... */}
      <button
        type="button"
        onClick={() => {
          setDeleting(true);
          deleteItem(data.id);
        }}
      >
        Delete
      </button>
      {deleting ? (
        <AlertDialog>
          <AlertDialogLabel>Удаляем</AlertDialogLabel>
          <AlertDialogDescription>Подумой перед удалением</AlertDialogDescription>
          <div class="alert-buttons">
            <button onClick={() => deleteItem(data.id)}>
              Yes yes, delete
            </button>
            <button onClick={() => setDeleting(false)}>
              No no, do not delete
            </button>
          </div>
        </AlertDialog>
      ) : null}
    </li>
  );
}

Что-то как-то много получилось. Наверно нужно просто алерт лэйаут вынести в отдельный компонент, чтобы переиспользовать можно было. Возможно. Но это не избавляет нас от появившегося асинхронного флоу: экш переходит от закреплённой кнопки в отдельный компонент, добавляется целая state variable и можно уже представить что будет в компоненте если понадобиться добавить ещё несколько подобных действий с подтверждением. Кроме того, нужно же ещё порефакторить остатки window.confirm() во всём проекте. Или, другим вариантом, можно вынести экшн в отдельный локальный компонент, где состояние, кнопка и алёрт будут объеденены. Идея вроде ок, но, опять же, создаёт определенные ожидания от будущего кода и не избавляет от появившегося флоу.

Не нравится мне такая идея и ожидаемый объём работы. Даже лень начинать.

В идеале, я хотел бы иметь тот же старый API в котором я просто вызывают функцию confirm() где угодно. Это практично, это не требует церемоний, это позволит проще перевести оставшиеся случаи в проекте на новый лэйаут.

Чтобы приблизиться к такой идее, скорее всего нужно делать непрямой рендер диалога. Я сделаю отдельный модуль, в котором, например, будет простой хук, через который я могу вызывать диалог с нужным мне контентом.

function ListItem({ data }) {
  let confirm = useConfirmDialog();
  return (
    <li className="list-item">
      {/* ... */}
      <button
        type="button"
        onClick={() => {
          let confirmed = await confirm('Are you sure?');
          if (confirmed) {
            deleteItem(data.id);
          }
        }}
      >
        Delete
      </button>
    </li>
  );
}

Выглядит ок, наверно. Поскольку это всё сделано как-то внутри дерева Реакта, значит что подобный API будет работать либо через контекст, либо через, прости господи, Редакс & Co. Использование контекста значит что где-то на верхних уровнях дерева приложения появляется целая обёртка:

function App() {
  return (
    <CustomContextA>
      <CustomContextB>
        <AlertDialogProvider>
          <StuffThatJohnDidntDelete>
            <LookMaWeGotRidOfRedux>
              <ApplicationContent />
            </LookMaWeGotRidOfRedux>
          </StuffThatJohnDidntDelete>
        </AlertDialogProvider>
      </CustomContextB>
    </CustomContextA>
  );
}

Использование хука ради получения функции и присутствие ещё одной обёртки контекста можно пережить, но мысль о том, что ради какого-то confirm() нужно прыгать по компонентам и втягивать API через хук мне всё ещё не улыбается. Скажешь что я совершенно ленивый? Мб. Скажешь что это вполне React way, скорее всего это правда. Но это всё ещё не такое практичное решение как window.confirm() и сама мысль об этом выедает меня изнутри.

Для подобной задачи я попробовал использовать CustomEvent. Можно представить window как шину событий, без каких либо библиотек, бутстрапов, прочего. Всё что нужно это window.dispatchEvent() и window.addEventListener().

Идея следующая: портал для реакта, только без createPortal, потому что рендерить нужно в то же дерево.

Делаем отдельный, изолированный модуль, в котором спрячем специальный компонент для рендера и весь желаемый API.

// confirm.js
function confirm(title, message, action) {
  return new Promise(resolve => {
    let remove = () => window.dispatchEvent(new CustomEvent('DialogDismiss'));
    let dialog = (
      <AlertDialog onDismiss={remove}>
        <AlertDialogLabel>{title}</AlertDialogLabel>
        <AlertDialogDescription>{message}</AlertDialogDescription>
        <div class="alert-buttons">
          <button onClick={() => resolve(true)}>
            {action}
          </button>
          <button onClick={() => resolve(false)}>
            Cancel
          </button>
        </div>
      </AlertDialog>
    );
    window.dispatchEvent(new CustomEvent('DialogRequest', { detail: dialog }));
  });
}

Спросишь что здесь происходит? UI это данные, как говорит Реакт, потому мы отправляем кусок данных в шину событий. Чем это лучше хука? Эту функцию можно вызвать в любом месте, а хуки всегда привязаны к фазе рендера.

Это конечно не финал, нужно добавить приёмочный компонент, который, собственно, и будет рендерить нужный диалог. Этот компонент будет использован примерно в том месте, где обычно в проекте собирается вышеупомянутое нагромождение контекстов, с единственной разницей, что этот компонент ничего не оборачивает.

// confirm.js
export function DialogPortal() {
  let [dialog, setDialog] = useState(null);

  useLayoutEffect(() => {
    function handleRequest(event) {
      setDialog(event.detail);
    }
    function handleDismiss() {
      setDialog(null);
    }
    window.addEventListener('DialogRequest', handleRequest);
    window.addEventListener('DialogDismiss', handleDismiss);
    return () => {
      window.removeEventListener('DialogRequest', handleRequest);
      window.removeEventListener('DialogDismiss', handleDismiss);
    };
  }, []);

  return dialog;
}

Что происходит дальше? Просто используется кастомный confirm(), так же как бы использовался window.confirm(). Теперь, он правда, асинхронный, но это уже совсем ерунда:

function ListItem({ data }) {
  return (
    <li className="list-item">
      {/* ... */}
      <button
        type="button"
        onClick={() => {
          let confirmed = await confirm('Are you sure?');
          if (confirmed) {
            deleteItem(data.id);
          }
        }}
      >
        Delete
      </button>
    </li>
  );
}

Подводя итоги:

  1. Получился API который не привязан к фазе рендера, что добавляет гибкости, особенно когда нужно эмулировать другие не-реактовые API.
  2. Очень важно прятать все dispatchEvent() и addEventListener() для одинаковых кастомных событий в изолированные модули — внешний мир не должен о них знать и как-то использовать.
  3. Не смотря на то что вышеупомянутый <DialogPortal /> кажется очень абстрактным и мог бы принимать всё что угодно, не стоит изобретать лишние абстракции, пусть всё будет конкретным, даже если использует подобные схемы управления.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment