Blitz.jsチュートリアル:投票サービスを15分で作ってみる

16 min read

はじめに

本記事では、Blitz.jsのチュートリアルに沿って以下のような投票サービスを15分で作ってみます。
データベースのCRUDを伴うReactアプリケーションが、まさに電撃(Blitz)のような速さでできあがります。

Blitz.jsとは

Blitz.jsとは、Railsにインスパイアされて作られたフルスタックReactフレームワークで、以下の特徴があります。

  • Next.js上に構築されたフルスタックフレームワークで、データベースからフロントエンドまで1つのアプリケーション内で完結する
  • “Zero-API”データレイヤーにより、サーバサイドのコードを直接コンポーネントにインポートできるため、RESTやGraphQLのようなAPIが不要
  • ルーティング、ディレクトリ構成、認証などのデフォルトの設定や規約が提供され、本質的な部分の開発に集中できる

https://blitzjs.com/

簡単に言うと、Railsの効率的な開発体験をReactで実現しようとしているフレームワークです。

先月β版となる v0.30 がリリースされ、4月中には v1.0 のリリースも予定されています。
β版リリース以降は大きな破壊的変更もなく、作者のBrandonも今がBlitz.jsで本番アプリケーションを開始する絶好のタイミングと Tweet しています。

Blitz.jsのインストール

まず、以下のコマンドを実行してBlitz.jsをインストールしましょう。
Node.js 12以上が必要です。

npm install -g blitz

インストールしたら、以下のコマンドでバージョン情報が表示されることを確認しましょう。

blitz -v

本記事で使用するバージョン

本記事では、2021/3/7時点の最新版である v0.31.1 を使用します。

チュートリアルをやってみる

それでは、早速チュートリアルに沿って投票サービスを作っていきましょう。

本家のドキュメントは以下のページです。

https://blitzjs.com/docs/tutorial

わかりやすいように適宜説明を追加・省略したり、内容を変更したりして解説していきます。

1. アプリケーションの作成

まずは以下のコマンドでアプリケーションを作成します。

blitz new my-blitz-app

途中、フォームライブラリを選択するように求められますが、ここでは推奨されている React Final Form を選択します。

実行が終わると以下のディレクトリとファイルが作成されます。

my-blitz-app
├── app
├── db
├── integrations
├── mailers
├── node_modules
├── public
├── test
├── README.md
├── babel.config.js
├── blitz.config.js
├── jest.config.js
├── package.json
├── tsconfig.json
├── types.d.ts
├── types.ts
└── yarn.lock

基本的には app/ 配下にプロジェクトのコードを書いていきます。
db/ 配下にはデータベースの設定やマイグレーションのコードが置かれます。

それでは、サーバを起動してみましょう。

cd my-blitz-app
blitz dev

localhost:3000 にアクセスして以下の画面が表示されれば成功です。

Blitz.jsはデフォルトでログイン機能が実装されていて、画面の Sign UpLogin のボタンから試すことができます。

デフォルトではデータベースにはSQLiteが使われています。
本番運用するプロジェクトの場合は こちら を参考に、PostgreSQLなどのスケーラブルなデータベースに置き換えましょう。

2. Scaffoldによるコード生成

Blitz.jsにはRailsと同じようにScaffoldのための generate コマンドが用意されています。
これを使って、投票サービスのための質問と選択肢のモデルを作成していきます。

Question は質問の内容と選択肢のリストを持ち、Choice は選択肢の内容と投票数、質問への関連を持ちます。
モデルのID、作成日時・更新日時のタイムスタンプはBlitz.jsが自動で生成してくれます。

Question モデル

まず、Question モデルに関するコードを生成します。

blitz generate all question name:string

本家のドキュメントではフィールド名が text になっていますがここでは name を使います。
現状 generate で生成されるページやミューテーションはフィールド名が name で生成されるので、後で nametext に書き換えるという無駄な作業をなくすためです。(詳細

以下のようにページとクエリ・ミューテーションのコードが生成されます。

CREATE    app/pages/questions/[questionId].tsx
CREATE    app/pages/questions/[questionId]/edit.tsx
CREATE    app/pages/questions/index.tsx
CREATE    app/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

✔ Model for 'question' created in schema.prisma:

> model Question {
>   id        Int      @default(autoincrement()) @id
>   createdAt DateTime @default(now())
>   updatedAt DateTime @updatedAt
>   name      String
> }

✔ Run 'prisma migrate dev' to update your database? (Y/n) · true

マイグレーションを実行するか聞かれるのでEnterを押してマイグレーションを実行します。
マイグレーション名を聞かれるので、後からわかりやすいよう add question のような名前を付けておきましょう。
ここで指定した名前がマイグレーションのディレクトリ名に使われます。

デフォルトではデータベースクライアントに Prisma が使われています。
Prismaについて詳しく知りたい場合は Prismaのドキュメント をご覧ください。

Choice モデル

次に、Choice モデルに関するコードを生成します。
Choice はページは不要なので generate resource を使います。

blitz generate resource choice name votes:int:default=0 belongsTo:question

今度はページは生成されずにクエリとミューテーションのみが生成されます。

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

✔ Model for 'choice' created in schema.prisma:

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

✔ Run 'prisma migrate dev' to update your database? (Y/n) · true

Enterを押してマイグレーションを実行し、マイグレーション名を入力します。

Question モデルと Choice モデルの関連付け

続いて、db/schema.prisma を開き、choices Choice[]Question モデルに追加します。

db/schema.prisma
 model Question {
   id        Int      @id @default(autoincrement())
   createdAt DateTime @default(now())
   updatedAt DateTime @updatedAt
   name      String
+  choices   Choice[]
 }

スキーマ変更のために以下のコマンドを実行してPrismaクライアントを更新します。

blitz prisma generate

データベースに実際のフィールドを追加するわけではないので、今回はマイグレーションは必要ありません。

生成されたコードの修正

Prismaがカスケード削除をまだサポートしていないため、質問を削除する時に選択肢も合わせて削除するよう app/questions/mutations/deleteQuestion.ts に以下のコードを追加します。

app/questions/mutations/deleteQuestion.ts
 export default resolver.pipe(resolver.zod(DeleteQuestion), resolver.authorize(), async ({ id }) => {
+  await db.choice.deleteMany({ where: { questionId: id } })
   const question = await db.question.deleteMany({ where: { id } })
 
   return question
 })

これで準備が整いました!

localhost:3000/questions にアクセスして、質問の作成・更新・削除を試してみましょう!
500エラーになる場合は一度 Ctrl+C でサーバを停止し、再度 blitz dev でサーバを起動してください。

3. Prismaデータベースクライアントを試す

BlitzコンソールからPrismaのデータベースクライアントを触ってみましょう。

Blitzコンソールを起動するには、以下のコマンドを実行します。

blitz console

コンソールが表示されたら、データベースへの操作を試してみましょう。

// 質問を作成
⚡️ > let q = await db.question.create({data: {name: "What's new?"}})
undefined
⚡️ > q
{
  id: 1,
  createdAt: 2021-03-07T06:10:43.123Z,
  updatedAt: 2021-03-07T06:10:43.123Z,
  name: "What's new?"
}
⚡️ > q.name
"What's new?"
⚡️ > q.createdAt
2021-03-07T06:10:43.123Z

// 質問を更新
⚡️ > q = await db.question.update({where: {id: 1}, data: {name: "What's up?"}})
{
  id: 1,
  createdAt: 2021-03-07T06:10:43.123Z,
  updatedAt: 2021-03-07T06:12:23.944Z,
  name: "What's up?"
}

// 質問を取得
⚡️ > await db.question.findMany()
[
  {
    id: 1,
    createdAt: 2021-03-07T06:10:43.123Z,
    updatedAt: 2021-03-07T06:12:23.944Z,
    name: "What's up?"
  }
]

4. 質問フォームに選択肢を追加する

ここからは、質問フォームに選択肢を追加していきます。

app/questions/components/QuestionForm.tsx を開いて、選択肢の <LabeledTextField> コンポーネントを追加します。

app/questions/components/QuestionForm.tsx
 export function QuestionForm<S extends z.ZodType<any, any>>(props: FormProps<S>) {
   return (
     <Form<S> {...props}>
       <LabeledTextField name="name" label="Name" placeholder="Name" />
+      <LabeledTextField name="choices.0.name" label="Choice 1" />
+      <LabeledTextField name="choices.1.name" label="Choice 2" />
+      <LabeledTextField name="choices.2.name" label="Choice 3" />
     </Form>
   )
 }

次に、app/pages/questions/new.tsx を開き、initialValues を次のように設定します。

app/pages/questions/new.tsx
       <QuestionForm
         submitText="Create Question"
-        // initialValues={{ }}
+        initialValues={{ choices: [] }}
         onSubmit={async (values) => {
           try {
             const question = await createQuestionMutation(values)
             router.push(`/questions/${question.id}`)
           } catch (error) {
             console.error(error)
             return {
               [FORM_ERROR]: error.toString(),
             }
           }
         }}
       />

最後に、app/questions/mutations/createQuestion.ts を開き、zodスキーマを更新して選択肢のデータがミューテーションで受け入れられるようにします。
また、選択肢のレコードも一緒に作成されるよう db.question.create を更新します。

app/questions/mutations/createQuestion.ts
 const CreateQuestion = z
   .object({
     name: z.string(),
+    choices: z.array(z.object({ name: z.string() })),
   })
   .nonstrict()

 export default resolver.pipe(resolver.zod(CreateQuestion), resolver.authorize(), async (input) => {
-  const question = await db.question.create({ data: input })
+  const question = await db.question.create({
+    data: {
+      ...input,
+      choices: { create: input.choices },
+    },
+  })
 
   return question
 })

これで選択肢のフォームが追加できました!

localhost:3000/questions/new から選択肢付きで質問を作ってみましょう!
ここでは選択肢の表示と更新はまだ未実装です。

5. 質問に選択肢を表示する

質問と一緒に選択肢を取得して表示するようにしていきます。

まず、質問を取得するクエリを改修します。
Prismaではネストされたリレーションを取得することをクライアントに手動で通知する必要があるため、getQuestion.tsgetQuestions.ts を次のように変更します。

app/questions/queries/getQuestion.ts
 export default resolver.pipe(resolver.zod(GetQuestion), resolver.authorize(), async ({ id }) => {
-  const question = await db.question.findFirst({ where: { id } })
+  const question = await db.question.findFirst({
+    where: { id },
+    include: { choices: true },
+  })

   if (!question) throw new NotFoundError()
    
   return question
 })
app/questions/queries/getQuestions.ts
     const { items: questions, hasMore, nextPage, count } = await paginate({
       skip,
       take,
       count: () => db.question.count({ where }),
       query: (paginateArgs) =>
         db.question.findMany({
           ...paginateArgs,
           where,
           orderBy,
+          include: { choices: true },
         }),
     })

次に、app/pages/questions/index.tsx の質問のリンクの下に以下のコードを追加します。

app/pages/questions/index.tsx
 {questions.map((question) => (
   <li key={question.id}>
     <Link href={`/questions/${question.id}`}>
       <a>{question.name}</a>
     </Link>
 +    <ul>
+      {question.choices.map((choice) => (
+        <li key={choice.id}>
+          {choice.name} - {choice.votes} votes
+        </li>
+      ))}
+    </ul>
   </li>
 ))}

localhost:3000/questions をチェックしてみましょう!
先程入力した選択肢が表示されるはずです。

同様に、app/pages/questions/[questionId].tsx にも選択肢のリストを追加します。
<h1>{question.name} に置き換えます。

app/pages/questions/[questionId].tsx
       <div>
 -        <h1>Question {question.id}</h1>
-        <pre>{JSON.stringify(question, null, 2)}</pre>
+        <h1>{question.name}</h1>
+        <ul>
+          {question.choices.map((choice) => (
+            <li key={choice.id}>
+              {choice.name} - {choice.votes} votes
+            </li>
+          ))}
+        </ul>
         // ...
       </div>

質問ページを開くと、以下のように質問内容と選択肢が表示されているはずです。

6. 投票機能の追加

続いて、投票機能を追加していきます。

まず、app/choices/mutations/updateChoice.ts を開き、zodスキーマの更新と投票のインクリメントの追加を行います。

app/choices/mutations/updateChoice.ts
 const UpdateChoice = z
   .object({
     id: z.number(),
-    name: z.string(),
   })
   .nonstrict()
   
 export default resolver.pipe(
   resolver.zod(UpdateChoice),
   resolver.authorize(),
   async ({ id, ...data }) => {
-    const choice = await db.choice.update({ where: { id }, data })
+    const choice = await db.choice.update({
+      where: { id },
+      data: { votes: { increment: 1 } },
+    })

     return choice
   }
 )

次に、app/pages/questions/[questionId].tsx を開き、先程の updateChoice ミューテーションをインポートして handleVote 関数を作成します。

app/pages/questions/[questionId].tsx
 import getQuestion from "app/questions/queries/getQuestion"
 import deleteQuestion from "app/questions/mutations/deleteQuestion"
+import updateChoice from "app/choices/mutations/updateChoice"

 export const Question = () => {
   const router = useRouter()
   const questionId = useParam("questionId", "number")
   const [deleteQuestionMutation] = useMutation(deleteQuestion)
-  const [question] = useQuery(getQuestion, { id: questionId })
+  const [question, { refetch }] = useQuery(getQuestion, { id: questionId })
+  const [updateChoiceMutation] = useMutation(updateChoice)
+
+  const handleVote = async (id: number) => {
+    try {
+      await updateChoiceMutation({ id })
+      refetch()
+    } catch (error) {
+      alert("Error updating choice " + JSON.stringify(error, null, 2))
+    }
+  }

   return (

そして、リストにボタンを追加して、クリック時に handleVote 関数を呼び出すようにします。

app/pages/questions/[questionId].tsx
           {question.choices.map((choice) => (
             <li key={choice.id}>
               {choice.name} - {choice.votes} votes
 +              <button onClick={() => handleVote(choice.id)}>Vote</button>
             </li>
           ))}

これで投票ボタンが実装されました!

7. 選択肢を編集できるようにする

最後に、既存の質問の選択肢を編集できるようにします。

既存の質問でEditボタンを押すと、質問作成時と同じフォームが使用されていることがわかるかと思います。
あとはミューテーションを更新するだけです。

app/questions/mutations/updateQuestion.ts を開き、次の変更を加えます。

app/questions/mutations/updateQuestion.ts
 const UpdateQuestion = z
   .object({
     id: z.number(),
     name: z.string(),
+    choices: z.array(z.object({ id: z.number().optional(), name: z.string() }).nonstrict()),
   })
   .nonstrict()
   
 export default resolver.pipe(
   resolver.zod(UpdateQuestion),
   resolver.authorize(),
   async ({ id, ...data }) => {
-    const question = await db.question.update({ where: { id }, data })
+    const question = await db.question.update({
+      where: { id },
+      data: {
+        ...data,
+        choices: {
+          upsert: data.choices.map((choice) => ({
+            // Appears to be a prisma bug,
+            // because `|| 0` shouldn't be needed
+            where: { id: choice.id || 0 },
+            create: { name: choice.name },
+            update: { name: choice.name },
+          })),
+        },
+      },
+    })

     return question
   }
 )

質問を作成する際には選択肢が必須でなかったため、追加にも更新にも対応できるよう upsert を使っています。
upsert はレコードが存在する場合は更新し、そうでない場合は作成します。

おめでとうございます🥳

これで冒頭のような投票サービスが作れたかと思います!
ぜひ画面やコードを触っていろいろと試してみてください。

まとめ

本記事では、Blitz.jsのチュートリアルに沿って投票サービスを作ってみました。
データベースのCRUDを伴うReactアプリケーションを爆速で作ることができて、昔Railsを初めて触った時のようなワクワク感がありました!

API開発が不要で、TypeScriptでデータベースの操作からフロントエンドまでシームレスに書ける体験はとても良かったです。
既にある程度の規模のサービスをリプレイスするメリットはあまりないですが、今後少人数で新規サービスの立ち上げやプロトタイプの開発を行う際にはぜひ使ってみたいと思いました。

これを機にBlitz.jsを試してみる方が増えたら嬉しいです!

現在、Blitz.jsのドキュメントを日本語に翻訳するプロジェクトも始まっていますので、興味のある方はぜひご参加ください。

https://github.com/blitz-js/ja.blitzjs.com

Discussion

ログインするとコメントできます