Figma
とStorybook
で作成したプロトタイプを実際にReactで実装していきます。
(プロトタイプ、アプリイメージについてはこちらの記事に詳細を投稿しています。)
Reactに関しては、技術の入れ替わりが激しく「3ヶ月前に採用したモジュールがすでにdeplicatedになっている」なんてことはざらにあります。
そんななかReact 16.8
から導入されたのが、Hooks
です。公式曰く
フック (hook) は React 16.8 で追加された新機能です。state などの React の機能を、クラスを書かずに使えるようになります。
未だ業務でバリバリ、クラスコンポーネントで書いている私にとっては寝耳に水なアップデートですが、
React からクラス型コンポーネントを削除する予定はありません。
フックは既存のコードと併用することができるので、段階的に採用していくことが可能です
とのことだったので少し安心し、ほったらかしにしていたのですが、クラスコンポーネントに対する批判的なコメントが記述してあり、結局のところは移行する方向で進めたほうが良さそうな雰囲気です。
そこで「技術を把握するためには実際に手を動かせ」とばかりに、今さらながらも個人アプリの開発に採用してみました。
Hooks APIには様々な種類のAPIがありますが、基本的にステート管理をするuseState
、副作用を管理するuseEffect
が使いこなせればアプリケーションの開発は可能なので、今回の開発ではシンプルにこの2つのAPIしか使用していません。
クラスコンポーネントとHooksとの違い
コンポーネントのライフサイクルにおいて一番使用するであろう、state
、componentDidMount
、componentDidUpdate
、componentWillUnmount
をクラスコンポーネント、Hooksそれぞれで実装した場合の比較を行っています。
Hooksの方がコードがシンプルなことがわかります。今回のサンプルはわかりやすくするため、componentDidMount
、componentDidUpdate
、componentWillUnmount
の処理を個別にuseEffect
で再現していますが、実際の実装はこれらの処理を1つのuseEffect
にまとめて記述することができます。
クラスコンポーネント
import React from 'react';
interface Props {}
interface State {
count: number;
}
class AppClass extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
count: 0
};
}
componentDidMount() {
console.log('componentDidMount');
}
componentDidUpdate() {
console.log('componentDidUpdate');
}
componentWillUnmount() {
console.log('componentWillUnmount');
}
render() {
return (
<>
{this.state.count}
<button
onClick={() => {
this.setState({count: this.state.count + 1})
}}
>
+
</button>
<button
onClick={() => {
this.setState({count: this.state.count - 1})
}}
>
-
</button>
</>
);
}
}
export default AppClass;
Hooks
import React, { useState, useEffect } from 'react';
const AppHooks: React.FC = () => {
const [count, setCount] = useState(0);
// コンポーネントマウント時に1回実行
useEffect(() => {
console.log('componentDidMount');
}, []);
// コンポーネント更新毎に実行
useEffect(() => {
console.log('componentDidUpdate');
});
// コンポーネント更新時、'count'が変更されていた場合実行
useEffect(() => {
console.log('componentDidUpdate');
}, [count]);
// コンポーネントアンマウント時に1回実行
useEffect(() => {
return () => console.log('componentWillUnmount');
}, []);
return (
<>
{count}
<button
onClick={() => {
setCount(prevCount => prevCount + 1)
}}
>
+
</button>
<button
onClick={() => {
setCount(prevCount => prevCount - 1)
}}
>
-
</button>
</>
);
}
export default AppHooks;
Atomic Designとは
Atomic Design
とは、コンポーネントの再利用性を向上させるためのデザインシステムで、React
と相性が良くコンポーネントの設計を考えるときにとても参考になります。
以下の図がAtomic Design
のステージ図です。atom
(最小単位)→pages
(最終型)までコンポーネントの重複を可能な限り0にすることで無駄がなく、テストを実行しやすいアプリケーションを作ることができます。
他の記事でもっとわかりやすいものがあるとは思いますが、自分の理解のため再度概要を記述しておきます。
Atoms(原子)
アプリケーションを構成する最小の単位です。最も再利用されることが想定されています。ボタン
・アイコン
・フォーム
がatom
に該当します。基本的に原子同様、それ以上分解が不可能なコンポーネントです。
今回開発するアプリはatom
は独自のものを作成せず全てMaterial-ui
のデフォルトのもとを使用することにしました。(数が大量になることが予想され管理が大変なため)
Molecules(分子)
原子コンポーネントを組み合わせて作られる、比較的単純なコンポーネントです。こちらも再利用されることが想定されています。検索フォーム
・キャプション・いいねボタン付き画像
などが該当します。
Organisms(有機体)
有機体は複数の原子・分子コンポーネントが組み合わさった複雑なコンポーネントです。ここまで来ると再利用性はあまり求められていません。ヘッダー
・カード
・記事
・フッター
などが該当します。
Templates(テンプレート)
有機体をレイアウトに配置し、アプリケーションの画面を構成します。ただ、テンプレートの段階では実際のデータは注入せず、ワイヤーフレーム(スケルトン)の状態です。
Pages(完成形)
最終的に、ワイヤーフレームに実際の画像
・テキスト
などのデータを流し込み、アプリケーションが完成します。
Atomic Designを意識したファイル構成
Hooksで実装を行う前に、ファイル構成を考えておく必要があります。React公式ページのDOCS FAQ「ファイル構成」によれば、ファイル構成についてのベスト・プラクティスは存在せず、プロジェクトに応じて適したファイル構成を考える必要があるようです。(正直ここは定形テンプレートがあれば、考えずに済むのですが、そこは開発者に委ねられているようです。)
私は今回Figma
とStorybook
を使って、AtomicDesign
使用を意識した設計方針で行きたいと考えていたので、以下のようなファイル構成にしました。(非同期処理の管理はreact redux
を使います)
src/
├── actions/
│ ├── index.tsx
│ ├── index.d.tsx
│ ├── Auth.tsx
│ └── Album.tsx
├── common/
│ └── image/
├── components/
│ ├── 01_atoms/
│ ├── 02_molecules/
│ │ ├── LoginForm.tsx
│ │ ├── MainTitle.tsx
│ │ ├── PhotoList.tsx
│ │ ├── SearchForm.tsx
│ │ └── Spiner.tsx
│ └── 03_organisms/
│ ├── Auth.tsx
│ ├── Album.tsx
│ ├── Header.tsx
│ ├── ImageUpload.tsx
│ ├── PostForm.tsx
│ └── UpdateForm.tsx
├── containers/
│ ├── App.tsx
│ ├── Album.tsx
│ ├── Header.tsx
│ └── ImageUpload.tsx
├── constants/
│ ├── Auth.tsx
│ ├── Album.tsx
│ └── GlobalUITheme.tsx
├── store/
│ ├── reducers/
│ │ ├── Auth.tsx
│ │ └── Album.tsx
│ └── configureStore.tsx
├── index.tsx
└── serviceWorker.ts
molecules
に分類するかorganisms
に分類するか迷った節もあるので、自己流になってしまいましたが、デザインシステムから大きく乖離していることもないと思います。特に共通molecules
、organisms
が繰り返されるリストはどこに割り振るべきか悩みますね。
また、全てのコンポーネントに対してStorybookファイルを記述しているのですが、上記ファイル構成に記述すると冗長になってしまうので省略してあります。
コード設計方針
全体イメージ図
Presentational Component
- UIの**見た目(ビュー)**を担当
-
Atomic Design
においてはatom
・molecules
・organisms
部分のコードを記述 -
Container
から受け取ったデータ(props)を表示する(Container
にデータを渡すことはない) -
Container
から受け取ったコールバック関数を実行する - 基本的にはコンポーネントの状態を持たない(
Container
から状態を受け取る) - 自身のコンポーネントの状態を管理することも稀にある
- CSSも基本的にはコンポーネントに内包する(
CSS-in-JS
)。material-ui
を使用している場合はmakeStyles
・createStyles
でスタイルを記述できる。その他のコンポーネントはemotion
・styled-components
などで記述。
Container Component
- UIのライフサイクルの管理、機能を担当
-
Atomic Design
においてはtemplete
・pages
部分のコードを記述(ビューコンポーネントを組み合わせ、jsonなどのデータを流し込む) - コンポーネントの状態を持つ
-
redux
アクションをdispatch
(発送)する -
redux
におけるstore
の状態をmap
(対応付)する
Action
-
redux
によるstore
に格納された状態を変更するためのアクションを発行する - API通信などの非同期処理もここで行う(
middleware
にredux thunk
を使用した場合)
Store
-
action
が発行されたタイミングでaction type
をreducer
が分別し、store
に格納されている状態が変更される
Constant
- 定数を定義する
Common
- 画像、jsonデータなどアプリ全体で使用する共通項目を保存
コード実装
全てのコードは載せきれない・説明しきれないので、アルバムリスト表示
・画像投稿機能
の2機能に絞ってコードの記述を説明します。
また、今回はTypeScript
の記事ではないため、型定義に関してはany
でかなり緩めになっています。堅牢なアプリをつくるためにはコンポーネント間の値の受け渡し時にinterface
でしっかりと型定義を行う必要があります。
material-uiのカスタムテーマを定義
機能を実装する前に、material-ui
のMuiThemeProvider
を使用してでカスタムテーマを定義します。とは言っても、基本的にはデフォルトの設定を活かすつもりですので、カラーのメインカラー
・アクセントカラー
のみ定義します。
import { createMuiTheme, Theme } from '@material-ui/core/styles';
export const theme: Theme = createMuiTheme({
palette: {
primary: {
light: '#7383A2',
main: '#445577',
dark: '#002f6c',
contrastText: '#FFF',
},
secondary: {
light: '#FF6428',
main: '#FF9678',
dark: '#c41c00',
contrastText: '#FFF',
},
},
});
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './containers/App';
import * as serviceWorker from './serviceWorker';
import { MuiThemeProvider } from '@material-ui/core/styles';
import { theme } from './constants/GlobalUITheme';
ReactDOM.render(
<MuiThemeProvider theme={theme}>
<App />
</MuiThemeProvider>,
document.getElementById('root')
);
serviceWorker.unregister();
アルバムリスト表示
仕様
投稿したアルバムのリストを表示する機能です。
Figma
では再現しきれていなかったのですが、画像は複数枚登録可能で横スクロールして2枚目以降の画像を見ることができます。
Hooksによるライフサイクルの流れ
-
album
コンポーネントを読み込んだ際に、redux action
によりAPIリクエストが行われ、アルバムデータをAWSから取得 - コンテナコンポーネントにおいて状態管理、コールバック関数の定義
- コンテナコンポーネントにおいて
organisms
からAlbum
UIコンポーネントを呼び出し、データ・関数を受け渡し -
Album
UIコンポーネントにおいてmolecules
からPhotoList
UIコンポーネントを呼び出し、データを受け渡し
PhotoList
は、正確には画像1枚をmolecules
、画像がリストになったものをorganisms
としなければならないのですが、今回は画像リストをmolecules
としました。テストなどで不都合があれば修正するかもしれません。(カードリストも同様です)
While some organisms might consist of different types of molecules, other organisms might consist of the same molecule repeated over and over again. For instance, visit a category page of almost any e-commerce website and you’ll see a listing of products displayed in some form of grid.
Atomic Design Methodology から引用
Hooksによるコード
Container Component
import React, { useState, useEffect } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators, Dispatch } from 'redux';
import Spiner from '../components/02_molecules/Spiner';
import Album from '../components/03_organisms/Album';
import * as albumActions from '../actions/AlbumAPI';
// redux storeの値をstateにマッピング
const mapStateToProps = (state: any): any => ({
albumState: state.album,
});
// redux actionの読み込み
const mapDispatchToProps = (dispatch: Dispatch): any => {
return {
albumActions: bindActionCreators(albumActions, dispatch),
};
};
const AlbumContainer: React.FC<any> = ({ albumState, albumActions }) => {
// stateの定義
const [open, setOpen] = useState(false);
const [albums, setAlbums] = useState([]);
const [updateValue, setUpdateValue] = useState([]);
// albumコンポーネントが読み込まれた際に、アルバムデータ取得アクションを発行
useEffect(() => {
(async () => {
await albumActions.getAlbumListFunc();
})();
}, []);
// API通信で値取得後にredux stateをlocal stateに代入
useEffect(() => {
if (albumState.isLoaded) {
setAlbums(albumState.albums);
}
}, [albumState.isLoaded]);
// モーダルを開くコールバック関数
const handleClickOpen = () => {
setOpen(true);
};
// モーダルを閉じるコールバック関数
const handleClose = () => {
setOpen(false);
};
// アルバムを削除するアクションを発行するコールバック関数
const albumDelete = async (album: any): Promise<void> => {
await albumActions.deleteAlbumFunc(albums, album);
};
// アルバムを更新するアクションを発行するコールバック関数
const albumUpdate = async (album: any): Promise<void> => {
setOpen(false);
await albumActions.updateAlbumFunc(albums, album);
};
return albumState.isLoading ? (
<Spiner />
) : (
<>
<Album
open={open}
handleClickOpen={handleClickOpen}
handleClose={handleClose}
albums={albumState.albums}
albumDelete={albumDelete}
albumUpdate={albumUpdate}
setUpdateValue={setUpdateValue}
updateValue={updateValue}
/>
</>
);
};
export default connect(
mapStateToProps,
mapDispatchToProps
)(AlbumContainer);
Presentational Component Organisms
import React from 'react';
import _ from 'lodash';
import moment from 'moment';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
import {
Container,
Box,
Grid,
Card,
CardHeader,
CardContent,
CardActions,
Avatar,
IconButton,
Typography,
Dialog,
DialogContent,
DialogTitle,
} from '@material-ui/core';
import Delete from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import UpdateForm from './UpdateForm';
import PhotoList from '../02_molecules/PhotoList';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
card: {
maxWidth: 350,
minWidth: 350,
marginTop: '20px',
},
avatar: {
backgroundColor: theme.palette.grey[700],
},
})
);
const Album: React.FC<any> = ({
open,
handleClickOpen,
handleClose,
albums,
albumDelete,
albumUpdate,
setUpdateValue,
updateValue,
}) => {
const classes = useStyles();
return (
<>
<Container maxWidth="lg">
<Box mt={'70px'}>
<Grid
container
direction="row"
justify="space-evenly"
alignItems="flex-start"
>
{_.map(albums, (album: any) => {
return (
<Card className={classes.card}>
<CardHeader
avatar={
<Avatar aria-label="recipe" className={classes.avatar}>
{album.owner}
</Avatar>
}
action={
<IconButton aria-label="settings">
<MoreVertIcon />
</IconButton>
}
title={album.title}
subheader={moment(album.createdAt).format(
'YYYY-MM-DD HH:mm'
)}
/>
<PhotoList picture={album.picture} />
<CardContent>
<Typography
variant="body2"
color="textSecondary"
component="p"
>
{album.note}
</Typography>
</CardContent>
<CardActions disableSpacing>
<IconButton
aria-label="delete"
onClick={() => albumDelete(album)}
>
<Delete />
</IconButton>
<IconButton
aria-label="edit"
onClick={() => {
handleClickOpen();
setUpdateValue(album);
}}
>
<EditIcon />
</IconButton>
<Dialog
open={open}
onClose={handleClose}
aria-labelledby="form-dialog-title"
maxWidth="sm"
fullWidth
>
<DialogTitle id="form-dialog-title">
Update Album
</DialogTitle>
<DialogContent>
<UpdateForm
onSubmit={albumUpdate}
initialValues={updateValue}
/>
</DialogContent>
</Dialog>
</CardActions>
</Card>
);
})}
</Grid>
</Box>
</Container>
</>
);
};
export default Album;
Presentational Component Molecules
import React from 'react';
import _ from 'lodash';
import { S3Image } from 'aws-amplify-react';
import { GridList, GridListTile, GridListTileBar } from '@material-ui/core';
import IconButton from '@material-ui/core/IconButton';
import StarBorderIcon from '@material-ui/icons/StarBorder';
import { makeStyles, Theme, createStyles } from '@material-ui/core/styles';
const useStyles = makeStyles((theme: Theme) =>
createStyles({
gridList: {
flexWrap: 'nowrap',
transform: 'translateZ(0)',
},
title: {
color: theme.palette.grey[100],
},
titleBar: {
background:
'linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.3) 70%, rgba(0,0,0,0) 100%)',
},
dummyImage: {
maxWidth: '100%',
maxHeight: '100%',
backgroundColor: 'lightgray',
},
})
);
export const PhotoList: React.FC<{ picture: any[]; isMock?: boolean }> = ({
picture,
isMock,
}) => {
const classes = useStyles();
return (
<>
<GridList className={classes.gridList} cols={1}>
{picture.length === 0 ? (
<div className={classes.dummyImage} />
) : (
_.map(picture, (tile: any) => (
<GridListTile key={tile.id}>
{!isMock ? (
<S3Image
level="public"
imgKey={tile.file.key}
theme={{
photoImg: {
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'cover',
},
}}
/>
) : (
<img
src={tile.file.key}
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'cover',
}}
/>
)}
<GridListTileBar
title={tile.name}
classes={{
root: classes.titleBar,
title: classes.title,
}}
actionIcon={
<IconButton aria-label={`star ${tile.name}`}>
<StarBorderIcon className={classes.title} />
</IconButton>
}
/>
</GridListTile>
))
)}
</GridList>
</>
);
};
export default PhotoList;
アルバム投稿機能
仕様
複数枚の画像を投稿できる機能です。
ドラックアンドドロップアップロード、アップロード前プレビュー表示を実現させるために、react-dropzone
を使っています。もちろん投稿スペースをクリックして画像を選択することも可能です。
プレビュー時の画像は一時的にファイルパスとして、stateで状態管理することで実現しています。
Hooksによるライフサイクルの流れ
-
ImageUpload
コンポーネントを読み込んだ際に、「編集画面」の場合S3から画像を読み込み。redux stateにファイルパスを追加。「新規画面」のときは何もしない。 - 画像投稿フィールドに画像がドラックアンドドロップされた際、filesに画像パスを格納。プレビューを表示。
- リセットボタンが押されたとき、redux state・local state・プレビューパスを全て削除
Hooksによるコード
Container Component
import React, { useState, useEffect } from 'react';
import ImageUpload from '../components/03_organisms/ImageUpload';
const ImageUploadContainer: React.FC<any> = ({ input, reset, resetTriger }) => {
// stateの定義
const [updateValue, _] = useState(input);
const [files, setFiles] = useState([]);
// files stateの値が変更されたとき(画像がアップデートされたとき)、プレビューに画像パスをセット
useEffect(() => {
if (files.length === 0) {
if (updateValue.value) input.onChange(updateValue.value);
} else {
input.onChange(files);
}
files.forEach((file: any) => URL.revokeObjectURL(file.preview));
}, [files]);
// resetボタンが押されたとき、redux state、local state、プレビューに格納されたfileを空にする。
useEffect(() => {
setFiles([]);
input.onChange(files);
files.forEach((file: any) => URL.revokeObjectURL(file.preview));
resetTriger(false);
}, [reset]);
return (
<ImageUpload files={files} setFiles={setFiles} updateValue={updateValue} />
);
};
export default ImageUploadContainer;
Presentational Component Molecules
import React from 'react';
import { S3Image } from 'aws-amplify-react';
import { useDropzone } from 'react-dropzone';
import { Box, Typography } from '@material-ui/core';
import { createStyles, makeStyles } from '@material-ui/core/styles';
import ImageIcon from '@material-ui/icons/Image';
const useStyles = makeStyles(() =>
createStyles({
dropBox: {
marginTop: 20,
minHeight: '20px',
padding: 10,
border: 'dashed 3px lightgray',
color: 'lightgray',
textAlign: 'center',
'&:hover': {
color: 'gray',
border: 'dashed 3px gray',
cursor: 'pointer',
},
},
thumbsContainer: {
display: 'flex',
marginTop: 16,
overflow: 'scroll',
},
thumb: {
display: 'inline-flex',
borderRadius: 2,
border: '1px solid #eaeaea',
marginBottom: 8,
marginRight: 8,
width: 100,
height: 100,
padding: 4,
},
thumbInner: {
display: 'flex',
minWidth: 0,
overflow: 'hidden',
},
})
);
const ImageUpload: React.FC<any> = ({ files, setFiles, updateValue }) => {
const classes = useStyles();
// プレビューを作成する処理
const { getRootProps, getInputProps } = useDropzone({
accept: 'image/*',
onDrop: (acceptedFiles: any) => {
setFiles(
acceptedFiles.map((file: any) =>
Object.assign(file, {
preview: URL.createObjectURL(file),
})
)
);
},
});
// サムネイルを作成する処理
const thumbs = files.map((file: any) => (
<Box className={classes.thumb} key={file.name}>
<Box className={classes.thumbInner}>
<img
src={file.preview}
style={{ width: 'auto', height: '100%', display: 'block' }}
alt="preview"
/>
</Box>
</Box>
));
// 「新規」ではなく「編集」の場合AWS S3にアップロードされている画像データを読み込み
const updateThumbs = updateValue.value
? updateValue.value.map((image: any) => (
<Box className={classes.thumb} key={image.name}>
<Box className={classes.thumbInner}>
<S3Image
level="public"
imgKey={image.file.key}
theme={{
photoImg: {
display: 'block',
width: 'auto',
height: 100,
},
}}
/>
</Box>
</Box>
))
: null;
return (
<>
<div {...getRootProps({ className: 'dropzone' })}>
<input {...getInputProps()} />
<Box className={classes.dropBox}>
<Typography>
Drag and drop some files here, or click to select files
</Typography>
<ImageIcon fontSize="large" />
<Box className={classes.thumbsContainer}>
{files.length === 0 ? updateThumbs : thumbs}
</Box>
</Box>
</div>
</>
);
};
export default ImageUpload;
まとめ
コードファイルが断片的になってしまったので、全体像が若干わかりづらくなってしまいましたが、少しでもHooksの雰囲気を掴んでもらえたら幸いです。
Hooks自体は特段難しいものではなく(もちろん全てのAPIをつかいこなすのはそれなりに大変ですが)クラスコンポーネントでのライフサイクル管理の概要が理解できていればすんなり理解できるようになると思います。むしろコードの記述量が少なくなる分、わかりやすいといえるのではないでしょうか。
AtomicDesignについてはatom
・molecules
・organisms
の仕分け方が難しく感じました。どうしても自分の都合の良い風に概念をねじ曲げてしまい自己流になってしまいがちですが、運用するうちに不都合を訂正し、研ぎ澄まされていけばいいなと考えています。いまいちしっくりとこなかったなと思われる方は、ブラッド・フロスト氏の原文Atomic Design Methodology を是非読んでもらいたいです。(きれいに概要がまとまっているので読み物としても面白いです。)
今回はフロント部分(コンポーネント)の解説に注力するため、Reduxの最重要部分action
による非同期通信
の部分にはあえて触れませんでした。アドベントカレンダーの次回は「AWS Amplify Auth
によるユーザー認証機能」、「AWS Amplify AppSync
(GraphQL)によるAPI通信」などのサーバサイド実装についてredux action
reducer
store
と絡めてまとめる予定です。