🔥

createPortalでToastを作ろう

5 min read

はじめに

ReactのcreatePortalという機能でモーダルを出すという記事を見かけたので、position:Fixedなどで一時的に表示するものは共通化して運用出来るのではないかと考え、実装してみました。

出来たもの

完成品がこちらです。create-react-appのテンプレートの上に作成しました。

仕組み

Toastは同時に表示されることがないため、キュー管理をします。
contextとuseStateで順番に表示したい要素のIDを配列として管理し、1番目のIDに一致する要素をcreatePortalでアプリケーションの外側に表示させます。

contextでのキュー管理

不要な再レンダリングを避けるため、キューのstateとdispachを分けてProviderに渡しておきます。

export const ContentsQueueContext = createContext<string[]>([]);
export const ContentsQueueControllerContext = createContext<
  Dispatch<SetStateAction<string[]>>
>(() => {
  // please override me
});

export const ContentsQueueProvider: FC = ({ children }) => {
  const [queue, setQueue] = useState<string[]>([]);

  return (
    <ContentsQueueContext.Provider value={queue}>
      <ContentsQueueControllerContext.Provider value={setQueue}>
        {children}
      </ContentsQueueControllerContext.Provider>
    </ContentsQueueContext.Provider>
  );
};

キュー管理用のhooksの作成

Providerから渡ってきたdispacherはそのままでは扱い辛いため、contextの取得とロジックをまとめたhooksを作成します。

export const useOutsideContentsQueue = () => useContext(ContentsQueueContext);

type QueueController = {
  push: (queueId: string) => void;
  unshift: (queueId: string) => void;
  shift: () => void;
};
export const useOutsideContentsQueueController = () => {
  const setQueue = useContext(ContentsQueueControllerContext);
  const controller = useMemo<QueueController>(
    () => ({
      push: (queueId) => {
        setQueue((prev) => [...prev, queueId]);
      },
      unshift: (queueId) => {
        setQueue((prev) => [queueId, ...prev]);
      },
      shift: () => {
        setQueue((prev) => [...prev.slice(1)]);
      },
    }),
    // eslint-disable-next-line react-hooks/exhaustive-deps
    []
  );
  return controller;
};

キューの1番目と一致する要素のみcreatePortalで表示

やっていることは単純で任意のqueueIdをpropsに取り、contextに管理されているqueueの1番目のIDと一致したらcreatePortalでアプリケーションの外側に表示したい要素を送り込みます。
コンポーネントを分けることで、useEffectでdivを追加するcomponentをrenderしないようにしてます。

export const OutsideContent: FC<{ queueId: string }> = ({
  queueId,
  children,
}) => {
  const [qurrentQueue] = useOutsideContentsQueue();

  return queueId === qurrentQueue ? (
    <OutsideContentPortal>{children}</OutsideContentPortal>
  ) : null;
};

const OutsideContentPortal: FC = ({ children }) => {
  const containerElRef = useRef(document.createElement("div"));
  useEffect(() => {
    const containerEl = containerElRef.current;
    document.body.appendChild(containerEl);
    return () => {
      containerEl.remove();
    };
  }, []);

  return createPortal(children, containerElRef.current);
};

テンプレート側でqueueIdを指定したコンポーネントを用意し、そのIDをキューに追加する

こちらが使用例です。
今回の例ではToastRegister内で作ったtoasts配列と同じ個数だけキューに追加し表示しています。
toastにはanimationEndでキューをshiftするようにコールバックを指定しているので、表示が終わった際に次のキューに進みます。

export const ToastRegister: FC = () => {
  const { push, shift } = useOutsideContentsQueueController();
  const [toasts, setToasts] = useState<{ id: string; text: string }[]>([]);
  const [text, setText] = useState("");

  const pushToast = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const id = Math.random().toString(36).substring(2); // randomなidの作成
    push(id);
    setToasts((prev) => [...prev, { id, text }]);
    setText("");
  };

  const shiftToast = () => {
    shift();
    setToasts((prev) => [...prev.slice(1)]);
  };

  return (
    <>
      <form onSubmit={pushToast}>
        <input
          type="text"
          value={text}
          onChange={(e: ChangeEvent<HTMLInputElement>) =>
            setText(e.target.value)
          }
        />
        <button type="submit">push Toast</button>
      </form>
      {toasts.map(({ id, text }) => (
        <OutsideContent queueId={id} key={id}>
          <Toast text={text} onAnimationEnd={shiftToast} />
        </OutsideContent>
      ))}
    </>
  );
};

const Toast: FC<{ onAnimationEnd: () => void; text: string }> = ({
  onAnimationEnd,
  text,
}) => {
  return (
    <div className="toast" onAnimationEnd={onAnimationEnd}>
      {text}
    </div>
  );
};

あとがき

今回はToastを表示するように作りましたが、この仕組みはモーダルなどでも使えると思います。
初めはモーダルでサンプルを作ろうと考えたのですが、デザインが思いつかず今回は見送りました。トーストなら角を丸くするだけなんで楽なんです笑

この記事を書き始めた一番最初はキューに直接reactのコンポーネントを入れるという荒技をやっていました。
そんな実装しているの見たことないですよね。なんとなく嫌な予感が。。。

実際にやってみたところ表示することまでは出来たのですが、キューに追加した時点で操作不能なコンポーネントになるという残念な結果に陥り、IDだけをキュー管理する今の形に落ち着きました。

実装で悩んでいたところ下記のblogですでに同様のことをされており参考にさせていただきました。
大変感謝しております。
stateにコンポーネントを直接入れるのはダメという話も書いてあり納得でした。

https://hbsnow.dev/blog/react-portal-modal/