どんなところでも開いたり閉じたりしたい
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の隣接セレクタとか使えなくなるのいやですよね)
ひえええという感じですが、汎用性を出すためなので頑張りましょう!
ただ、上記のリストだけではどういう風に書きたいのか一向にイメージがつかないと思いますので、上記を叶えた仕組みを使った上で、どうコーディングをしようとしているのかを書いておきたいと思います。
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;
このように書けるといいな!ということです!
今回、わざとAccordion
とAccordionToggleButton
の間に<p>
を挟んでいまして、必ずしも子、孫、ひ孫、さらにそれ以下の要素になっても良いというのを意識付けるためにこうしました。(AccordionAreaも然りですが、冗長化するなと思って直下に置いています。ただ、要素の位置には依存させないようにします。)
※Accordion
のimport元が'./Components/Accordion'
とかになってしますが、特に深い意味はないので、好きな位置で大丈夫です!
さらに、これが開いた状態の時に、通常のDOMツリー状はどういう風になるかというと、、、
<p>
<button>閉じる</button>
</p>
<p>アコーディオンの中身だよ</p>
という形になります。
jQueryとかで実装する場合は、良くアコーディオンする場所をさらにもう一つdiv
とかで囲っていたと思いますが、今回の場合は囲っても囲わなくてもどちらでもOKというのが実現できるようになります。便利!
普通に便利そうなので、これを実現するためのAccordionコンポーネント群を作ってみたいと思います!
※注意:このコンポーネントはアニメーションは想定していません!ごめんなさい!
肝はContext APIとcloneElement
今回のように、
- どの深さにどんな要素が来るかわからない
- 自身の子に親からpropsを渡したい
というケースには、それぞれ、Context APIとcloneElementの二つが解決してくれます。
※注意:Context API
はv16.3
以降らしいです!react
とreact-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種類を作ります。
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;
}
}
import React from 'react';
const AccordionArea = ({ children }) => (
children
);
export default AccordionArea;
とてつもなく無意味な2コンポーネントが出来上がりましたね^^
ただただ、子要素を返すだけの、なんのために生まれたのかわからないコンポーネントが出来上がりました。
本来やりたいことは、Accordion
で作ったstateをうまいことAccordionArea
に引き渡して、isExpanded
を活用してAccordingArea
内のchildren
を出したり閉まったりすればいいはずなのですが、当然isExpanded
はAccordion
でしか使うことができません。
なら、Accordion
から子のAccordionArea
に渡せばいいじゃんって話ですが、今回は**AccordionArea
が必ず子に来るとは限らず、孫かもしれないし、ひ孫かもしれない**という状況なのでそれもできません。
そこで、Context APIの登場です!
まず、以下のようなファイルを作ります。
import { createContext } from 'react';
const AccordionContext = createContext();
export default AccordionContext;
これだけです。
このファイルを、先ほど作った各コンポーネントにimportして活用していきます。
まずは何も説明しないまま、思うがままにContext APIを使ったコードへと修正していきます。すみません。
後ほどちゃんと説明します。
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>
);
}
}
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.Provider
とAccordionContext.Consumer
の2点です。
この二つは、AccordionContext.jsxで生成したものを各コンポーネントで使っているということなので、つまりcreateContext
によって生み出されたものということになります。
ProviderもConsumerも、どちらもReactコンポーネントになるので<Provider>
、<Consumer>
と書けるわけです。
ちなみにですが、今回、私がAccordionContext.Provider
と書いているのは、ただ単に変数に入れるのがめんどくさかっただけです^^
このProvider
とConsumer
は何ができるのかをざっくりと説明すると、
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と先ほど紹介したこちらの記事にインスパイアされて、state
とactions
というシャレたプロパティ名をつけてみたという感じです^^
今度はAccordionContext.Consumer
の方ですが、こちらは少々書き方が独特です。
const AccordionArea = props => (
<AccordionContext.Consumer>
{({ state }) => {
if (state.isExpanded) {
return props.chidlren;
}
return null;
}}
</AccordionContext.Consumer>
);
切り出してみました。
Consumer
のあとにいきなり関数を入れていますが、この関数の引数に、Provider
のvalue
で投げたオブジェクトが流れてくるわけです。
なので、この関数を省略するとせっかくProvider
が流してくれた値を活用することができないので、絶対に入れるようにしましょう。お約束ってやつですね。
AccordionArea
ではactions
は特に必要ないので、{ state }
と記述してstate
だけを切り出し、if
で純粋に出し分けを行っているだけです。
ロジック自体はとてもシンプルですね。
こうすることで、要素をどれだけネストしても、期待通りに値を受け渡すことができました!
さて、せっかくactionsの方も作ったので、Consumer
を活用してAccordionToggleButton
も作ってみましょう。
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
**が出てきます。
これを使うことによって、
children
にprops
を追加させることができちゃいます!
本当にそんなことやっていいのかどうかはちょっと心苦しいですが、せっかくなのでやってみましょう。
今回やりたいのは、
Accordion
からもらってきたactions
を、AccordionToggleButton
の中にあるchildren
のonClick
に付与する。
ということです。わかりづらい。。
要するに
this.props.children.props.onClick = actions.toggle;
とやりたいわけです。
ただこんなことしたらReactがエラー吐きまくる(propsの上書きは基本的には禁止)ので、それを解決するのがcloneElement
というわけです。
先程のAccordionToggleButton
を改良してみました。
※以下の記事を参考にしています。
React で this.props.children に新しい Props を渡す
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
という関数を作ったことです。
この関数でChildren
とcloneElement
を使うためにReactから同時にimportしています。
このChildren
というのは、私もよく知りませんが、this.props.children
を処理しやすいようにreactが用意してくれているものなんだとか。
今回はその中のmap()
を使いました。Array.prototype.map()
と大差ないと思います。
cloneElement
は、第一引数にReactElementを入れて、第二引数にprops
となるオブジェクトを入れると、新たに要素を返してくれる関数です。
Children.map
で一つ一つに分解されたchild
をcloneElement
で{ 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にちょっと小細工をいれます。
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,
}
AccordiontArea
とAccordionToggleButton
をAccordion
に集約させてあげました!
こうしてあげると、いざ使おうというときに
import Accordion, { AccordionArea, AccordionToggleButton } from './Components/Accordion';
というふうに書くことができます!(実は冒頭の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の記事が書けて楽しかったです!
この辺の知識はまだ覚えたてなので、間違いとかあればコメントください!