Blitz.jsチュートリアル:投票サービスを15分で作ってみる
はじめに
本記事では、Blitz.jsのチュートリアルに沿って以下のような投票サービスを15分で作ってみます。
データベースのCRUDを伴うReactアプリケーションが、まさに電撃(Blitz)のような速さでできあがります。
Blitz.jsとは
Blitz.jsとは、Railsにインスパイアされて作られたフルスタックReactフレームワークで、以下の特徴があります。
- Next.js上に構築されたフルスタックフレームワークで、データベースからフロントエンドまで1つのアプリケーション内で完結する
- “Zero-API”データレイヤーにより、サーバサイドのコードを直接コンポーネントにインポートできるため、RESTやGraphQLのようなAPIが不要
- ルーティング、ディレクトリ構成、認証などのデフォルトの設定や規約が提供され、本質的な部分の開発に集中できる
簡単に言うと、Railsの効率的な開発体験をReactで実現しようとしているフレームワークです。
先月β版となる v0.30
がリリースされ、4月中には 。v1.0
のリリースも予定されています
(追記: v1.0
のリリースは延期になったようです)
Blitz.jsのインストール
まず、以下のコマンドを実行してBlitz.jsをインストールしましょう。
Node.js 12以上が必要です。
npm install -g blitz
インストールしたら、以下のコマンドでバージョン情報が表示されることを確認しましょう。
blitz -v
本記事で使用するバージョン
本記事では、2021/3/7時点の最新版である v0.31.1
を使用します。
チュートリアルをやってみる
それでは、早速チュートリアルに沿って投票サービスを作っていきましょう。
本家のドキュメントは以下のページです。
わかりやすいように適宜説明を追加・省略したり、内容を変更したりして解説していきます。
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 Up
と Login
のボタンから試すことができます。
2. Scaffoldによるコード生成
Blitz.jsにはRailsと同じようにScaffoldのための generate コマンドが用意されています。
これを使って、投票サービスのための質問と選択肢のモデルを作成していきます。
Question
は質問の内容と選択肢のリストを持ち、Choice
は選択肢の内容と投票数、質問への関連を持ちます。
モデルのID、作成日時・更新日時のタイムスタンプはBlitz.jsが自動で生成してくれます。
Question
モデル
まず、Question
モデルに関するコードを生成します。
blitz generate all question name:string
以下のようにページとクエリ・ミューテーションのコードが生成されます。
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
のような名前を付けておきましょう。
ここで指定した名前がマイグレーションのディレクトリ名に使われます。
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
モデルに追加します。
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
に以下のコードを追加します。
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>
コンポーネントを追加します。
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
を次のように設定します。
<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
を更新します。
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.ts
と getQuestions.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
})
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
の質問のリンクの下に以下のコードを追加します。
{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}
に置き換えます。
<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スキーマの更新と投票のインクリメントの追加を行います。
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
関数を作成します。
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
関数を呼び出すようにします。
{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
を開き、次の変更を加えます。
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のドキュメントを日本語に翻訳するプロジェクトも始まっていますので、興味のある方はぜひご参加ください。
Discussion