もふもふ技術部

IT技術系mofmofメディア

盛り盛りなReactのフレームワーク Blitz入門

最近ホットなBlitzに入門します!
公式チュートリアル(アンケートサービスみたいなものを作ります)に沿って進めていきますので、困ったら公式見てください()
https://blitzjs.com/docs/tutorial

ただ、バージョンが違うと自動生成されるコードが違ったりする可能性があるので、その点は注意してください。
執筆時は、
blitz: 0.27.0
を利用していました。ちょっと公式との差分もあったので参考になる箇所があるかもしれません。

導入

まず、 npm install -g blitz とかでBlitzをインストールします。 インストールが完了したら、プロジェクトを作成します。任意のディレクトリで、

blitz new mysite

newの後ろに書くのはプロジェクト名です。お好みでどうぞ。

enterを押すと、ファイルの生成が始まります。Railsと似たような構造になっていますね。すごい親近感。

では、ディレクトリ移動して起動してみましょう。

cd mysite
blitz start

http://localhost:3000 にアクセスすると、このような画面が表示されます。

ページを触ってみる

ちょっと編集してみましょう。 app/pages/index.tsx を下記の内容に置き換えてください。

const Index = () => (
  <div>
    <h1>Hello, world!</h1>
  </div>
)
export default Index

これだけでOKです。Nextのルーティングに則っているので、ルートパスで表示される内容がこれになります。

モデルの作成

チュートリアルではsqliteを利用します。接続情報は schema.prismaに記載する形になっていますね。

今回利用するモデルは、質問を表現するQuestionと、選択肢のリストであるChoiceの2つです。

まずはQuestionの作成。Railsと同様、generateコマンドでコントローラー / ビュー / Railsのモデルに相当するCRUDが作成できます。

$ blitz generate all question text:string "choices:choice[]"

✔ Model for 'question' updated successfully:

> model Question {
>   id        Int      @default(autoincrement()) @id
>   createdAt DateTime @default(now())
>   updatedAt DateTime @updatedAt
>   choices   Choice[]
> }

Now run blitz db migrate to add this model to your database

CREATE    app/questions/pages/questions/[questionId]/edit.tsx
CREATE    app/questions/pages/questions/[questionId].tsx
CREATE    app/questions/pages/questions/index.tsx
CREATE    app/questions/pages/questions/new.tsx
CREATE    app/questions/components/QuestionForm.tsx
CREATE    app/questions/queries/getQuestion.ts
CREATE    app/questions/queries/getQuestions.ts
CREATE    app/questions/mutations/createQuestion.ts
CREATE    app/questions/mutations/deleteQuestion.ts
CREATE    app/questions/mutations/updateQuestion.ts

generateの後に書いてある all は、生成するリソースの指定に使います。

  • model
  • queries
  • mutations
  • pages

の4項目のうち、どれを生成しますか?というものですね。 詳しくはこちら https://blitzjs.com/docs/cli-generate

コマンドについては公式に注意書きがあるのですが、 zshを利用している場合は "choices:choice[]" とダブルクォーテーションで囲まないとエラーになってしまいます。

続けて、Questionにぶら下がるchoiceを生成します。

$ blitz generate resource choice text "votes:int:default[0]" belongsTo:question

✔ Model for 'choice' created successfully:

> model Choice {
>   id         Int      @default(autoincrement()) @id
>   createdAt  DateTime @default(now())
>   updatedAt  DateTime @updatedAt
>   text       String
>   votes      Int      @default(0)
>   question   Question @relation(fields: [questionId], references: [id])
>   questionId Int
> }

Now run blitz db migrate to add this model to your database

CREATE    app/choices/queries/getChoice.ts
CREATE    app/choices/queries/getChoices.ts
CREATE    app/choices/mutations/createChoice.ts
CREATE    app/choices/mutations/deleteChoice.ts
CREATE    app/choices/mutations/updateChoice.ts

ここまでできたらDBに反映します。マイグレーション名を聞かれた場合は適当でいいですが、ここでは init db にしてあります。

$ blitz db migrate

✔ Name of migration … init db

サーバに接続して触ってみましょう。

$ blitz console

⚡️ > db.question.create({data: {text: "What's new?"}})

⚡️ > await db.question.findMany()
[
  {
    id: 1,
    createdAt: 2020-12-08T07:36:00.216Z,
    updatedAt: 2020-12-08T07:36:00.216Z,
    text: "What's new?"
  }
]

直感的な操作ができて良いですね。prismaすごい。

Questionの削除について

prismaが子モデルの削除までサポートしてないので、 deleteQuestion のmutationをちょっと触っておきましょうという記述が公式にあります。
具体的には、questionを削除する前に関連する選択肢の削除処理を挟んでおきます。

app/questions/mutations/deleteQuestion.ts

export default async function deleteQuestion({where}: DeleteQuestionInput, ctx: Ctx) {
  ctx.session.authorize()

  await db.choice.deleteMany({where: {question: {id: where.id}}})
  const question = await db.question.delete({where})

  return question
}

これでサーバ側は準備完了なので、フロント側触っていきましょう。

フロント

一覧

app/questions/pages/questions/index.tsx を触ります。
公式のチュートリアル記載の下記コードだと動かなかったです。

export const QuestionsList = () => {
  const [questions] = useQuery(getQuestions, {orderBy: {id: "desc"}})

  return (
    <ul>
      {questions.map((question) => (
        <li key={question.id}>
          <Link href="/questions/[questionId]" as={`/questions/${question.id}`}>
            <a>{question.name}</a> 
          </Link>
        </li>
      ))}
    </ul>
  )
}

ここに書いてない内容として、まず useQuery をimpotしないと動きません。頭に書いておきましょう。
たぶん既にLinkとかBlitzPageとかをblitzからimportしているはずなので、そこにuseQueryを併記する形でOKです。

import { useQuery } from "blitz"

そして僕の環境では questions.map が落ちました。useQueryに渡している getQuestions を確認してみます。generateコマンドで自動生成されたものですね。

app/questions/queries/getQuestions.ts

import { Ctx } from "blitz"
import db, { Prisma } from "db"

type GetQuestionsInput = Pick<Prisma.FindManyQuestionArgs, "where" | "orderBy" | "skip" | "take">

export default async function getQuestions(
  { where, orderBy, skip = 0, take }: GetQuestionsInput,
  ctx: Ctx
) {
  ctx.session.authorize()

  const questions = await db.question.findMany({
    where,
    orderBy,
    take,
    skip,
  })

  const count = await db.question.count()
  const hasMore = typeof take === "number" ? skip + take < count : false
  const nextPage = hasMore ? { take, skip: skip + take! } : null

  return {
    questions,
    nextPage,
    hasMore,
    count,
  }
}

returnされているのはこいつらですね。

  return {
    questions,
    nextPage,
    hasMore,
    count,
  }

なので、indexのquestionsに入ってるのはObjectだということになります。よくないですね。
受け取るのをquestionsにするか、適当な名前で受け取ってquestionsを見るかすればOKです。

export const QuestionsList = () => {
  const [{questions}] = useQuery(getQuestions, { orderBy: { id: "desc" } })
  // もしくは [data]

  return (
    <ul>
      {questions.map((question) => (
      // もしくは {data.questions.map( ~

としましょう。全体像はこんな感じです。

app/questions/pages/questions/index

import { Suspense } from "react"
import Layout from "app/layouts/Layout"
import { Link, useQuery, BlitzPage } from "blitz"
import getQuestions from "app/questions/queries/getQuestions"

export const QuestionsList = () => {
  const [data] = useQuery(getQuestions, { orderBy: { id: "desc" } })

  return (
    <ul>
      {data.questions.map((question) => (
        <li key={question.id}>
          <Link href="/questions/[questionId]" as={`/questions/${question.id}`}>
            <a>{question.text}</a>
          </Link>
        </li>
      ))}
    </ul>
  )
}

const QuestionsPage: BlitzPage = () => {
  return (
    <div>
      <p>
        <Link href="/questions/new">
          <a>Create Question</a>
        </Link>
      </p>

      <Suspense fallback={<div>Loading...</div>}>
        <QuestionsList />
      </Suspense>
    </div>
  )
}

QuestionsPage.getLayout = (page) => <Layout title={"Questions"}>{page}</Layout>

export default QuestionsPage

このページは app/questions/pages/questions/index なので、 http://localhost:3000/questions にいけば見れます。トップにこのページへのリンクを置いておきましょうか。

pages/index.tsx

import { Link } from "blitz"

const Index = () => (
  <div>
    <h1>Hello, world!</h1>
    <Link href="/questions">質問一覧</Link>
  </div>
)

export default Index

新規作成/編集

自動生成されたコードでquestionの作成・編集が実装されているのですが、存在しない name というカラムを操作しようとしているので変更しておきます。

app/questions/pages/questions/new.tsx

  return (
    <div>
      <h1>Create New Question</h1>

      <QuestionForm
        initialValues={{}}
        onSubmit={async () => {
          try {
            // 以下変更
            const question = await createQuestionMutation({
              data: {text: "Do you love Blitz?", choices: {create: [{text: "Yes!"}]}},
            })
            alert("Success!" + JSON.stringify(question))
            router.push(`/questions/${question.id}`)

app/questions/pages/questions/[questionId]/edit.tsx

  return (
    <div>
      <h1>Edit Question {question.id}</h1>
      <pre>{JSON.stringify(question)}</pre>

      <QuestionForm
        initialValues={question}
        onSubmit={async () => {
          try {
            // 以下変更
            const updated = await updateQuestionMutation({
              where: {id: question.id},
              data: {text: "Do you really love Blitz?"},
            })

これで一覧・詳細・作成・編集が動くようになっているはずです。
プロジェクト生成時に勝手に実装されるアカウント作成・ログインを使ってログイン完了した上で、このあたり触ってみましょう。認証は app/auth 以下で実装されています。

アンケートサービス仕上げ

質問作成時に、選択肢も登録できるようにします。

フォームの設置 子モデルのcreate処理

QuestionForm.tsx

import React from "react"

type QuestionFormProps = {
  initialValues: any
  onSubmit: React.FormEventHandler<HTMLFormElement>
}

const QuestionForm = ({ initialValues, onSubmit }: QuestionFormProps) => {
  return (
    <form
      onSubmit={(event) => {
        event.preventDefault()
        onSubmit(event)
      }}
    >
      <input placeholder="Name" />
      <input placeholder="Choice 1" />
      <input placeholder="Choice 2" />
      <input placeholder="Choice 3" />
      <button>Submit</button>
    </form>
  )
}

export default QuestionForm

更新時に入力内容を送信するよう改修

app/questions/pages/questions/new.tsx

import Layout from "app/layouts/Layout"
import { Link, useRouter, useMutation, BlitzPage } from "blitz"
import createQuestion from "app/questions/mutations/createQuestion"
import QuestionForm from "app/questions/components/QuestionForm"

const NewQuestionPage: BlitzPage = () => {
  const router = useRouter()
  const [createQuestionMutation] = useMutation(createQuestion)

  return (
    <div>
      <h1>Create New Question</h1>

      <QuestionForm
        initialValues={{}}
        onSubmit={async (event) => {
          try {
            // 主にこのあたりを変更
            const question = await createQuestionMutation({
              data: {
                text: event.target[0].value,
                choices: {
                  create: [
                    { text: event.target[1].value },
                    { text: event.target[2].value },
                    { text: event.target[3].value },
                  ],
                },
              },
            })
            alert("Success!" + JSON.stringify(question))
            router.push("/questions/[questionId]", `/questions/${question.id}`)
          } catch (error) {
            alert("Error creating question " + JSON.stringify(error, null, 2))
          }
        }}
      />

      <p>
        <Link href="/questions">
          <a>Questions</a>
        </Link>
      </p>
    </div>
  )
}

NewQuestionPage.getLayout = (page) => <Layout title={"Create New Question"}>{page}</Layout>

export default NewQuestionPage

詳細画面で選択肢も表示するようにする 関連の取得処理

現状ではquestionの情報しか取得できないので、questionに紐づく選択肢も取得できるようにqueryを改修しましょう。

getQuestions.ts

import { Ctx } from "blitz"
import db, { Prisma } from "db"

type GetQuestionsInput = Pick<Prisma.FindManyQuestionArgs, "where" | "orderBy" | "skip" | "take">

export default async function getQuestions(
  { where, orderBy, skip = 0, take }: GetQuestionsInput,
  ctx: Ctx
) {
  ctx.session.authorize()

  const questions = await db.question.findMany({
    where,
    orderBy,
    take,
    skip,
    include: {choices: true}, // 追加
  })

  const count = await db.question.count()
  const hasMore = typeof take === "number" ? skip + take < count : false
  const nextPage = hasMore ? { take, skip: skip + take! } : null

  return {
    questions,
    nextPage,
    hasMore,
    count,
  }
}

getQuestion.ts

import { Ctx, NotFoundError } from "blitz"
import db, { Prisma } from "db"

type GetQuestionInput = Pick<Prisma.FindFirstQuestionArgs, "where">

export default async function getQuestion({ where }: GetQuestionInput, ctx: Ctx) {
  ctx.session.authorize()

  const question = await db.question.findFirst({ where, include: {choices: true} }) // includeを追加

  if (!question) throw new NotFoundError()

  return question
}

これで、choiceを含んだ状態で取得できるようになりました。question詳細ページで表示しましょう。

[questionId].tsx

  return (
    <div>
      <h1>{question.text}</h1>
      <ul>
        {question.choices.map((choice) => {
          return (
            <li>
              {choice.text} - {choice.votes} votes
            </li>
          )
        })}
      </ul>

投票機能の実装 更新処理

questionに対してアクションできるようにしていきます。選択肢の横にボタンを設置して、それをクリックするとカウントされるような形です。

[questionId].tsx

// 追加
import updateChoice from "app/choices/mutations/updateChoice"

export const Question = () => {
  const router = useRouter()
  const questionId = useParam("questionId", "number")

  // refetchを追加
  const [question, { refetch }] = useQuery(getQuestion, { where: { id: questionId } })
  const [deleteQuestionMutation] = useMutation(deleteQuestion)

  // 更新のmutationを追加
  const [updateChoiceMutation] = useMutation(updateChoice)

  // 更新の処理を追加
  const handleVote = async (id, votes) => {
    try {
      const updated = await updateChoiceMutation({
        where: { id },
        data: { votes: votes + 1 },
      })
      refetch()
    } catch (error) {
      alert("Error creating question " + JSON.stringify(error, null, 2))
    }
  }

  // ボタン押下で更新処理を呼びだすように
  return (
    <div>
      <h1>{question.text}</h1>
      <ul>
        {question.choices.map((choice) => {
          return (
            <li>
              {choice.text} - {choice.votes} votes
              <button onClick={() => handleVote(choice.id, choice.votes)}>Vote</button>
            </li>
          )
        })}
      </ul>

ボタン押下時に更新処理が走り、完了後に最新の状態を取得することで投票数が同期されるようになっています。

ということで、完成形はこちら!

以上

チュートリアルを一通りこなしてみました。ハマりポイントは公式の記載と自動生成コードの差分くらいですかね。短いスパンでアップデートされていく時期だと思うので、ざらにありそうな気がします。

railsコマンドでいうところのdestroyコマンドがない(?)とか、db:rollbackもない…(?)(僕が見つけられてないだけ説もある)あたりがちょっと痒かったですが、ゆくゆくは実装されるんでしょうか。

とまあRailsを普段触っている身としてはかなり親近感があってかつ直感的に利用できる機能が多いです。全部jsで完結できるのもまあいいっちゃいいですね。個人的にはサーバは全然Railsでもいいんですが。

これから成長していくのが楽しみです。個人開発でも使おうと思います。

generateやmigrateで何が起きるのか & recipeを使ってtailwindを導入する記事も書いたのであわせてどうぞ。

Blitzのgenerateやrecipeを試す | もふもふ技術部