バケツリレーについて考える


Propsのバケツリレーについて、考えたことをメモしていきます。
初学者・独学・初投稿という3段構えのため、何かありましたらコメントいただけると助かります。

バケツリレーは悪いことではない

個人的にバケツリレー自体は悪いことではないと考えています。
Propsとして要素を親から子へ渡すことで、依存関係がエレメントから読み解きやすくなったり、制御が簡単になるケースが個人制作している限りでは多いためです。

例えば、ブログ投稿の編集画面を考えたときに以下のようなコンポーネントがあったとします。

const Page = () => {
   const post = usePost({id: '001'})
   if (!post) {
      return <Loading/>
   }
   return (
      <>
         <PostEditHeader post={post}/>
         <FlexBox>
            <PostEditorDisplay post={post} />
	    <PostEditorSidebar post={post} />
         <FlexBox />
      </>
   )
}

かなり浅い例ですが、この例ではPage投稿(post)が取得できなかった場合ローディング画面(<Loading/>)が表示され続けます。

投稿の取得はPageが責任を持って管理しているため、その下にある子コンポーネントは投稿が取得できたのかどうかを気にすることなく使用することができますし、取得できたのか曖昧な状態では使用できないコンポーネントとして定義することができます。

また、子コンポーネントはPropspostという項目を持つことで、中身の実装まで見に行かなくとも
「このコンポーネントは投稿を取得する役割はないんだな」
「このコンポーネントを利用するためには、投稿を取得して渡さないといけないんだな」
ということがわかります。

投稿を取得して表示するまでのことを考えてみました。
次に、この投稿を編集する時のことを考えてみます。

バケツリレーで渡された要素を編集する

export const PostEditorSideBar = (props: {
   post: Post
}) => {
   return (
      <>
         <SideTextField
            title={'スラッグ'}
            subTitle={'SLUG'}
            description={`投稿のスラッグを設定します。`}
            defaultValue={props.post.slug}
            onInput={(s) => (props.post.slug = s)}
          />
	  <SideSwitchField
            title={'公開設定'}
            subTitle={'PUBLISH'}
            description={`Webサイト上における投稿の公開・非公開を設定します。`}
            state={props.post.publish}
            onSwitch={(s) => (props.post.publish = s)}
          />
      </>
   )
}

投稿のスラッグなどを編集するサイドバーを例に書き出してみました。

それぞれのフィールドで何か更新があれば、Propsから渡された投稿(post)を編集します。
Propsで渡されたオブジェクトは値渡しではなく参照渡しのため、ここでPostEditorSidebarで編集された内容は親コンポーネントが持つ投稿にも反映されます。(親に知らされることはありません)

要素の編集をバケツリレーの最初に知らせる

編集するだけであればこれで問題ないかと思ったのですが、編集時に何かアクションしたいかつ アクションの制御を親コンポーネントでしたいという場合は、どうにかして「編集したから気づいて!」という信号を子コンポーネントから親コンポーネントに送る必要があります。

私の頭の中でパッと思いついたのは、Propsに編集した時に実行する関数を定義することです。

export const PostEditorSideBar = (props: {
   post: Post
   onChange: (post: Post) => void
}) => {
   return (
      <>
         <SideTextField
            title={'スラッグ'}
            subTitle={'SLUG'}
            description={`投稿のスラッグを設定します。`}
            defaultValue={props.post.slug}
            onInput={(s) => {
	       props.post.slug = s
	       props.onChange(props.post)
	    }}
          />
	  <SideSwitchField
            title={'公開設定'}
            subTitle={'PUBLISH'}
            description={`Webサイト上における投稿の公開・非公開を設定します。`}
            state={props.post.publish}
            onSwitch={(s) => {
	       props.post.publish = s
	       props.onChange(props.post)
	    }}
          />
      </>
   )
}

このようにすれば、投稿を編集した際に親コンポーネントで定義した希望のアクションを行うことが可能です。
これで解決!と思いましたが。。。
階層が深い場合は、Propsが複雑になる時もあるとも思いました。

const Page = () => {
   const post = usePost({id: '001'})
   const onChange = (post: Post) => console.log(post)
   if (!post) {
      return <Loading/>
   }
   return (
      <>
         <PostEditHeader post={post}/>
         <PostEditor post={post} />
      </>
   )
}

const PostEditor = (props: {
   post: Post
   onChange: (p: Post) => void
}) => (
   <FlexBox>
      <PostEditorDisplay post={post} />
      <PostEditorSidebar post={post} onChange={(post) => props.onChange(post)} />
   </FlexBox>
)

PostEditorというコンポーネントを新たに作り、3階層にしてみました。
こうなった時に個人的に思ったのは、
「受け渡すだけのPropsはバケツリレーを複雑にするのでは?」
ということです。

PostEditorというコンポーネントの役割が 投稿の編集にまつわるコンポーネントをまとめるコンポーネント となっているので、設計が悪かったりするかと思うのですが、コンポーネントの見通しをよくするためにまとめることはまあまああることではないかなと考えています。

最初にもお話ししましたが、これはこれで悪くないと思います。
投稿の状態をPageのみで管理することができるため、下のコンポーネントに余計な心配をかける必要がないためです。
ただこれがもっと深くなると、中にはコンポーネントの意味にそぐわないPropsを渡す必要が出てきてしまうこともあるのではと思いました。そのような場合、一旦設計を見直すのがベストだと思いますが、その後でどうしてもその設計でいきたいという時、私の場合Context等で状態をグローバルに管理する選択肢が出てきます。

バケツリレーをやめてグローバルに管理する

今までPropsで渡していた投稿をContextでグローバルに管理してみます。

const Page = () => {
   const post = usePost({id: '001'})
   const [editPost, setEditPost] = useContext(PostContext)
   
   useEffect(() => {
      if(post) {
         setEditPost(post)
      }
   }, [post])
   
   if (!post) {
      return <Loading/>
   }
   return (
      <>
         <PostEditHeader />
         <PostEditor />
      </>
   )
}

export const PostEditorSideBar = () => {
   const [post, setPost] = useContext(PostContext) 
   if (!post) {
      return <></>
   }
   return (
      <>
         <SideTextField
            title={'スラッグ'}
            subTitle={'SLUG'}
            description={`投稿のスラッグを設定します。`}
            defaultValue={post.slug}
            onInput={(s) => setPost({...post, slug: s})}
          />
	  <SideSwitchField
            title={'公開設定'}
            subTitle={'PUBLISH'}
            description={`Webサイト上における投稿の公開・非公開を設定します。`}
            state={post.publish}
            onSwitch={(s) => setPost({...post, publish: s})}
          />
      </>
   )
}

Contextを使うと、今まで親からPropsとして投稿を渡されていた子コンポーネントは、自立して自分自身で投稿を取得するようになります。また、取得された投稿に対してContextを保持する全コンポーネントは同様の更新権限を持つようになります。
こうなると制御するハードルはかなり高くなります。各コンポーネントで状態を共有することは容易になりますが、その反面何かバグった際にその原因特定が難しくなることも考えられます。
更新を実行する箇所を限定することで、全コンポーネントで状態を適切に管理できると思います。

結論は?

試行錯誤中なので、結論は出ていませんが。。。
現時点では、以下の順に設計するのがいいかなと思いました。

  1. 親で状態管理し、Propsによるバケツリレー
  2. 全体でContextを用いて状態管理(Propsの階層が深い かつ 意味ないPropsを中継するコンポーネントがある場合)