35
26

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 3 years have passed since last update.

React #2Advent Calendar 2019

Day 9

ぬるく始めてきっちり作るTypeScript+React+SSR構成入門

Last updated at Posted at 2019-12-08

この記事はReact #2 Advent Calendar 2019の9日目の記事です。

ぬるくとは書いたものの、前提としてReact、webpack、babel、TypeScriptに関してある程度の知識がある前提で説明します。(基礎知識に関しては補足にリンクをまとめました)
さらにwebpackのビルド設定の難易度も高いせいか、なかなかTypeScript+React+SSRの最小サンプルがなかったので作ってみました。TypeScript+Reactに関して局所的なサンプルは多いのですが、ちゃんと全部理解して実務で使えている人はどれだけいるんだろうか・・・?
(NextJSもcreate-react-appも使っていません)
今回のGitHubサンプル

正直な所、私自身はそこまでTypeScriptが好きではない1のですが、大規模なプロジェクトになるとeslintに加え関数の引数チェック(typo、引数忘れなど)や型定義があることの安心感があるので導入しているプロジェクトは増えているように感じています。

今回執筆するにあたって@about_hiroppyさんのssr-sampleのリポジトリを参考にさせていただきました。
(TypeScript+SSR+dynamic-importしてるのがこのサンプルくらいしかなかった)
参考:hiroppy/ssr-sample

全部入りでとても勉強になるリポジトリなのですが、TypeScript+React+SSR構成としてはApollo(Graph QL)やperformanceチェックのツールなど構成には必須ではないツールも含まれており(入れておくと便利ですけど・・・)、最小構成として理解しやすくする上でそのへんのツール周りを除外して今回は最小限の構成でサンプルを作成しました。

プロジェクト構成

今回のサンプルで使っている構成(パッケージ)

  • TypeScript(@babel/preset-typescript)
  • React
  • NodeJS(express+nodemon+@babel/node)
  • webpack+HMR(webpack-dev-middleware+webpack-hot-middleware)
  • babel
  • redux+react-redux+redux-thunk+typescript-fsa
  • react-helmet-async
  • dynamic-import(loadble-component)
  • eslint
  • material-ui

実際は必要だけど、今回のサンプルでは説明しないもの:

  • DB周り:NodeJSだとMongoDB(Mongoose)を使うことが多いですが、モデル周りの実装は今回省略してます。
  • Form周り:React-Reduxの最新版(最新版だとRedux周りのHookが使える)に追従してるForm系のライブラリはformikかreact-final-formですが、この辺もmaterial-uiと組み合わせるとUIの実装で煩雑になるので触れてません

mongooseとreact-final-form込みのテンプレはこちら
https://github.com/teradonburi/typescript-react-template

ビルド設定

package.jsonです。バックエンドで使っているライブラリ郡は本番のパッケージインストール(yarn --prod)で使うためdependenciesに入れてます。それ以外はdevDependenciesに入れてます。
サンプルの起動はyarn devで行います。
npm-run-allパッケージで並列でyarn dev-serveryarn type-checkを実行しています。
Typescriptのビルドは@babel/preset-typescriptで行っていますので、型チェックはtype-checkの方で行っています。
(それ以外でTypeScriptをビルドする方法だとts-loaderを使うかtscコマンドで直接やるしかない)
@babel系はbabelのランタイムとpresetとプラグインです。
@types系はtypescriptの型定義です。ライブラリ本体が型定義を包括しているものは追加していません。(redux、redux-thunk)
react-helmet-asyncは内部的にreact-helmetを使っているため、@types/react-helmetを使っています。

package.json
{
  "name": "typescript-react",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "dev": "run-p dev-server type-check",
    "dev-server": "nodemon ./server/server.tsx",
    "type-check": "tsc -w",
    "lint": "eslint . --ext .ts,.tsx"
  },
  "devDependencies": {
    "@babel/cli": "^7.7.4",
    "@babel/core": "^7.7.4",
    "@babel/plugin-proposal-optional-chaining": "^7.7.5",
    "@babel/preset-env": "^7.7.4",
    "@babel/preset-react": "^7.7.4",
    "@babel/preset-typescript": "^7.7.4",
    "@loadable/babel-plugin": "^5.11.0",
    "@loadable/component": "^5.11.0",
    "@loadable/webpack-plugin": "^5.7.1",
    "@types/express": "^4.17.2",
    "@types/loadable__component": "^5.10.0",
    "@types/loadable__server": "^5.9.1",
    "@types/node": "^12.12.14",
    "@types/react": "^16.9.13",
    "@types/react-dom": "^16.9.4",
    "@types/react-helmet": "^5.0.14",
    "@types/react-redux": "^7.1.5",
    "@types/react-router-dom": "^5.1.3",
    "@typescript-eslint/eslint-plugin": "^2.10.0",
    "@typescript-eslint/parser": "^2.10.0",
    "axios": "^0.19.0",
    "babel-loader": "^8.0.6",
    "core-js": "^3.4.5",
    "eslint": "^6.7.2",
    "eslint-loader": "^3.0.3",
    "eslint-plugin-react": "^7.17.0",
    "nodemon": "^2.0.1",
    "redux-devtools": "^3.5.0",
    "redux-thunk": "^2.3.0",
    "regenerator-runtime": "^0.13.3",
    "typescript": "^3.7.2",
    "typescript-fsa": "^3.0.0",
    "typescript-fsa-reducers": "^1.2.1",
    "webpack": "^4.41.2",
    "webpack-cli": "^3.3.10",
    "webpack-dev-middleware": "^3.7.2",
    "webpack-hot-middleware": "^2.25.0"
  },
  "dependencies": {
    "@babel/node": "^7.7.4",
    "@loadable/server": "^5.11.0",
    "@material-ui/core": "^4.7.1",
    "@material-ui/icons": "^4.5.1",
    "express": "^4.17.1",
    "npm-run-all": "^4.1.5",
    "react": "^16.12.0",
    "react-dom": "^16.12.0",
    "react-helmet-async": "^1.0.4",
    "react-redux": "^7.1.3",
    "react-router-dom": "^5.1.2",
    "redux": "^4.0.4"
  }
}

nodemon.jsonにnodemonコマンドのサーバの起動設定を記述します。
今回はバックエンドもbabelでビルドして起動するため、@babel/nodeにてビルド&起動を行います。
verbose: trueにするとログが全部出るので、ビルドの速度を上げたい場合はfalseにします。

nodemon.json
{
  "verbose": true,
  "ignore": ["dist", "client"],
  "exec": "BABEL_ENV=node babel-node --inspect --extensions \".ts,.tsx\"",
  "ext": "ts,tsx,json"
}

babelの設定

babelの設定です(babel.config.js)。フロントエンドとバックエンド両方で使います。
BABEL_ENVの環境変数でフロントエンドとバックエンドどちらのビルドか切り替えを行っています。
フロントエンドからの利用はwebpackのbabel-loaderから使用されます。
バックエンドからの利用はnodemonの起動時のBABEL_ENV=node @babel/nodeから使用しています。
@babel/plugin-proposal-optional-chainingだけオプショナルチェーンの文法を使うために入れていますが、
その他のpresetとプラグインは必須です。

  • @babel/preset-env:各ブラウザ&NodeJSでES文法を共通化(Polyfill)する
  • @babel/preset-typescript:TypeScriptのビルド
  • @babel/preset-react:Reactのビルド
  • @loadable/babel-plugin:loadable-componentのプラグイン
babel.config.js
module.exports = (api) => {
  const web = process.env.BABEL_ENV !== 'node'

  api.cache(true)

  return {
    presets: [
      [
        '@babel/preset-env',
        {
          useBuiltIns: web ? 'usage' : undefined,
          corejs: web ? 'core-js@3' : false,
          targets: !web ? { node: 'current' } : undefined,
        },
      ],
      '@babel/preset-typescript',
      '@babel/preset-react',
    ],
    plugins: [
      '@babel/plugin-proposal-optional-chaining',
      '@loadable/babel-plugin',
    ],
  }
}

webpackの設定

webpackのビルド設定です(webpack.config.js)。
publicパスは/public/を指定しています。
entryにwebpack-hot-middlewareのHMR設定とcore-jsのpolyfillを追加しています。
./client/index.tsxが実際のフロントエンドのエントリーポイントです。
今回はexpressの公開フォルダとwebpack-dev-middlewareで公開するフォルダをpublicフォルダ以下に合わせています。
loaderにbabel-loadereslint-loaderを使用しています。
(eslint-loaderに関してはビルドが重く感じたら外しても良いかもしれません)
プラグインはHMR用のwebpack.HotModuleReplacementPluginとloadable-componetのwebpackプラグインであるLoadablePluginを追加しています。

webpack.config.js
const path = require('path')
const webpack = require('webpack')
const LoadablePlugin = require('@loadable/webpack-plugin')

module.exports = {
  mode: 'development', // 開発モードビルド
  // ビルド対象のアプリケーションのエントリーファイル
  entry: [
    'webpack-hot-middleware/client', // HMR debug用
    'core-js/modules/es.promise', // IEでPromiseを使えるようにする
    'core-js/modules/es.array.iterator', // IEでArrayのiteratorを使えるようにする
    './client/index.tsx'
  ], 
  devtool: 'inline-cheap-module-source-map', // ソースマップを出力するための設定、ソースマップファイル(.map)が存在する場合、ビルド前のソースファイルでデバッグができる
  output: {
    path: path.resolve(__dirname, 'dist'), // 出力するフォルダ名(dist)
    filename: 'bundle.js', // 出力するメインファイル名
    publicPath: '/public/' // ホスティングするフォルダ
  },
  resolve: {
    modules: ['node_modules'],
    extensions: ['.js', '.ts', '.tsx']
  },
  module: {
    rules: [
      { 
        test: /\.ts(x?)$/, // .ts .tsxがbabelのビルド対象
        exclude: /node_modules/, // 関係のないnode_modulesはビルドに含めない
        use: [
          {loader: 'babel-loader'}, // babel
          {loader: 'eslint-loader'}, // eslint
        ]
      }
    ]
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(), // HMR debug用
    new LoadablePlugin(),
  ],
}

型チェックの設定

typescriptの型チェックの設定をtsconfig.jsonに指定します。(実際のビルドはbabelで行うため、型チェックのみ)
"jsx": "react"を指定することでJSX、TSXファイルを正しく認識します。
"noEmit":trueにしてtscコマンドによるファイル出力を無効化します。
tsc -wコマンドで型チェックを行います。

tsconfig.json
{
  "compilerOptions": {
    "target": "es2019",                          
    "module": "commonjs",                  
    "jsx": "react",              
    "noEmit": true,                     
    "isolatedModules": true,              
    "strict": true,                          
    "noImplicitAny": true,               
    "strictNullChecks": true,              
    "noImplicitThis": true,             
    "moduleResolution": "node",           
    "baseUrl": "./",                       
    "typeRoots": ["types", "node_modules/@types"], 
    "paths": {"interface": ["types/interface"]},  
    "esModuleInterop": true                   
  },
  "exclude": [
    "dist",
    "node_modules",
    "babel.config.js",
    "webpack.config.js"
  ]
}

型定義ファイル

プロジェクト内で横断して使われる型定義などは型定義ファイルを作成し、そこに記述します。
(今回はtypes/inteface.d.tsに記述)
横断して使用されるため、型の変更、追加をする際は慎重に行う必要があります。2

interface.d.ts
export namespace model {
  export interface User {
    gender: string;
    name: {
      first: string;
      last: string;
    };
    email: string;
    picture: {
      thumbnail: string;
    };
  }
}

参照できるようにtsconfig.jsonにtypes/interface.d.tsのパスを指定します。

tsconfig.json
{
  "compilerOptions": {
    "baseUrl": "./",                      
    "typeRoots": ["types", "node_modules/@types"],  
    "paths": {"interface": ["types/interface"]}  
  }
}

ESlint

TypeScriptのlintも@typescript-eslint/parser@typescript-eslint/eslint-pluginを使うことでESLintで行うことができます。
eslintパッケージ、reactの文法チェックもするためeslint-reactパッケージも必須です。
webpack時にeslintを実行するためeslint-loaderも導入しています。
.eslintrc.jsの設定は次のようになっています。
parserには@typescript-eslint/parserを指定します。
ecmaFeaturesのjsxはtrueにします。
extendsとpluginsにtypescriptとreactの指定をします。

.eslintrc.js
module.exports = {
  'parser': '@typescript-eslint/parser',
  'env': {
    'browser': true, // ブラウザ
    "node": true,
    'es6': true
  },
  // 拡張
  "extends": [
    "eslint:recommended",
    'plugin:react/recommended',
    'plugin:@typescript-eslint/recommended',
  ],
  'parserOptions': {
    'ecmaFeatures': {
      'experimentalObjectRestSpread': true, // spread演算子有効
      'jsx': true, // JSX文法有効
    },
    'sourceType': 'module'
  },
  'settings': { 
    'react': { 'version' : 'detected' } // Reactのバージョンを自動特定
  },
  // プラグイン
  'plugins': [
    'react',
    '@typescript-eslint',
  ],
  'rules': {
    /* 細かいルール */
  }
}

.eslintignoreにESLint対象外のファイルを指定します。
ビルド設定ファイルはチェックしてほしくないので意図的に外しています。

.eslintignore
babel.config.js
webpack.config.js

VSCodeをエディターとして使っている場合は編集ファイル保存時にeslintを実行させ、
自動的に文法ルールを適用するようにすると修正がはかどります。

.vscode/settings.json
{
  "eslint.nodePath": "./node_modules/eslint",
  "eslint.run": "onSave",
  "eslint.autoFixOnSave": true,
  "eslint.alwaysShowStatus": true,
  "eslint.enable": true,
  "eslint.validate": [
    "javascript",
    "javascriptreact",
    {
      "language": "typescript",
      "autoFix": true
    },
    {
      "language": "typescriptreact",
      "autoFix": true
    }
  ],
  "typescript.tsdk": "node_modules/typescript/lib"
}

処理の流れ

  1. サーバの起動
  • nodemon => babel-node => webpack-dev-middleware(webpackでフロントエンドのビルド)
  1. SSR
  • ブラウザからサーバアクセス => URLに基づいてReduxストアを初期化しReact-Routerでルーティングし、React Componentをレンダリングしhtmlを生成(SSR) => 生成したhtmlにビルドしたフロントエンドのbundle.jsを埋め込みレスポンス
  1. CSR
  • ブラウザでbundle.jsを読み込みJSを実行 => React Componentをレンダリング(CSR) => react-reduxでreduxのaction経由でAPIコールし、reduxストアにアクセス => データ更新し、再度レンダリング(CSR)

SSR

expressフレームワークでサーバの起動を行います。(server.tsx)
今回はhttp://localhost:8080で起動します。

server.tsx
/* eslint-disable @typescript-eslint/no-var-requires */
import path from 'path'
import { Request, Response, NextFunction } from 'express'
import express from 'express'
import React from 'react'
import { renderToString } from 'react-dom/server'
import { ChunkExtractor } from '@loadable/server'
import reducer from '../client/reducer/reducer'
import { model } from 'interface'

const app = express()

// サーバを起動
app.listen(8080, () => console.log('Server started http://localhost:8080'))

サーバ起動時にフロントエンドのwebpackビルドをwebpack-dev-middlewareで行います。
同時にHMRの設定をwebpack-hot-middlewareで行います。

server.tsx
if (process.env.NODE_ENV !== 'production') {
  /* eslint-disable @typescript-eslint/no-var-requires */
  const webpackConfig = require('../webpack.config')
  const webpackDevMiddleware = require('webpack-dev-middleware')
  const webpackHotMiddleware = require('webpack-hot-middleware')
  const webpack = require('webpack')
  /* eslint-enable @typescript-eslint/no-var-requires */

  // サーバ起動時、src変更時にwebpackビルドを行う
  const compiler = webpack(webpackConfig)

  // バックエンド用webpack-dev-middleware
  app.use(
    webpackDevMiddleware(compiler, {
      publicPath: webpackConfig.output.publicPath,
      // 書き込むファイルの判定
      writeToDisk(filePath: string) {
        return /loadable-stats/.test(filePath)
      },
    }),
  )
  // HMR
  app.use(webpackHotMiddleware(compiler))
}

app.use(express.static(path.join(__dirname, '../public')))

apiのパス以外のリクエストが来た場合にSSRを行います。
パスに合わせて、必要なデータを取得してReduxストアの初期化を行います。
パスに合わせてルーティングし、Reactコンポーネントのレンダリングを行います。
react-helmet-asyncを使い、ページごとのtitleやmetaデータを埋め込みます。
(react-helmetでなく、react-helmet-asyncを使っているのはreact-helmetがdeprecatedのcomponentWillMountをまだ直していないからです)
extractor.getScriptTags()でフロントエンドのビルド済みのbundle.jsを埋め込みます。

server.tsx
// Redux
import { createStore } from 'redux'
import { Provider } from 'react-redux'
// StaticRouter
import { StaticRouter } from 'react-router-dom'
import { Router } from '../client/Router'
// Material-UI SSR
import { ServerStyleSheets, ThemeProvider } from '@material-ui/styles'
import theme from '../client/theme'
// Helmet
import { HelmetProvider } from 'react-helmet-async'
import { HelmetData } from 'react-helmet'


const nodeStats = path.resolve(
  __dirname,
  '../dist/loadable-stats.json',
)

app.get(
  '*',
  async (req: Request, res: Response) => {

    // 疑似ユーザ作成(本来はDBからデータを取得して埋め込む)
    const initialData = req.url === '/' ? { user: {users} } : {}
    // Redux Storeの作成(initialDataには各Componentが参照するRedux Storeのstateを代入する)
    const store = createStore(reducer, initialData)

    const context = {}

    // ChunkExtractorでビルド済みのチャンク情報を取得
    const extractor = new ChunkExtractor({ statsFile: nodeStats })

    // CSS(MUI)
    const sheets = new ServerStyleSheets()

    const helmetContext: {helmet?: HelmetData} = {}

    const App: React.SFC = () => (
      sheets.collect(
        <HelmetProvider context={helmetContext}>
          <ThemeProvider theme={theme}>
            <Provider store={store}>
              <StaticRouter location={req.url} context={context}>
                <Router />
              </StaticRouter>
            </Provider>
          </ThemeProvider>
        </HelmetProvider>
      )
    )

    // loadable-stats.jsonからフロントエンドモジュールを取得する
    const jsx = extractor.collectChunks(<App />)

    // SSR
    const html = renderToString(jsx)

    // Material-UIのCSSを作成
    const MUIStyles = sheets.toString()

    // Helmetで埋め込んだ情報を取得し、そのページのheaderに追加する
    const { helmet } = helmetContext

    res.set('content-type', 'text/html')
    res.send(`<!DOCTYPE html>
<html lang='ja'>
<head>
<meta charset='utf-8' />
<meta name='viewport' content='width=device-width, initial-scale=1' />
${extractor.getLinkTags()}
${extractor.getStyleTags()}
${helmet ? helmet.title.toString() + helmet.meta.toString() + helmet.link.toString() + helmet.script.toString() : ''}
<style id='jss-server-side'>${MUIStyles}</style>
</head>
<body>
  <div id="root">${html}</div>
  <script id="initial-data">window.__STATE__=${JSON.stringify(initialData)}</script>
  <!-- CSR -->
  ${extractor.getScriptTags()}
</body>
</html>`)
  },
)

NodeJSのエラーハンドリングとAPIのエラーハンドリングを定義して、
userを返すAPIを定義します。

server.tsx
// APIエラーハンドリング
const wrap = (fn: (req: Request, res: Response, next?: NextFunction) => Promise<Response | undefined>) => (req: Request, res: Response, next?: NextFunction): Promise<Response | undefined> => fn(req, res, next).catch((err: Error) => {
  console.error(err)
  if (!res.headersSent) {
    return res.status(500).json({message: 'Internal Server Error'})
  }
})
// NodeJSエラーハンドリング
process.on('uncaughtException', (err) => console.error(err))
process.on('unhandledRejection', (err) => console.error(err))

// 今回はダミーデータ、本来はDBから取得する
const users: model.User[] = [{gender: 'male', name: {first: 'テスト', last: '太郎'}, email: 'test@gmail.com', picture: {thumbnail: 'https://avatars1.githubusercontent.com/u/771218?s=460&v=4'}}]
app.get('/api/users', wrap(async (_: Request, res: Response): Promise<Response | undefined> => {
  return res.json(users)
}))

CSR

SSRでレンダリング返されたあと、ブラウザ側でbundle.jsが読み込まれ、エントリーポイント(index.tsx)で初期化処理を行います。
バックエンドでwindow.__STATE__に埋め込んだ、reduxストアに埋め込んだ初期化データを取得してクライアントサイド側のreduxストアを初期化します。(SSRの初期化データを代入する)
jss-server-sideに埋め込んだmaterial-uiのスタイルも初回レンダリング後に削除します。(同じスタイルがクライアントサイドでも生成され、重複するため)

index.tsx
import React from 'react'
import ReactDOM from 'react-dom'
import { createStore, applyMiddleware, compose } from 'redux'
import { Provider } from 'react-redux'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from '@material-ui/styles'
import { loadableReady } from '@loadable/component'
import client from 'axios'
import thunk from 'redux-thunk'
import { HelmetProvider } from 'react-helmet-async'

import reducer from './reducer/reducer'
import theme from './theme'

declare global {
  interface Window {
    __REDUX_DEVTOOLS_EXTENSION_COMPOSE__: <R>(a: R) => R;
    __STATE__: {};
  }
  interface NodeModule {
    hot: {
      accept(dependency: string, callback: () => void): void;
    };
  }
}

// バックエンドで埋め込んだRedux Storeのデータを取得する
const initialData = window.__STATE__ || {}
delete window.__STATE__
const dataElem = document.getElementById('initial-data')
if (dataElem && dataElem.parentNode) dataElem.parentNode.removeChild(dataElem)

// redux-devtoolの設定
const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose
// axiosをthunkの追加引数に加える
const thunkWithClient = thunk.withExtraArgument(client)
// redux-thunkをミドルウェアに適用
const store = createStore(
  reducer,
  initialData,
  composeEnhancers(applyMiddleware(thunkWithClient))
)

const renderMethod = module.hot ? ReactDOM.render : ReactDOM.hydrate

const App: React.FC = ({ children }) => {
  React.useEffect(() => {
    const jssStyles = document.getElementById('jss-server-side')
    if (jssStyles && jssStyles.parentNode) {
      // フロントエンドでもMaterial-UIのスタイルを再生成するため削除する
      jssStyles.parentNode.removeChild(jssStyles)
    }
  }, [])

  return (
    <HelmetProvider>
      <ThemeProvider theme={theme}>
        <Provider store={store}>
          <BrowserRouter>{children}</BrowserRouter>
        </Provider>
      </ThemeProvider>
    </HelmetProvider>
  )
}

const render = async (): Promise<void> => {
  const { Router } = await import(/* webpackMode: "eager" */ './Router')

  renderMethod(
    <App>
      <Router />
    </App>,
    document.getElementById('root')
  )
}

// loadable-componentの準備待ち
loadableReady(() => {
  render()
})

// webpack-hot-middlewareのHMR時に再レンダリング
if (module.hot) {
  module.hot.accept('./Router', () => {
    render()
  })
}

フロントエンド、バックエンド共通のルーティングコンポーネントです。
react-routerによりパスに対応するコンポーネントがレンダリングされます。
loadable-componentによりdynamic-import(遅延import)を行っています。
これにより、importの同期読み込み待ちを待たずに済むため、レンダリングの速度が向上します。

Router.tsx
import React from 'react'
import { Route, Switch } from 'react-router-dom'
import loadable from '@loadable/component'

const UserPage = loadable(() => import(/* webpackPrefetch: true, webpackChunkName: 'userpage' */ './UserPage'))
const NotFound = loadable(() => import(/* webpackPrefetch: true, webpackChunkName: 'notfound' */ './NotFound'))

export const Router: React.SFC = () => (
  <Switch>
    <Route exact path='/' render={(props): JSX.Element => <UserPage bgcolor='#eeeeee' {...props} />} />
    {/* それ以外のパス */}
    <Route component={NotFound} />
  </Switch>
)

UserPage.tsxでuserの情報をレンダリングします。
ConnectedPropsでconnectの型を作成し、Propsの型に結合します。
ClassesでwithStyleの属性の型を作成し、同様にPropsの型に結合します。
UserPagePropsの型は外部から渡されるpropsの型を定義しました。
UserPageコンポーネントのstateの型はUserPageStateに定義しました。

UserPage.tsx
import React from 'react'
import { connect, ConnectedProps } from 'react-redux'
import { load } from './action/user'
import { Link } from 'react-router-dom'
import { Helmet } from 'react-helmet-async'
import { model } from 'interface'

import { withStyles } from '@material-ui/core/styles'
import {
  AppBar,
  Toolbar,
  Avatar,
  Card,
  CardContent,
  Button,
  Dialog,
  DialogTitle,
  DialogContent,
} from '@material-ui/core'
import { Email } from '@material-ui/icons'
import { orange } from '@material-ui/core/colors'
interface ReduxState {
  user: { users: model.User[] };
}

// connectでwrap
const connector = connect(
  // propsに受け取るreducerのstate
  (state: ReduxState) => ({
    users: state.user.users,
  }),
  // propsに付与するactions
  { load }
)

interface UserPageProps {
  bgcolor: string;
}
interface UserPageState {
  user: model.User | null;
}

interface Classes {
  classes: {
    root: string;
    card: string;
    name: string;
    gender: string;
  };
}

type PropsFromRedux = ConnectedProps<typeof connector>;
// propsの型
type Props = PropsFromRedux & Classes & UserPageProps;
// stateの型
type State = UserPageState;

class UserPage extends React.Component<Props, State> {
  constructor(props: Props) {
    super(props)
    this.state = {
      user: null,
    }
    this.handleClickOpen = this.handleClickOpen.bind(this)
    this.handleRequestClose = this.handleRequestClose.bind(this)
  }

  componentDidMount(): void {
    // user取得APIコールのactionをキックする
    // SSR時のreduxストアデータがあれば、本来呼ぶ必要がないが、今回はサンプルのためキックする
    this.props.load()
  }

  handleClickOpen(user: model.User): void {
    this.setState({ user })
  }

  handleRequestClose(): void {
    this.setState({ user: null })
  }

  render(): JSX.Element {
    const { users, classes } = this.props

    // 初回はnullが返ってくる(initialState)、処理完了後に再度結果が返ってくる
    console.log(users)
    return (
      <div>
        <Helmet>
          <title>ユーザページ</title>
          <meta name="description" content="ユーザページのdescriptionです" />
        </Helmet>
        <AppBar position="static" color="primary">
          <Toolbar classes={{ root: classes.root }}>タイトル</Toolbar>
        </AppBar>
        {/* 配列形式で返却されるためmapで展開する */}
        {users &&
          users.map(user => {
            return (
              // ループで展開する要素には一意なkeyをつける(ReactJSの決まり事)
              <Card key={user.email} style={{ marginTop: '10px' }}>
                <CardContent className={classes.card}>
                  <Avatar src={user.picture.thumbnail} />
                  <p className={classes.name}>
                    {'名前:' + user?.name?.first + ' ' + user?.name?.last}
                  </p>
                  <p className={classes.gender}>
                    {'性別:' + (user?.gender == 'male' ? '男性' : '女性')}
                  </p>
                  <div style={{ textAlign: 'right' }}>
                    <Button
                      variant="contained"
                      color="secondary"
                      onClick={(): void => this.handleClickOpen(user)}
                    >
                      <Email style={{ marginRight: 5, color: orange[200] }} />
                      Email
                    </Button>
                  </div>
                </CardContent>
              </Card>
            )
          })}
        <Link style={{display: 'block', marginTop: 30}} to="/hoge">存在しないページ</Link>
        {this.state.user && (
          <Dialog
            open={!!this.state.user}
            onClose={(): void => this.handleRequestClose()}
          >
            <DialogTitle>メールアドレス</DialogTitle>
            <DialogContent>{this.state.user?.email}</DialogContent>
          </Dialog>
        )}
      </div>
    )
  }
}

export default withStyles(theme => ({
  root: {
    fontStyle: 'italic',
    fontSize: 21,
    minHeight: 64,
    // 画面サイズがモバイルサイズのときのスタイル
    [theme.breakpoints.down('xs')]: {
      fontStyle: 'normal',
    },
  },
  card: {
    background: (props: UserPageProps): string => `${props.bgcolor}`, // props経由でstyleを渡す
  },
  name: {
    margin: 10,
    color: theme.palette.primary.main,
  },
  gender: {
    margin: 10,
    color: theme.palette.secondary.main, // themeカラーを参照
  },
}))(connector(UserPage))

connectからreduxのactionを参照できるようにしています。
reduxのactionはaction/user.tsにて定義しています。
typescript-fsaを使うことで、actionの型定義のフォーマットを統一することができます。

action/user.ts
import actionCreatorFactory from 'typescript-fsa'
import { AxiosInstance } from 'axios'
import { Store } from 'redux'
import { model } from 'interface'

const actionCreator = actionCreatorFactory()

// typescript-fsaで<params,result,error>の型を定義
export const loadAction = actionCreator.async<{}, {users: model.User[]}, {error: Error}>('user/LOAD')

// actionの定義
export function load() {
  // clientはaxiosの付与したクライアントパラメータ
  // 非同期処理をPromise形式で記述できる
  return (dispatch: Store['dispatch'], getState: Store['getState'], client: AxiosInstance): Promise<void> => {
    return client
      .get('/api/users')
      .then(res => res.data)
      .then(users => {
        // 成功
        dispatch(loadAction.done({
          params: {},
          result: { users },
        }))
      })
      .catch(error => {
        // 失敗
        dispatch(loadAction.failed({params: {}, error}))
      })
  }
}

reducer/user.tsでreduxストアのパラメータを更新します。
typescript-fsa-reducersを使うことでcaseメソッドでactionの種別を分岐することができます。

user.ts
import { reducerWithInitialState } from 'typescript-fsa-reducers'
import { model } from 'interface'
import { loadAction } from '../action/user'


interface ReduxState {
  users?: model.User[];
}

// 初期化オブジェクト
const initialState: ReduxState = {
  users: [],
}

const reducer = reducerWithInitialState(initialState)
    .case(loadAction.done, (state, data) => ({...state, users: data.result.users}))
    .case(loadAction.failed, (state, data) => ({...state, error: data.error}))

export default reducer

reducer.tsにてcombineReducersを使い、複数のreducerをまとめます(今回は一つですが)

reducer.ts
import { combineReducers } from 'redux'
// 作成したuserのreducer
import user from './user'

// 作成したreducerをオブジェクトに追加していく
// combineReducersで1つにまとめてくれる
export default combineReducers({
  user,
})

Reactの型定義

おさえておきたいReactの型です。

  • React.Component< P, S >: Reactのクラスコンポーネントです。P、Sはジェネリックスで任意のpropsとstateの型を渡すことができます
  • React.SFC: Reactの状態を持たない関数コンポーネントです。
  • React.FC: Reactの状態を持つ関数コンポーネント(Hooksありの関数コンポーネント)です。
  • JSX.Element: JSXのレンダリング結果の型です。
  • JSX.IntrinsicElements: JSXの基本HTMLElementの型です。

参考:TypeScript Deep Dive日本語訳

補足

TypeScriptを使っていないReactJS+webpackでのプロジェクト構成に関してはすでに以前書いているので以下の記事を参考にしてください
参考:ReactJSで作る今時のSPA入門(基本編)

そもそもbabelとかwebpackとかCommonJSとかECMAScriptについてよくわかってない人はまとめたので以下を参考にしてください
参考:JSのモジュールとbabelとwebpackとは何かまとめてみる(初心者向け)

TypeScriptの基礎文法に関しては以下にまとめました。
参考:TypeScript練習帳(入門)

  1. ただでさえ複雑なwebpackのビルド構成がTypeScriptを入れることでさらに複雑になるのと、複雑なジェネリックスの型パズルを考えるのに開発リソースを使いたくない(プリミティブな型はいいのですけどね・・・)というのがあります。静的型付言語に対して後付けの型付なため、不完全な面が否めない(プログラマの実装に委ねられている)のと型定義と実装が乖離したときにメンテナンスが厄介という面もあります。とはいえ、TypeScript 2.6以降の型推論が優秀なのと、TypeScriptでないとできないチェック機能や補完が便利なため、トレードオフな感じがあります。

  2. 型定義を間違えるとテストの実装を間違えるのと同様に仕様が間違ってるのか、型定義が間違ってるのかわからなくなります(特にジェネリックスを使っている複雑な型ほど)。故に型定義を間違える罪は重い・・・

35
26
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
35
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?