はじめに
RailsとReact、GraphQLを使って簡単なアプリを作りたく、勉強がてらRails + React + GraphQL + Apolloで簡単なアプリを作ってみました。
その際に、GraphQL使うならReduxを使うメリットってないんじゃないかなと思い、Reduxを使わずにいい感じに実装できないかなと試行錯誤したことを記事にしてみようと思います。
実際のアプリはTakumi0901/rails-react-sampleにあります。
もしstarとかforkとかissueもらえると嬉しいです。よろしくお願いします^ ^
Atomic Designを使ったディレクトリ構成
Atomic Designとは
Atomic Designでは5つのステージに分けて管理します。
- Atoms(アトム) – 原子
- Molecules(モルキュール) – 分子
- Organisms(オルガニズム) – 有機体
- Templates(テンプレート) – テンプレート
- Pages(ページ) – ページ
これらのステージは上から下に行くにつれて、粒度は大きくなり、抽象度は下がります。 抽象度の高いコンポーネント(たとえば原子)を合体させて、繰り返し可能なコンポーネント(分子)やテンプレートを構築できるようにデザインを考えていきます。 ページをデザインするのではなく、コンポーネントで構成するデザインシステムです。
ってことで、こんな感じのディレクトリ構成にしてみました。
├── actions
├── components
│ ├── atoms
│ ├── molecules
│ ├── organisms
│ └── templates
├── containers
│ ├── hoc
│ └── pages
├── helper
├── router
└── startup
ざっくり一つずつ説明をすると
actions
ここではGraphQLのQueryをまとめたり、共通して使えるstateを変数にまとめています。
import gql from "graphql-tag"
export const FETCH_ALL_BOOKS_QUERY = gql`
query {
books {
id
name
picture
}
}
`
export const FETCH_INITIAL_STATE = {
succeeded: false,
deleted: false,
errors: {}
}
export const FETCH_SUCCEEDED_STATE = {
succeeded: true,
deleted: false,
errors: {}
}
export const FETCH_DELETED_STATE = {
succeeded: false,
deleted: true,
errors: {}
}
export const FETCH_IS_ERROR_STATE = (errors = {}) => {
return ({
succeeded: false,
deleted: false,
errors: errors
})
}
componentsとcontainers
この中でAtomic Designの概念でcomponentを管理しています。詳しくは後ほど。
helper
ここでは共通で使う関数をまとめています。例えば、Validate.js
とか
routerとstartupについては今回の趣旨とあまり関係ないので割愛します。
componentの構成について
Atoms
SFC(Stateless Functional Component)で書くようにします。propsで受け取ったものを戻り値としてレンダリングする純粋な関数にします。ライフサイクルメソッドやstate
を扱うことはできませんが、stateを変更することができないということを明示的にできて良いなと思っています。
また、基本的にrenderされる要素は1つになるようにします。なので、いくつも要素が入るようならそれはAtomsではなくMoleculesになるはずです。
※ material-ui使ってる
import React from "react"
import TextField from 'material-ui/TextField/TextField'
const FormField = (props) => {
return (
<TextField
{...props.input}
// その他props
/>
)
}
export default FormField
Molecules
MoleculesについてもSFCで書くようにします。さらに、Moleculesは基本的には複数のAtomsもしくは場合によってはMoleculesで構成します。
ここでは複数のField(atoms)を組み合わせて入力フォームをまとめたものになっています。
んで、いつも悩むのがOrganismsとの違いです。自分はそこの判断をOrganismsはそれ単体で機能するものとしました。
今回の場合だと、Fieldのみだと入力するだけのものですが、そこにSubumitボタンなどを組み合わせた場合は「formを入力して送信」する機能として成り立ちます。
その「formを入力して送信」する機能をOrganismsになるように今回は設計をしました。
import React from "react"
import {Field} from 'react-final-form'
import FormField from "../../atoms/FormField"
import FieldDropZone from "../FieldDropZone"
const UpdateBookFields = (props) => (
<CardText>
<Field
name="name"
component={FormField}
/>
// その他Field
</CardText>
)
export default UpdateBookFields
Organisms
OrganismsについてもSFCで書くようにします。AtomsとMoleculesを組み合わせて機能を作る感じです。
UpdateFields
が入力部分のMoleculesでUpdateActions
はsubmit部分のMoleculesですね。それらを組み合わせて先ほど書いた「formを入力して送信」する機能」として本の名前などの情報を入力してsubmitできるようにしています。
import React from "react"
import { Form } from 'react-final-form'
import Card from 'material-ui/Card/Card'
import CardTitle from 'material-ui/Card/CardTitle'
import UpdateFields from '../../molecules/book/UpdateFields'
import UpdateActions from '../../molecules/UpdateActions'
import ErrorHOC from '../../../containers/hoc/ErrorHOC'
const UpdateBookContent = (props) => {
const initialValues = props.bookData.book ? {
name: props.bookData.book.name,
description: props.bookData.book.description,
author: props.bookData.book.author,
categoryId: props.bookData.book.category_id,
url: props.bookData.book.url
} : {}
return (
<Card>
<CardTitle title={props.card.title} subtitle={props.card.subtitle}/>
<Form
initialValues={initialValues}
onSubmit={props.onSubmit.method}
render={({ handleSubmit }) => (
<div>
<UpdateFields {...props}/>
<UpdateActions
{...props}
handleSubmit={handleSubmit}/>
</div>
)}
/>
</Card>
)
}
export default ErrorHOC(UpdateBookContent)
Templates
ここでは、機能が完結しているOrganismsをrenderします。基本的には、ここではOrganisms以外のcomponentを使わないようにします。
またTemplatesは今までと違いthis.state
を使ってそのView全体の管理をしたいのでclassで作ります。
ここでは本のcreateができたかどうかをローカルstateで持っています。
またメソッドも定義してstateと共に子componentにバケツリレーされます。
import React from 'react'
import {FETCH_INITIAL_STATE, FETCH_SUCCEEDED_STATE, FETCH_IS_ERROR_STATE} from '../../actions/Fetch'
import {FETCH_ALL_BOOKS_QUERY} from '../../actions/Books'
import FoundationHOC from '../../containers/hoc/FoundationHOC'
import UpdateContent from '../organisms/book/UpdateContent'
class Books extends React.Component {
constructor() {
super()
this.state = FETCH_INITIAL_STATE
}
onSubmit(values, e) {
const {createBook} = this.props
createBook({
variables: {...values},
refetchQueries: [{
query: FETCH_ALL_BOOKS_QUERY
}]
}).then(() => {
e.reset()
this.setState(FETCH_SUCCEEDED_STATE)
}).catch((errors) => {
console.log(errors)
this.setState(FETCH_IS_ERROR_STATE(errors))
})
}
render() {
return (
<UpdateContent
{...this.state}
{...this.props}
bookData={false}
card={{title: '本の登録', subtitle: '本の登録をします'}}
onHandleSelect={this.onHandleSelect.bind(this)}
onHandleRemove={this.onHandleRemove.bind(this)}
onSubmit={{label: '登録する', method: this.onSubmit.bind(this)}}
onDelete={{}}
/>
)
}
}
export default FoundationHOC(Books)
Pages
ここでは何もrenderをしません。templatesをGraphQLと連携させるだけです。
ここで初めてサーバーから受け取ったデータをpropsに渡されるイメージです。
nameで定義したthis.props
にデータだったり、メソッドが入ります。
this.props.booksData
とかthis.props.createBook()
ですね。
もしRedux使うならここでコネクトさせる感じかなと思います。
ちなみに、他のプロジェクトで設計をした時はOrganismsにReduxとのコネクトを任せるように作ったことがあります。ようは、機能ごとに必要なstateをpropsに渡すようにしていました。この設計は最初はまあまあよかったのですが、ページ機能が増えてくるとページ内で同じStateを使ったりするので、propsに渡すのが何箇所もあったり、APIから取得するのも機能ごとなので実装者によっては何箇所にもあったり・・・
どうしても規模が大きくなるにつれやりにくさが出てきました。
問題点としては、APIから取得するとこの所在と使っているStateの場所がいくつもあり所在が把握しづらくなってしまいました。
なので、今回はOrganismsではStateを持たすことをやめてページ単位で存在するPagesに持たすって感じですね。
import Books from '../../components/templates/Books'
import { graphql, compose } from 'react-apollo'
import {FETCH_ALL_BOOKS_QUERY, CREATE_BOOK_MUTATION} from '../../actions/Books'
export default compose(
graphql(FETCH_ALL_BOOKS_QUERY, {
name: 'booksData'
}),
graphql(CREATE_BOOK_MUTATION, {
name: 'createBook'
})
)(Books)
Reduxいらないと思った理由
Apollo
によりGraphQLで取得したデータにしろ、Createだったりのメソッドもprops
に入れてくれます。
なので、this.props.booksData
やthis.props.createBook()
というようにcomponentで扱うことができます。
だったら、別にReduxでState管理をしなくてもいいのではと思い今回のような実装にしました。
ただReduxがないと困る部分もある
基本的にReduxを使わないとなると共通で何かしたい場合に困ります。
- 共通エラー周り
- 共通UI周り
とか
例えば、全ページで何かの一覧を表示させたいとか。今回の場合だと、book一覧を常にサイドバーに表示させています。
他にもユーザーのアカウント名を常にheaderに表示させたいなどはよくありますしね。
HOCで解決できた
そういった問題もHigher Order Components(HOC)で解決できます。例えば先ほどのbook一覧を常にサイドバーに表示であれば、サイドバーにラップされたcomponentから受け取ったpropsのbookListを渡してやることでできます。
あと、ついでに共通処理としてページ遷移したらページトップへScrollさせる処理も入れときます。
import React from 'react'
import NavBar from '../../components/atoms/NavBar'
import Container from '../../components/atoms/Container'
import SideBar from '../../components/organisms/SideBar'
const FoundationHOC = (WrappedComponent) => {
class Foundation extends React.Component {
constructor() {
super()
this.state = {
open: false
}
}
handleToggle() {
this.setState({
open: !this.state.open
})
}
componentDidUpdate(prevProps, prevState) {
setTimeout(() => {
/** ブラウザバックならreturn **/
if (this.props.history.action === 'POP') {
return
}
if(prevProps.match !== this.props.match) {
/** それ以外はページトップへ移動 **/
window.scrollTo(0, 0)
}
})
}
render() {
const {booksData} = this.props
return (
<div>
<NavBar
{...this.state}
onToggle={() => this.handleToggle()}/>
<SideBar
{...this.state}
onToggle={() => this.handleToggle()}
list={booksData.books}
/>
<Container>
<WrappedComponent {...this.props}/>
</Container>
</div>
)
}
}
return Foundation
}
export default FoundationHOC
あとは、使いたいcomponentをラップするだけです。
export default FoundationHOC(Books)
今でも悩んでいる部分
Atomic Designの概念としては、TemplatesでState(ローカルstate)を持たすのはちょっと違うのかもと思っています。ローカルstateもPagesで持った方が良いのかなと。
ただ、PageではGraphQLのを使うメソッドのみっていうのもわかりやすくて気に入っているから、どうしようかなと。この辺は実際にプロジェクトで使ってみると問題点が見つかっていいんですよね。
機会があれば試してみたいと思います。
まとめ
とりあえず今回はReduxを使わずにGraphQL + Reactでの設計というかやってみた実装方法でした。
ポイントとしては、
- 基本的にSFC(Stateless Functional Component)で書くようにしたことで、Stateの所在がわかりやくなった
- HOCを使うことで共通化も簡単
というか、GraphQL + ReactがいいというよりかはHOCの効能というか破壊力を体感できたの大きかったように感じます。