11
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Context APIを活用して、Reduxのstate管理から外したAccordionコンポーネントを作る

Last updated at Posted at 2018-06-20

どんなところでも開いたり閉じたりしたい

Webでは結構おなじみになっているものかと思いますが、「もっと見る」ボタンをポチッと押したら要素がべろーんと出てくるようなものをAccordionなんていいますね。
React+Reduxで作っている場合は、純粋にリストの項目をボーンとStoreにぶん投げてしまえばべろべろーっと出るのでそこまで意識しないかと思います。
ただ、一旦デザイン的には要素を閉じておくけど、 別に通信するわけでないのでもともと静的に持っているDOMをべろんと出したいなんてこともよくありますよね?

「いやいや、こんなアコーディオンが開いてるとか閉じてるとか、そんなことのためにわざわざAction作りたくないねん。」
「アコーディオンが開いてることをStoreが知ってなんの得があんねん。」

と思う方多いのではないでしょうか?

私は思いました!
かなり必死に調べたので、せっかくなので記しておきたいと思います。

stateやreducerの肥大化の闇

ReduxにおけるGlobal stateとLocal stateの共存
こちらの記事に、Global stateとLocal stateを共存して書くことによって、肥大化が起こりにくくなるかもね〜ということが書いてあります。
もちろんケースバイケースだと思いますが、アコーディオンなんかは開いているか否かなんてのをLocal stateに任せても別にいいじゃんねー?と私は思うわけです。
(ただし、必ず一つのアコーディオンしか開かないというような、他のアコーディオンと連携しないことを想定しています。アコーディオン同士が干渉する場合は別です。)

というわけで、ポチッと押したらべろんと出てくる超汎用的なコンポーネントを作ってみましょう。

やりたいことのまとめ

まず、どういうものを作りたいのかをちゃんと整理しておきましょう。

  • Accordionの中にはすべての要素を受け付けられるようにする
  • Accordionの中身の中で、指定した箇所だけのDOMが消えたり表示されたりするようにする
  • トリガーとなるボタンは、Accordionの子となるか、孫となるか、ひ孫となるか、さらにそれ以下の要素になるかは不明である(デザインによってDOM構造が変わるので当然ですよね)
  • トリガーとなるボタンは好きな要素を入れられるようにする(テキストリンクなのか、buttonタグなのか選べた方がいいですよね)
  • Accordion機能を追加するためだけに新たなDOMは追加しない(CSSの隣接セレクタとか使えなくなるのいやですよね)

ひえええという感じですが、汎用性を出すためなので頑張りましょう!
ただ、上記のリストだけではどういう風に書きたいのか一向にイメージがつかないと思いますので、上記を叶えた仕組みを使った上で、どうコーディングをしようとしているのかを書いておきたいと思います。

App.jsx
import react from 'react';
import Accordion, { AccordionArea, AccordionToggleButton } from './Components/Accordion';

const App = () => (
  <Accordion>
    <p>
      <AccordionToggleButton>
        <button>開く</button>
      </AccordionToggleButton>
    </p>
    <AccordionArea>
      <p>アコーディオンの中身だよ</p>
    </AccordionArea>
  </Accordion>
)

export default App;

このように書けるといいな!ということです!
今回、わざとAccordionAccordionToggleButtonの間に<p>を挟んでいまして、必ずしも子、孫、ひ孫、さらにそれ以下の要素になっても良いというのを意識付けるためにこうしました。(AccordionAreaも然りですが、冗長化するなと思って直下に置いています。ただ、要素の位置には依存させないようにします。)
Accordionのimport元が'./Components/Accordion'とかになってしますが、特に深い意味はないので、好きな位置で大丈夫です!

さらに、これが開いた状態の時に、通常のDOMツリー状はどういう風になるかというと、、、

<p>
  <button>閉じる</button>
</p>
<p>アコーディオンの中身だよ</p>

という形になります。
jQueryとかで実装する場合は、良くアコーディオンする場所をさらにもう一つdivとかで囲っていたと思いますが、今回の場合は囲っても囲わなくてもどちらでもOKというのが実現できるようになります。便利!

普通に便利そうなので、これを実現するためのAccordionコンポーネント群を作ってみたいと思います!
※注意:このコンポーネントはアニメーションは想定していません!ごめんなさい!

肝はContext APIとcloneElement

今回のように、

  • どの深さにどんな要素が来るかわからない
  • 自身の子に親からpropsを渡したい

というケースには、それぞれ、Context APIcloneElementの二つが解決してくれます。
※注意:Context APIv16.3以降らしいです!reactreact-domのバージョンアップを必ずしてください!私は動かない理由が小一時間わからなくて辛かったです!

Context API

これはreactが用意してくれているもので、ルートとなるコンポーネントのstateをバケツリレーせずに任意の場所から提供できるようになるという代物です。

reduxをやったことある人は、まさしくreduxだー!と思うはずです。
react-reduxを使って任意のコンポーネントをcontainerでconnectすりゃいいというのとかなり似ています。

ただし、reduxと大きく違うのは、Local stateとして扱うところです。小さなstoreが生まれるイメージです。reduxだけでやっていた人には少々違和感があるかもしれませんが、今回はreduxを肥大化させないようにLocal stateを活用したいと思います!

以下の記事を参考に作ってみます。
React context APIを触ってみた

まずはふつうにコンポーネントを作っていきます。
全体をラップするAccordionコンポーネントと、出たり閉じたりする箇所となるAccordionAreaの2種類を作ります。

Accordion.jsx
import React, { Component } from 'react';

export default class Accordion extends Component {
  constructor(props) {
    super(props);
    this.props = props;

    this.state = {
      isExpanded: false,
    };
  }

  render() {
    return this.props.children;
  }
}
AccordionArea.jsx
import React from 'react';

const AccordionArea = ({ children }) => (
  children
);

export default AccordionArea;

とてつもなく無意味な2コンポーネントが出来上がりましたね^^
ただただ、子要素を返すだけの、なんのために生まれたのかわからないコンポーネントが出来上がりました。

本来やりたいことは、Accordionで作ったstateをうまいことAccordionAreaに引き渡して、isExpandedを活用してAccordingArea内のchildrenを出したり閉まったりすればいいはずなのですが、当然isExpandedAccordionでしか使うことができません。

なら、Accordionから子のAccordionAreaに渡せばいいじゃんって話ですが、今回は**AccordionAreaが必ず子に来るとは限らず、孫かもしれないし、ひ孫かもしれない**という状況なのでそれもできません。

そこで、Context APIの登場です!

まず、以下のようなファイルを作ります。

AccordionContext.jsx
import { createContext } from 'react';

const AccordionContext = createContext();

export default AccordionContext;

これだけです。
このファイルを、先ほど作った各コンポーネントにimportして活用していきます。
まずは何も説明しないまま、思うがままにContext APIを使ったコードへと修正していきます。すみません。
後ほどちゃんと説明します。

Accordion.jsx
import React, { Component } from 'react';
import AccordionContext from './AccordionContext';

export default class Accordion extends Component {
  constructor(props) {
    super();
    this.props = props;

    this.state = {
      isExpanded: false,
    };
  }

  render() {
    return (
      <AccordionContext.Provider value={{
        state: this.state,
        actions: {
          toggle: (state) => {
            this.setState({
              isExpanded: !state,
            });
          },
        },
      }}
      >
        {this.props.children}
      </AccordionContext.Provider>
    );
  }
}
AccordionArea.jsx
import React from 'react';
import AccordionContext from './AccordionContext';

const AccordionArea = ({ children }) => (
  <AccordionContext.Consumer>
    {({ state }) => {
      if (state.isExpanded) {
        return children;
      }

      return null;
    }}
  </AccordionContext.Consumer>
);

export default AccordionArea;

ここで肝になってくるのはAccordionContext.ProviderAccordionContext.Consumerの2点です。

この二つは、AccordionContext.jsxで生成したものを各コンポーネントで使っているということなので、つまりcreateContextによって生み出されたものということになります。

ProviderもConsumerも、どちらもReactコンポーネントになるので<Provider><Consumer>と書けるわけです。
ちなみにですが、今回、私がAccordionContext.Providerと書いているのは、ただ単に変数に入れるのがめんどくさかっただけです^^

このProviderConsumerは何ができるのかをざっくりと説明すると、

Providerに渡したvalueをConsumerで受け取れることができる

というわけです!
※詳しくはこちらを参照してください。

Accordion.jsxのrenderで<AccordionContext.Provider value={}>の中に、少し大きめのobjectを渡しています。


{
  state: this.state,
  actions: {
    toggle: (state) => {
      this.setState({
        isExpanded: !state,
      });
    },
  },
}

切り出してみました。
オブジェクトの中身は特に型とか決まっていなくて、好きな形で良いと思うのですが、一番重要なポイントは**Consumerで受け取りたいものをここで定義しておく**ということです。

今回は、Consumerに対して渡したいものは、**Accordionで管理しているthis.state**と、ボタンのonClickで使う、共通のメソッド群だと思います。

この2つを、ただ好きにProviderから渡せばいいのですが、ちょっとだけreduxと先ほど紹介したこちらの記事にインスパイアされて、stateactionsというシャレたプロパティ名をつけてみたという感じです^^

今度はAccordionContext.Consumerの方ですが、こちらは少々書き方が独特です。

const AccordionArea = props => (
  <AccordionContext.Consumer>
    {({ state }) => {
      if (state.isExpanded) {
        return props.chidlren;
      }

      return null;
    }}
  </AccordionContext.Consumer>
);

切り出してみました。
Consumerのあとにいきなり関数を入れていますが、この関数の引数に、Providervalueで投げたオブジェクトが流れてくるわけです。
なので、この関数を省略するとせっかくProviderが流してくれた値を活用することができないので、絶対に入れるようにしましょう。お約束ってやつですね。
AccordionAreaではactionsは特に必要ないので、{ state }と記述してstateだけを切り出し、ifで純粋に出し分けを行っているだけです。
ロジック自体はとてもシンプルですね。

こうすることで、要素をどれだけネストしても、期待通りに値を受け渡すことができました!

さて、せっかくactionsの方も作ったので、Consumerを活用してAccordionToggleButtonも作ってみましょう。

AccordionToggleButton.jsx
import React from 'react';
import AccordionContext from './AccordionContext';

const AccordionToggleButton = ({ children }) => (
  <AccordionContext.Consumer>
    {({ state, actions }) => (
      children
    )}
  </AccordionContext.Consumer>
);

export default AccordionToggleButton;

困りました。。。何が困ったか察しの良い方ならわかるかと思います。。。

actionsの関数を付与する隙がねぇ!

「クリックしたら」というのは、childrenに入ってくる要素の「onClick」に関数を渡さなければならないのに、渡し方が全然わからない!!

ただ、さっき「やりたいことまとめ」でこんなことを書きました。

トリガーとなるボタンは好きな要素を入れられるようにする(テキストリンクなのか、buttonタグなのか選べた方がいいですよね)

*「選べたほうが良いですよね」*じゃねぇよって話ですが、まぁ確かに選べた方がいいので、これをなんとかしていきます。

cloneElement

ここでやっと**cloneElement**が出てきます。
これを使うことによって、

childrenpropsを追加させることができちゃいます!

本当にそんなことやっていいのかどうかはちょっと心苦しいですが、せっかくなのでやってみましょう。

今回やりたいのは、

Accordionからもらってきたactionsを、AccordionToggleButtonの中にあるchildrenonClickに付与する。

ということです。わかりづらい。。

要するに

this.props.children.props.onClick = actions.toggle;

とやりたいわけです。
ただこんなことしたらReactがエラー吐きまくる(propsの上書きは基本的には禁止)ので、それを解決するのがcloneElementというわけです。

先程のAccordionToggleButtonを改良してみました。
※以下の記事を参考にしています。
React で this.props.children に新しい Props を渡す

AccordionToggleButton.jsx
import React, { Children, cloneElement } from 'react';
import AccordionContext from './AccordionContext';

const AccordionToggleButton = ({ children }) => {
  const childrenWithProps = (_children, onClick) => (
    Children.map(_children, child => (
      cloneElement(child, { onClick })
    ))
  );

  return (
    <AccordionContext.Consumer>
      {({ state, actions }) => (
        childrenWithProps(children, () => (actions.toggle(state.isExpanded)))
      )}
    </AccordionContext.Consumer>
  );
};

export default AccordionToggleButton;

だいぶ変えちゃいましたが、大きく変えたのはchildrenWithPropsという関数を作ったことです。
この関数でChildrencloneElementを使うためにReactから同時にimportしています。
このChildrenというのは、私もよく知りませんが、this.props.childrenを処理しやすいようにreactが用意してくれているものなんだとか。
今回はその中のmap()を使いました。Array.prototype.map()と大差ないと思います。

cloneElementは、第一引数にReactElementを入れて、第二引数にpropsとなるオブジェクトを入れると、新たに要素を返してくれる関数です。
Children.mapで一つ一つに分解されたchildcloneElement{ onClick }を付与しながらクローンして、それをすべて返すのがchildrenWithPropsのお仕事となるわけです。

childrenWithPropsの第二引数であえてonClickという名前で引き受けることで、cloneElementの第二引数に{ onClick }と書けば良くなります。

なんでそもそも、onClickという名前で渡しているかというと、reactは<div onClick={() => { // なにか処理 }}>と書くと、クリックしたときの処理を行うことができるので、今回の処理は、強制的に子要素にonClickの処理を追加してね!という処理になります。
※ここはちょっと賛否両論あるかなと思っています。

さらに、もう一つ肝となるのは

childrenWithProps(children, () => (actions.toggle(state.isExpanded)))

の箇所なのですが、さらに

() => (actions.toggle(state.isExpanded))

のところです。
ここ、ちょっと違和感がありませんか?

actions.toggle(state.isExpanded)

なぜこれではいけないのか?

これ、結構JSを書いていてハマりがちで、実はFlashの時代からこの問題はあります。
もちろん、onClickには関数を渡さなければなりませんが、もし、、

actions.toggle(state.isExpanded)

を渡してしまうと、これはもう実行された結果が渡されることになるので、actions.toggle()が返した値がonClickに渡ってしまうことになります。
なので、今回は特に返り値を設定していないのでundefinedということになります。やばい。

なので、、、

() => (actions.toggle(state.isExpanded))

こうしてあげることで、関数を返す関数を作ってあげるのです。
es5で書いたほうがわかりやすいと思うので、上記の式をes5に書き換えてみます。

function() {
  return actions.toggle(state.isExpanded)
}

こうしてあげれば、onClickに渡るときにはちゃんとactions.toggle(state.isExpanded)が渡るようになります。
reactになると結構しれっと書かれていることが多いので、お気をつけて〜〜
※Flash AS3を題材に同じようなことが書かれているのはこちら→addEventListenerで一緒に引数を渡したい

仕上げ

そんなこんなで各プログラムが書けたので、ここでAccordion.jsxにちょっと小細工をいれます。

Accordion.jsx
import React, { Component } from 'react';
import AccordionContext from './AccordionContext';
import AccordionArea from './AccordionArea';
import AccordionToggleButton from './AccordionToggleButton';

class Accordion extends Component {
  constructor(props) {
    super();
    this.props = props;

    this.state = {
      isExpanded: false,
    };
  }

  render() {
    return (
      <AccordionContext.Provider value={{
        state: this.state,
        actions: {
          toggle: (state) => {
            this.setState({
              isExpanded: !state,
            });
          },
        },
      }}
      >
        {this.props.children}
      </AccordionContext.Provider>
    );
  }
}

export {
  Accordion as default,
  AccordionArea,
  AccordionToggleButton,
}

AccordiontAreaAccordionToggleButtonAccordionに集約させてあげました!
こうしてあげると、いざ使おうというときに

import Accordion, { AccordionArea, AccordionToggleButton } from './Components/Accordion';

というふうに書くことができます!(実は冒頭のApp.jsxでもこういう記述をしていました)

これで晴れて、以下の記述ができるようになりました!

App.jsx
import react from 'react';
import Accordion, { AccordionArea, AccordionToggleButton } from './Components/Accordion';

const App = () => (
  <Accordion>
    <p>
      <AccordionToggleButton>
        <button>開く</button>
      </AccordionToggleButton>
    </p>
    <AccordionArea>
      <p>アコーディオンの中身だよ</p>
    </AccordionArea>
  </Accordion>
)

export default App;

めでたし!

あとがき

ずっと、CSSとか初級編とかばっかり書いていたので、ようやくReactの記事が書けて楽しかったです!
この辺の知識はまだ覚えたてなので、間違いとかあればコメントください!

11
16
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
11
16

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?