🫧

Motion × グローバルステート管理(Context/Redux)でExitアニメーションがパッと消えた件

に公開

はじめに

こんにちは!PortalKeyの渋谷です。
今回はMotion(旧Framer Motion)についてです。
https://motion.dev/

Motionは簡単に言うとアニメーションが簡単につけられてすごい!というライブラリです。
今記事はMotionの詳しい使用方法等は省きます。

現在開発中のプロジェクトではReduxを用いて開発を行っています。
https://react-redux.js.org/

いい感じに機能が揃ってきたのでアニメーションを入れよう!という後づけの形でアニメーションを付ける際にMotionを使用したのですが、Exitアニメーションを設定してるのに「パッ」と消えて「???」となったので原因と改善策をまとめました。

Motionの説明

使用方法は省くとは言っても軽く説明はしたいと思います。
今回は簡単なサンプルを2つ用いて説明させていただきます。

■Visibleを切り替えてアニメーションを再生

普段使っている要素(div等)の前にmotion.を付けるだけであら不思議…
animateの中を切り替えるだけでサクッとアニメーションが実装できます。
initialはマウント時にアニメーションを行うかどうか。
transitionはアニメーションの時間などを設定できます。

import { motion } from "framer-motion"
import { useCallback, useState } from "react"

export const ToggleVisibleTest = () => {
  const [visible, setVisible] = useState(true)
  const toggleVisible = useCallback(() => {
    setVisible((prev) => !prev)
  }, [])

  return (
    <div className="w-96 p-2 flex flex-col items-center justify-center gap-2 bg-gray-50">
      <div className="w-full h-52 p-2 flex gap-1 items-center justify-center bg-white">
        <motion.div
          className="h-8 px-4 flex items-center justify-center rounded bg-gray-300 text-white"
          initial={false}
          animate={visible ? { opacity: 1 } : { opacity: 0 }}
          transition={{ duration: 0.3, ease: "easeInOut" }}
        >
          Test
        </motion.div>
      </div>
      <button className="w-full h-8 bg-gray-100" onClick={toggleVisible}>
        Toggle
      </button>
    </div>
  )
}

■マウント<->アンマウントを切り替えてアニメーションを再生

これこそがMotionの真骨頂です!
アンマウント時にアニメーションを仕込むことが出来ます。
AnimatePresencemotion.divを囲うとexitに設定したアニメーションがアンマウント時に反応します。
AnimatePresenceinitialはマウント時にアニメーションを行うかどうか。
この場合のmotion.divinitialは再マウント時の初期状態になるため、exitと同じ値を入れるのが定石となります。
挙動は上と変わりませんが、アンマウントできるので処理負荷に優しかったりレイアウトの組みやすさが上がったりしますね!

import { AnimatePresence, motion } from "framer-motion"
import { useCallback, useState } from "react"

export const ToggleMountTest = () => {
  const [visible, setVisible] = useState(true)
  const toggleVisible = useCallback(() => {
    setVisible((prev) => !prev)
  }, [])

  return (
    <div className="w-96 p-2 flex flex-col items-center justify-center gap-2 bg-gray-50">
      <div className="w-full h-52 p-2 flex gap-1 items-center justify-center bg-white">
        <AnimatePresence initial={false}>
          {visible && (
            <motion.div
              className="h-8 px-4 flex items-center justify-center rounded bg-gray-300 text-white"
              initial={{ opacity: 0 }}
              animate={{ opacity: 1 }}
              exit={{ opacity: 0 }}
              transition={{ duration: 0.3, ease: "easeInOut" }}
            >
              Test
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      <button className="w-full h-8 bg-gray-100" onClick={toggleVisible}>
        Toggle
      </button>
    </div>
  )
}

本題

Motionについて軽く分かっていただけたと思うので、本題に移ります。
筆者が今回「パッ」と消えたケースはリストにアニメーションを付けた時です。
まずは成功例をお見せします。

■成功例

リストが空っぽからリストに要素が1つ以上あるになった時にanimateが再生
リストに要素が1つ以上あるからリストが空っぽになった時にexitが再生
というアニメーションになります。

追加されたらリスト全体が下から現れ、全て消えたらリスト全体が下に消えていく
という感じです。

import { AnimatePresence, motion } from "framer-motion"
import { useCallback, useState } from "react"

interface ItemProps {
  id: string
  name: string
}

const List = ({ items }: { items: ItemProps[] }) => {
  return (
    <div className="w-full h-full flex gap-1 items-center justify-center">
      {items.map((item) => (
        <div key={item.id} className="h-8 px-4 flex items-center justify-center rounded bg-gray-300 text-white">
          {item.name}
        </div>
      ))}
    </div>
  )
}

export const ListMountTest = () => {
  const [items, setItems] = useState<ItemProps[]>([])
  const add = useCallback(() => {
    setItems((prev) => [...prev, { id: `${prev.length}`, name: `Item ${prev.length}` }])
  }, [])

  const reset = useCallback(() => {
    setItems([])
  }, [])

  return (
    <div className="w-96 p-2 flex flex-col items-center justify-center gap-2 bg-gray-50">
      <div className="w-full h-52 p-2 flex gap-1 items-center justify-center bg-white">
        <AnimatePresence initial={false} mode="wait">
          {items.length > 0 && (
            <motion.div
              className="w-full h-full"
              initial={{ opacity: 0, y: 20 }}
              animate={{ opacity: 1, y: 0 }}
              exit={{ opacity: 0, y: 20 }}
              transition={{ duration: 0.3, ease: "easeInOut" }}
            >
              <List items={items} />
            </motion.div>
          )}
        </AnimatePresence>
      </div>
      <div className="w-full h-8 flex gap-1">
        <button className="w-full h-8 bg-gray-100" onClick={add}>
          Add
        </button>
        <button className="w-full h-8 bg-gray-100" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

■失敗例

同じアニメーションをContextを用いて実装してみます。
「パッ」と消えているのがおわかりいただけるだろうか…

import { AnimatePresence, motion } from "framer-motion"
import { createContext, useCallback, useContext, useState } from "react"

interface ItemProps {
  id: string
  name: string
}

const List = ({ items }: { items: ItemProps[] }) => {
  return (
    <div className="w-full h-full flex gap-1 items-center justify-center">
      {items.map((item) => (
        <div key={item.id} className="h-8 px-4 flex items-center justify-center rounded bg-gray-300 text-white">
          {item.name}
        </div>
      ))}
    </div>
  )
}

const CountContext = createContext<ItemProps[]>([])
const ContextList = () => {
  const items = useContext(CountContext)
  return <List items={items} />
}

const ContextListMountTest = () => {
  const [items, setItems] = useState<ItemProps[]>([])
  const add = useCallback(() => {
    setItems((prev) => [...prev, { id: `${prev.length}`, name: `Item ${prev.length}` }])
  }, [])

  const reset = useCallback(() => {
    setItems([])
  }, [])

  return (
    <div className="w-96 p-2 flex flex-col items-center justify-center gap-2 bg-gray-50">
      <div className="w-full h-52 p-2 flex gap-1 items-center justify-center bg-white">
        <CountContext.Provider value={items}>
          <AnimatePresence initial={false} mode="wait">
            {items.length > 0 && (
              <motion.div
                className="w-full h-full"
                initial={{ opacity: 0, y: 20 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: 20 }}
                transition={{ duration: 0.3, ease: "easeInOut" }}
              >
                <ContextList />
              </motion.div>
            )}
          </AnimatePresence>
        </CountContext.Provider>
      </div>
      <div className="w-full h-8 flex gap-1">
        <button className="w-full h-8 bg-gray-100" onClick={add}>
          Add
        </button>
        <button className="w-full h-8 bg-gray-100" onClick={reset}>
          Reset
        </button>
      </div>
    </div>
  )
}

原因

一言で申しますとMotionの使い方を間違えているのが原因です。

Motionはexitのタイミングでpropsをスナップショットして保持してくれます。
なので普通に使えば「アニメーションが終わるまで最後の状態を維持」できるはずなんです。

ところが今回の失敗例では、Context(=Reduxでも同じ)から直接itemsを読みに行っていたため、グローバルステートが更新された瞬間にitems=[]が反映されてしまう。
結果として「Exit中に中身が空になり → パッと消える」という挙動になってしまいました。

ただ、アニメーションの事を考えずにReduxなんか使ってたら内部でuseGetして…というケース…容易に想像できますよね…?

改善策・設計のポイント

結論としては、MotionでExitを使うならpropsをきちんと受け渡して**「Exit用に凍結された値」**を使う設計にする必要があります。
少しでもアニメーションを付ける可能性があるのであれば最初からその設計を頭の片隅に入れておくだけで幸せになれると思います。
Motionを用いなかったとしてもアニメーションを付けたいケースであれば必要な設計になると思うので…

Component名の頭に「Animate」を付けるようにし、その内部のComponentでuseという関数が無いかCopilot等にチェックさせるのも手かもしれません。

最後に

今回は「Motion × グローバルステート管理」でハマった話を紹介しました。
要するに「Exitアニメーションを使う予感が1%でもするならちゃんと設計しておけ」ってことです。
「パッ」と消えて困ってこの記事に来た人は…修正お互い頑張りましょう…!
ではまた!

PortalKey Tech Blog

Discussion