Upgrade to Pro — share decks privately, control downloads, hide ads and more …

新規プロダクトでプロトタイプから正式リリースまでNext.jsで開発したリアル

 新規プロダクトでプロトタイプから正式リリースまでNext.jsで開発したリアル

https://findy.connpass.com/event/365033/
こちらのイベントの登壇資料になります

Avatar for かわりく / Kawano Riku

かわりく / Kawano Riku

September 16, 2025
Tweet

More Decks by かわりく / Kawano Riku

Other Decks in Technology

Transcript

  1. 自己紹介 成果を出す!実践DXを学ぼう 受賞企業 登壇セッション (株)オイシス 生産本部兼はりま工場 統括ライン長 荒尾 和哉様 ファウンテン・デリ(株) 品質管理主任兼カミナシPJチーム事務局

    立花 直也様 ▪略歴 〜2024年10月:株式会社ゆめみ 2024年10月〜:株式会社カミナシ 〜現在:AIラベル検査の開発 株式会社カミナシ StatHackカンパニー ソフトウェアエンジニア 河野 陸 5/54
  2. カミナシ機能ラインナップ 現場のデジタルインフラとしてお客様のあらゆる業務を解決します Method 作業 Men ⼈ Machine 設備 ペーパーレス化を実現 現場の品質レベルを向上

    従業員と会社のやり取りを 1ツールで完結 現場教育を効率的に 動画マニュアルと研修管理 電⼦帳票 故障履歴や保全計画を 設備カルテとして⼀元管理 マニュアル‧研修 コミュニケーション 設備カルテ 現場の共通管理基盤(カミナシID) 1つのID/Passで運⽤で複数のシステムを利⽤ 運⽤の負担軽減とセキュリティ向上 10/54
  3. FE/BEで型を共通化しすぎてつらい case1 特にレイヤを意識せずにServer Actionsを使うとこうなる(こうなった) // server/type.ts type User = {

    id: string; name: string hashedPassword: string; createdAt: string; settingId: number; } // Server Actions export const getUsers = async (): Promise<User[]> => { 'use server'; // ユーザーを取得して返却する } プロトタイプからの技術的課題を解決せよ export const Page = async () => { const users = await getUsers(); return <UserList users={users} /> } 'use client' export const UserList = ({users}: {users: server.User[]}) => { // クライアントにhashedPasswordが漏れてるけど大丈夫? // クライアントにとって不要なフィールドはない? return <ul> {users.map((user) => ( <li key={user.id}>{user.name}</li> ))} </ul> } 34/54
  4. 解決方法 // server/type.ts type User = { id: string; name:

    string hashedPassword: string; createdAt: string; settingId: number; } // client/type.ts type User = { id: string; name: string } それぞれに最適な型を定義して疎結合にした。Server Actionsとして実装する関数はあくまでも境界に プロトタイプからの技術的課題を解決せよ FE/BEで型を共通化しすぎてつらい case1 // server/service/user.ts import type { User } from '../server/types' const getUsersService = (): User[] => { // dbアクセスやロジックなど } 37/54
  5. 解決方法 import * as client from '@/client/types' import { getUsersService

    } from '@/server/service' // server actions はFE/BEの境界としてデータの変換 export const getUsers = async (): Promise<client.User[]> => { 'use server'; // ユーザー取得する処理 const users = getUsersService() // clientの型に変換する return convertToClientUser(users) } それぞれに最適な型を定義して疎結合にした。Server Actionsとして実装する関数はあくまでも境界に プロトタイプからの技術的課題を解決せよ FE/BEで型を共通化しすぎてつらい case1 38/54
  6. Progressively Enhanced Form、複雑なFormはどうしたら...? case2 export const createUser = async (_prev:

    unknown, fd: FormData) => { "use server"; const name = fd.get("name") as string; const age = fd.get("age") as string; const parsed = parseUser(name, age); if (!parsed.success) { return parsed.errors } const userId = await userRepo.create({ name, age }) redirect("/users/" + userId); } "use client"; export default function UserCreateForm() { const [errors, formAction, isPending] = useActionState(createUser, {name: [], age: []}); return ( <form action={formAction}> <Name errors={errors.name} /> <Age errors={errors.age} /> <SubmitButton isPending={isPending} /> </form> ); } Server Actions が最初に出てきた時にインパクトあったやつですね プロトタイプからの技術的課題を解決せよ 39/54
  7. const { query, push } = useRouter(); const limit =

    !Array.isArray(query.limit) ? parseInt(query.limit ?? '50', 10) : 50; // … const sp = new URLSearchParams(location.search); sp.set('limit', String(nextLimit)); // 全部 string に変換必須… push(`?${sp.toString()}`); 最近の開発 before nuqsでクエリパラメータの管理を安全かつシンプルに case1 - router.queryのナロイングが必須&複雑になりがち - URLSearchParamsの操作はバグりがち - 全てstringに変換、削除や未設定時の対応など... 45/54
  8. import { useQueryState, parseAsInteger, withDefault } from 'nuqs'; const [limit,

    setLimit] = useQueryState( 'limit', withDefault(parseAsInteger.withOptions({ min: 1 }), 50) ); // … setLimit(newLimit); // 直列化・更新方式はフックが面倒見てくれる 最近の開発 after nuqsでクエリパラメータの管理を安全かつシンプルに case1 - 組み込みのparser 👌 - クエリパラメータの更新も宣言的にかける。気にすることが大幅減 46/54
  9. export const createUser = async (_prev: unknown, fd: FormData) =>

    { "use server"; const name = fd.get("name") as string; const age = fd.get("age") as string; const parsed = parseUser(name, age); if (!parsed.success) { return parsed.errors } const userId = await userRepo.create({ name, age }) redirect("/users/" + userId); } - Server Actionsに全て投げてバリデーション、エラーがあったら一気にFEに返して表示 最近の開発 Zustand + Valibotによるフォーム実装 case2 before 49/54
  10. Zustand Slice Patternなるものを使ってみた import type { FormInputSliceCreator } from '@/common/form';

    import { validateLength } from '@/common/form/validation'; export type NameSlice = { name: string; setName: (name: string) => void; getNameErrorMessages: (value: string) => string[]; getNameIsValid: () => boolean; }; export const createNameSlice: FormInputSliceCreator< NameSlice, { name: string } > = (initialValue) => (set, get) => ({ name: initialValue.name, setName: (name) => set({ name }), getNameErrorMessages: (name) => [ ...validateLength({ value: name, minLength: 1, maxLength: 50 }), ], getNameIsValid: () => get().getNameErrorMessages(get().name).length === 0, }); 最近の開発 Zustand + Valibotによるフォーム実装 case2 50/54
  11. import { useState } from 'react'; import { Input }

    from '@/common/components/FormInput'; import { useFormStore } from '../FormStoreProvider'; export const Name = () => { const value = useFormStore((state) => state.name); const setValue = useFormStore((state) => state.setName); const getErrors = useFormStore((state) => state.getNameErrorMessages); const [errorMsg, setErrorMsg] = useState<string>(''); return ( <Input name="name" label="ユーザー名" value={value} onChange={(e) => setValue(e.target.value)} onBlur={() => setErrorMsg(getErrors(value)?.[0] ?? '')} required errorMessage={errorMsg} error={errorMsg !== ''} /> ); }; 最近の開発 Zustand Slice Patternなるものを使ってみた Zustand + Valibotによるフォーム実装 case2 51/54
  12. export type UserStore = NameSlice & AgeSlice & StatusSlice export

    const createUserStore = (initial: InitialValue) => { const { name, age, status } = initial; return create<UserStore>()((...args) => ({ ...createNameSlice({ name })(...args), ...createAgeSlice({ age })(...args), ...createStatusSlice({ status })(...args), })); }; 最近の開発 Zustand Slice Patternなるものを使ってみた Zustand + Valibotによるフォーム実装 case2 52/54