Kosaku Kurino

Kosaku Kurino

【Next.js】ReactエンジニアがNext.jsを触り始めて、なんとなく使えるようになるまでの軌跡

まえがき

普段私はReact.jsでWebアプリを開発しているのだが、ふとサーバーサイドレンダリングで表示させてみたいと思い、Qiitaで調べた。Next.jsというまさにこれだというフレームワークを見つけ、勉強し始めた。 私がNext.jsで開発できるようになるまでに、これってどうすれば?と思う箇所が多かったので、振り返りも込めてまとめることにした。

対象者は、React.jsを理解していてサーバーサイドレンダリングで表示してみたいかたです。

1章 Hello World

まずはじめにHello Worldを表示させるため、Next.js公式サイトに飛び、ドキュメントを2、3分読んだ。

そして手を動かしはじめた。

nextreactreact-domをインストールし、package.jsonnpm run devのスクリプトを追記、pagesフォルダを作成し、その中にindex.jsを作った。

export default () => <div>Hello World</div>

余裕だった、私は完全に天狗になっていた。

これreact-reduxとかインストールして、connectで挟めば、redux導入もすぐできるんじゃね?

こんなことを心の中で囁いていた。

次に、ルーティングについて学んだ。

pagesフォルダに新しくabout.jsを作ってやればlocalhost:3000/aboutで表示できるとのことだったのでやってみた。

export default () => <div>about page</div>

おぉ、React.jsでルーティングしようとすると、複雑でライブラリによってはversionが変わると動かないという不安定さがあったが、Next.jsはシンプルでいいね

なんて呟いていた。

2章 React.jsで使っていたライブラリの導入

とりあえず、普段よく使っていたreduxmaterial-uiredux-sagaを導入してみようと思い、調べ始めた。

ん?_app.jsってなんだ?_document.jsなにそれ?

そう、謎ファイルのとの出会いだった。Next.jsは普通にライブラリインストールして、React.jsのようにしてやれば導入できますというような代物ではなかったのだ。

import App, { Container } from 'next/app'
import React from 'react'
import withReduxStore from '../lib/with-redux-store'
import { Provider } from 'react-redux'

class MyApp extends App {
  render () {
    const { Component, pageProps, reduxStore } = this.props
    return (
      <Container>
        <Provider store={reduxStore}>
          <Component {...pageProps} />
        </Provider>
      </Container>
    )
  }
}
export default withReduxStore(MyApp)

with-redux-storeをつくっていたり、pagePropsを使っていたりとわけがわからなかった。 どうやらページ表示時はじめに実行されるものならしい。詳しくは「Next.jsの_app.jsと_document.js」の記事がよかったので読んでみて下さい。

どうやらライブラリを導入したい場合、この_app.jsを書き換えることで使えるようになるみたいだ。

そして大体有名なライブラリはNext.jsgithubのexamplesに書き換え方のサンプルが落ちていることに気がついた。

reduxmaterial-uiredux-sagaもサンプルが落ちていた。

このサンプルを真似て、_app.jsを作成し手を加えていくとreduxを導入することができた。 ※material-uiの導入もできたがここでは割愛する。

redux導入

reduxreact-reduxをインストール

pagesフォルダに_app.jsを作成

import App, { Container } from 'next/app'
import React from 'react'
import withReduxStore from '../lib/with-redux-store'
import { Provider } from 'react-redux'

class MyApp extends App {
  render () {
    const { Component, pageProps, reduxStore } = this.props
    return (
      <Container>
        <Provider store={reduxStore}>
          <Component {...pageProps} />
        </Provider>
      </Container>
    )
  }
}

export default withReduxStore(MyApp)

新しくlibフォルダを作成し、reduxを使えるようにラップするwith-redux-store.jsを作成した。

import React from 'react'
import { initializeStore } from '../store'

const isServer = typeof window === 'undefined'
const __NEXT_REDUX_STORE__ = '__NEXT_REDUX_STORE__'

function getOrCreateStore (initialState) {
  // Always make a new store if server, otherwise state is shared between requests
  if (isServer) {
    return initializeStore(initialState)
  }

  // Create store if unavailable on the client and set it on the window object
  if (!window[__NEXT_REDUX_STORE__]) {
    window[__NEXT_REDUX_STORE__] = initializeStore(initialState)
  }
  return window[__NEXT_REDUX_STORE__]
}

export default App => {
  return class AppWithRedux extends React.Component {
    static async getInitialProps (appContext) {
      // Get or Create the store with `undefined` as initialState
      // This allows you to set a custom default initialState
      const reduxStore = getOrCreateStore()

      // Provide the store to getInitialProps of pages
      appContext.ctx.reduxStore = reduxStore

      let appProps = {}
      if (typeof App.getInitialProps === 'function') {
        appProps = await App.getInitialProps(appContext)
      }

      return {
        ...appProps,
        initialReduxState: reduxStore.getState()
      }
    }

    constructor (props) {
      super(props)
      this.reduxStore = getOrCreateStore(props.initialReduxState)
    }

    render () {
      return <App {...this.props} reduxStore={this.reduxStore} />
    }
  }
}

store.jsを作成した。

今回はtextcountのキーで値を保持させた。 PLUSSET_TEXTアクションを定義し、textcountの値を変更できるようにした。

import { createStore } from 'redux'

const initialState = {
  text: '',
  count: 0,
}

export const actionTypes = {
  SET_TEXT: 'SET_TEXT',
  PLUS: 'PLUS',
}

// REDUCERS
export const reducer = (state = initialState, action) => {
  switch (action.type) {
    case actionTypes.SET_TEXT:
      return Object.assign({}, state, {
        text: action.payload,
    })
    case actionTypes.PLUS:
      return Object.assign({}, state, {
        count: state.count + 1
      })
    default:
      return state
  }
}

// ACTIONS
export const SetTextAction = (text) => {
    return { type: actionTypes.SET_TEXT, payload: text }
}

export const PlusAction = () => {
  return{ type: actionTypes.PLUS }
}

export function initializeStore (initialState = initialState) {
  return createStore(
    reducer,
    initialState,
  )
}

index.jsabout.jsを書き換えた。

import React from 'react'
import { connect } from 'react-redux'
import Link from 'next/link'
import { PlusAction, SetTextAction } from '../store'

class Index extends React.Component {
  static getInitialProps ({ reduxStore, req }) {
    const isServer = !!req
    if (isServer) reduxStore.dispatch(SetTextAction('hello world'))

    return {}
  }

  render () {
    return (
      <div>
        <h1>index page</h1>
        <p>text: {this.props.text}</p>
        <p>count: {this.props.count}</p>

        <button onClick={this.props.handlePlus}>plus</button>
        <button onClick={() => this.props.handleSetText('Index')}>set Index</button>

        <Link href="/about"><a>go to about page</a></Link>
      </div>
    )
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    handlePlus: () => {
      dispatch(PlusAction())
    },

    handleSetText: (text) => {
      dispatch(SetTextAction(text))
    }
  }
}

const mapStateToProps = (state) => {
  return state
}

export default connect(mapStateToProps, mapDispatchToProps)(Index)

import React from 'react'
import { connect } from 'react-redux'
import { PlusAction, SetTextAction } from '../store'

class About extends React.Component {
  render () {
    return (
      <div>
        <h1>about page</h1>
        <p>text: {this.props.text}</p>
        <p>count: {this.props.count}</p>

        <button onClick={this.props.handlePlus}>plus</button>
        <button onClick={() => this.props.handleSetText('About')}>set About</button>
      </div>
    )
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    handlePlus: () => {
      dispatch(PlusAction())
    },

    handleSetText: (text) => {
      dispatch(SetTextAction(text))
    }
  }
}

const mapStateToProps = (state) => {
  return state
}

export default connect(mapStateToProps, mapDispatchToProps)(About)

Next.jsはどうやらはじめにサーバーサイドレンダリングで表示され、そこから先はクライアント側の処理だけで表示されるSPAに切り替わるらしい。

getInitialPropsはページが表示されるときに、一度だけ実行されるらしい。 はじめはサーバー側で実行され、そこから先ページ遷移した場合はクライアント側で実行される万能な関数だ。 もちろんリロードすればサーバー側で実行される。

index.jsではhello worldの文字をtextにいれるアクションSetTextActiondispatchしている。またreqの値でサーバー側の処理かクライアント側の処理か判定もできた。

class Index extends React.Component {
  static getInitialProps ({ reduxStore, req }) {
    const isServer = !!req
    if (isServer) reduxStore.dispatch(SetTextAction('hello world'))

    return {}
  }

  render () {
    // 省略
  }
}

なんか、getInitialPropsが使えるようになったことで少しだけNext.jsがどういうものなのか理解が深まった気がした。

3章 Web APIってどうやって使う?

WebAPIの使用はとても簡単だった。 Next.js公式サイトのlearnのFetching Data for Pagesにて解説してあった。

もうすでにあなたは知ってる、getInitialPropsでリクエストを投げればいい。

まず、リクエストを投げるisomorphic-unfetchをインストール。

index.jsを書き直した。


import React from 'react'
import { connect } from 'react-redux'
import fetch from 'isomorphic-unfetch'
import Link from 'next/link'
import { PlusAction, SetTextAction } from '../store'

class Index extends React.Component {
  static async getInitialProps ({ reduxStore, req }) {
    const isServer = !!req
    if (isServer) reduxStore.dispatch(SetTextAction('hello world'))

    const endpoint = 'https://qiita.com/api/v2/users/kousaku-maron/items?page=1&per_page=100'
    const res = await fetch(endpoint)
    const data = await res.json()

    const result = data.map(record => record.title)

    return {
      result: result,
    }
  }

  render () {
    return (
      <div>
        <h1>index page</h1>
        <p>text: {this.props.text}</p>
        <p>count: {this.props.count}</p>
        {this.props.result.map(title => (
          <p key={title}>{title}</p>
        ))}

        <button onClick={this.props.handlePlus}>plus</button>
        <button onClick={() => this.props.handleSetText('Index')}>set Index</button>

        <Link href="/about"><a>go to about page</a></Link>
      </div>
    )
  }
}

const mapDispatchToProps = (dispatch) => {
  return {
    handlePlus: () => {
      dispatch(PlusAction())
    },

    handleSetText: (text) => {
      dispatch(SetTextAction(text))
    }
  }
}

const mapStateToProps = (state) => {
  return state
}

export default connect(mapStateToProps, mapDispatchToProps)(Index)

getInitialPropsasyncにし、isomorphic-unfetchでリクエストを投げている。返り値はthis.propsに入るので利用する。

今回使用しているAPIはQiita APIです。

static async getInitialProps ({ reduxStore, req }) {
    const isServer = !!req
    if (isServer) reduxStore.dispatch(SetTextAction('hello world'))

    const endpoint = 'https://qiita.com/api/v2/users/kousaku-maron/items?page=1&per_page=100'
    const res = await fetch(endpoint)
    const data = await res.json()

    const result = data.map(record => record.title)

    return {
      result: result,
    }
  }

4章 localhost/:idなどのルーティングはどうやる?

こちらもNext.js公式サイトのlearnのServer Side Support for Clean URLsにて解説してあった。

expressを利用して実現するらしい。

expressをインストール。

server.jsを作成し、ルーティングの設定をした。

const express = require('express')
const next = require('next')

const dev = process.env.NODE_ENV !== 'production'
const app = next({ dev })
const handle = app.getRequestHandler()

app.prepare()
.then(() => {
  const server = express()

  server.get('/p/:id', (req, res) => {
    const actualPage = '/about'
    const queryParams = { id: req.params.id } 
    
    app.render(req, res, actualPage, queryParams)
  })

  server.get('*', (req, res) => {
    return handle(req, res)
  })

  server.listen(3000, (err) => {
    if (err) throw err
    console.log('> Ready on http://localhost:3000')
  })
})
.catch((ex) => {
  console.error(ex.stack)
  process.exit(1)
})

同時にpackage.jsonを書き換えた。

"scripts": {
  "dev": "node server.js",
  "build": "next build",
  "start": "NODE_ENV=production node server.js",
},

index.jsでは、SPAによる遷移でabout.jsのページに遷移する。 server.jsはサーバーサイドレンダリングでの処理のため、クライアント側でもクエリを取得できるように、 Linkの属性である、hrefにはクエリを含む本来のルーティングを、asには表示するルーティングを記載しなければならない。

// 省略

class Index extends React.Component {
  // 省略

  render () {
    return (
      <div>
        <h1>index page</h1>
        <p>text: {this.props.text}</p>
        <p>count: {this.props.count}</p>
        {this.props.result.map(title => (
          <p key={title}>{title}</p>
        ))}

        <button onClick={this.props.handlePlus}>plus</button>
        <button onClick={() => this.props.handleSetText('Index')}>set Index</button>

        <Link as="/p/1" href="/about?id=1"><a>go to about page</a></Link>
      </div>
    )
  }
}

// 省略

export default connect(mapStateToProps, mapDispatchToProps)(Index)

about.jsでクエリを取得し表示させてみる。クエリの取得にはwithRouterを使用する。

import React from 'react'
import { withRouter } from 'next/router'
import { connect } from 'react-redux'
import { PlusAction, SetTextAction } from '../store'

class About extends React.Component {
  render () {
    return (
      <div>
        <h1>about page</h1>
        <p>text: {this.props.text}</p>
        <p>count: {this.props.count}</p>
        <p>id: {this.props.router.query.id}</p>

        <button onClick={this.props.handlePlus}>plus</button>
        <button onClick={() => this.props.handleSetText('About')}>set About</button>
      </div>
    )
  }
}

// 省略

export default connect(mapStateToProps, mapDispatchToProps)(withRouter(About))

これで、サーバーから直接アクセスしても、index.jsからSPAでアクセスしてもidの取得が可能になった。

サーバー側での処理とクライアント側での処理を意識しながら開発をしていかないとダメなんだなぁと実感した。

あとがき

これでカスタムサーバー、ライブラリの導入ができるようになったのでNext.jsでWebアプリを作れるようになったんじゃないでしょうか。React.jsを触ったことがあるのであれば一度Next.jsを触ってみるのもいいかもしれませんね。

サンプルコードをgithubに公開しておきました。