Open40

Remix SPAモードで管理画面を作成する

ThirosueThirosue

最近お気に入りの shadcn-ui をUI libraryとして使おうと、公式の通り実施するもエラー。

https://ui.shadcn.com/docs/installation/remix

npx shadcn-ui@latest init
file:///Users/hirosue/.npm/_npx/125ee17d583c4e03/node_modules/shadcn-ui/dist/index.js:3
`);for(let r of o)if(r.includes("@apply")){let n=r.replace("@apply","").trim(),i=y(n,e);t=t.replace(n,i)}return t}var mt=[he,ye,ue,we],ft=new lt({compilerOptions:{}});async function dt(t){let e=await at.mkdtemp(ve.join(ct(),"shadcn-"));return ve.join(e,t)}async function U(t){let e=await dt(t.filename),o=ft.createSourceFile(e,t.raw,{scriptKind:pt.TSX});for(let r of mt)r({sourceFile:o,...t});return await xe({sourceFile:o,...t})}import Se from"chalk";import{Command as gt}from"commander";import{execa as be}from"execa";import ut from"ora";import q from"prompts";import*as x from"zod";var ht=x.object({components:x.array(x.string()).optional(),yes:x.boolean(),overwrite:x.boolean(),cwd:x.string(),all:x.boolean(),path:x.string().optional()}),Ie=new gt().name("add").description("add a component to your project").argument("[components...]","the components to add").option("-y, --yes","skip confirmation prompt.",!0).option("-o, --overwrite","overwrite existing files.",!1).option("-c, --cwd <cwd>","the working directory. defaults to the current directory.",process.cwd()).option("-a, --all","add all available components",!1).option("-p, --path <path>","the path to add the component to.").action(async(t,e)=>{try{let o=ht.parse({components:t,...e}),r=K.resolve(o.cwd);Y(r)||(p.error(`The path ${r} does not exist. Please try again.`),process.exit(1));let n=await C(r);n||(p.warn(`Configuration is missing. Please run ${Se.green("init")} to create a components.json file.`),process.exit(1));let i=await N(),s=o.all?i.map(c=>c.name):o.components;if(!o.components?.length&&!o.all){let{components:c}=await q({type:"multiselect",name:"components",message:"Which components would you like to add?",hint:"Space to select. A to toggle all. Enter to submit.",instructions:!1,choices:i.map(d=>({title:d.name,value:d.name,selected:o.all?!0:o.components?.includes(d.name)}))});s=c}s?.length||(p.warn("No components selected. Exiting."),process.exit(0));let m=await G(i,s),f=await O(n.style,m),h=await z(n.tailwind.baseColor);if(f.length||(p.warn("Selected components not found. Exiting."),process.exit(0)),!o.yes){let{proceed:c}=await q({type:"confirm",name:"proceed",message:"Ready to install components and dependencies. Proceed?",initial:!0});c||process.exit(0)}let l=ut("Installing components...").start();for(let c of f){l.text=`Installing ${c.name}...`;let d=await W(n,c,o.path?K.resolve(r,o.path):void 0);if(!d)continue;if(Y(d)||await Te.mkdir(d,{recursive:!0}),c.files.filter(T=>Y(K.resolve(d,T.name))).length&&!o.overwrite)if(s.includes(c.name)){l.stop();let{overwrite:T}=await q({type:"confirm",name:"overwrite",message:`Component ${c.name} already exists. Would you like to overwrite?`,initial:!1});if(!T){p.info(`Skipped ${c.name}. To overwrite, run with the ${Se.green("--overwrite")} flag.`);continue}l.start(`Installing ${c.name}...`)}else continue;for(let T of c.files){let $=K.resolve(d,T.name),Re=await U({filename:T.name,raw:T.content,config:n,baseColor:h});n.tsx||($=$.replace(/\.tsx$/,".jsx"),$=$.replace(/\.ts$/,".js")),await Te.writeFile($,Re)}let k=await L(r);c.dependencies?.length&&await be(k,[k==="npm"?"install":"add",...c.dependencies],{cwd:r}),c.devDependencies?.length&&await be(k,[k==="npm"?"install":"add","-D",...c.devDependencies],{cwd:r})}l.succeed("Done.")}catch(o){I(o)}});import{existsSync as X,promises as xt}from"fs";import Z from"path";import A from"chalk";import{Command as yt}from"commander";import{diffLines as wt}from"diff";import*as v from"zod";var Ct=v.object({component:v.string().optional(),yes:v.boolean(),cwd:v.string(),path:v.string().optional()}),Ee=new yt().name("diff").description("check for updates against the registry").argument("[component]","the component name").option("-y, --yes","skip confirmation prompt.",!1).option("-c, --cwd <cwd>","the working directory. defaults to the current directory.",process.cwd()).action(async(t,e)=>{try{let o=Ct.parse({component:t,...e}),r=Z.resolve(o.cwd);X(r)||(p.error(`The path ${r} does not exist. Please try again.`),process.exit(1));let n=await C(r);n||(p.warn(`Configuration is missing. Please run ${A.green("init")} to create a components.json file.`),process.exit(1));let i=await N();if(!o.component){let f=n.resolvedPaths.components,h=i.filter(c=>{for(let d of c.files){let w=Z.resolve(f,d);if(X(w))return!0}return!1}),l=[];for(let c of h){let d=await ze(c,n);d.length&&l.push({name:c.name,changes:d})}l.length||(p.info("No updates found."),process.exit(0)),p.info("The following components have updates available:");for(let c of l){p.info(`- ${c.name}`);for(let d of c.changes)p.info(`  - ${d.filePath}`)}p.break(),p.info(`Run ${A.green("diff <component>")} to see the changes.`),process.exit(0)}let s=i.find(f=>f.name===o.component);s||(p.error(`The component ${A.green(o.component)} does not exist.`),process.exit(1));let m=await ze(s,n);m.length||(p.info(`No updates found for ${o.component}.`),process.exit(0));for(let f of m)p.info(`- ${f.filePath}`),await vt(f.patch),p.info("")}catch(o){I(o)}});async function ze(t,e){let o=await O(e.style,[t]),r=await z(e.tailwind.baseColor),n=[];for(let i of o){let s=await W(e,i);if(s)for(let m of i.files){let f=Z.resolve(s,m.name);if(!X(f))continue;let h=await xt.readFile(f,"utf8"),l=await U({filename:m.name,raw:m.content,config:e,baseColor:r}),c=wt(l,h);c.length>1&&n.push({file:m.name,filePath:f,patch:c})}}return n}async function vt(t){t.forEach(e=>{if(e)return e.added?process.stdout.write(A.green(e.value)):e.removed?process.stdout.write(A.red(e.value)):process.stdout.write(e.value)})}import{existsSync as Oe,promises as E}from"fs";import P from"path";import B from"path";import ee from"fast-glob";import Q,{pathExists as Tt}from"fs-extra";import{loadConfig as St}from"tsconfig-paths";var te=["**/node_modules/**",".next","public","dist","build"];async function Pe(t){let e=await C(t);if(e)return e;let o=await bt(t),r=await It(t),n=await zt(t);if(!o||!r||!n)return null;let i=await Et(t),s={$schema:"https://ui.shadcn.com/schema.json",rsc:["next-app","next-app-src"].includes(o),tsx:i,style:"new-york",tailwind:{config:i?"tailwind.config.ts":"tailwind.config.js",baseColor:"zinc",css:r,cssVariables:!0,prefix:""},aliases:{utils:`${n}/lib/utils`,components:`${n}/components`}};return await b(t,s)}async function bt(t){if(!(await ee.glob("**/*",{cwd:t,deep:3,ignore:te})).find(i=>i.startsWith("next.config.")))return null;let r=await Q.pathExists(B.resolve(t,"src"));return await Q.pathExists(B.resolve(t,`${r?"src/":""}app`))?r?"next-app-src":"next-app":r?"next-pages-src":"next-pages"}async function It(t){let e=await ee.glob("**/*.css",{cwd:t,deep:3,ignore:te});if(!e.length)return null;for(let o of e)if((await Q.readFile(B.resolve(t,o),"utf8")).includes("@tailwind base"))return o;return null}async function zt(t){let e=await St(t);if(e?.resultType==="failed"||!e?.paths)return null;for(let[o,r]of Object.entries(e.paths))if(r.includes("./*")||r.includes("./src/*"))return o.at(0);return null}async function Et(t){return Tt(B.resolve(t,"tsconfig.json"))}async function $e(t){if(!(await ee.glob("tailwind.config.*",{cwd:t,deep:3,ignore:te})).length)throw new Error("Tailwind CSS is not installed. Visit https://tailwindcss.com/docs/installation to get started.");return!0}var je=`import { type ClassValue, clsx } from "clsx"
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                

Error: Tailwind CSS is not installed. Visit https://tailwindcss.com/docs/installation to get started.
    at $e (file:///Users/hirosue/.npm/_npx/125ee17d583c4e03/node_modules/shadcn-ui/dist/index.js:3:7112)

Node.js v20.11.1

tailwindcssを先にセットアップせよとのことなので、公式に乗っ取りセットアップする。

https://tailwindcss.com/docs/installation

  • setup tailwindcss
npm install -D tailwindcss
npx tailwindcss init
  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/1a304969f1cdf5c4001e1954ded44490d216df15

ThirosueThirosue

改めて 公式に手順に乗っ取り shadcn-ui をセットアップ

https://ui.shadcn.com/docs/installation/remix

SPAなので、React Server Components は、no にする。

  • components.json
components.json
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "default",
  "rsc": false,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.js",
    "css": "app/tailwind.css",
    "baseColor": "slate",
    "cssVariables": true,
    "prefix": ""
  },
  "aliases": {
    "components": "~/components",
    "utils": "~/lib/utils"
  }
}
  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/040fedff6940097777de6f031fa3d2dc4d9aacdf

ThirosueThirosue

公式に乗っ取り shadcn-ui の Button を追加してみる

https://ui.shadcn.com/docs/installation/remix

  • postcss.config.js を追加
touch postcss.config.js
postcss.config.js
export default {
    plugins: {
      tailwindcss: {},
      autoprefixer: {},
    },
  }
  • tailwind.css を Layout に設定する
app/root.tsx
+ import type { LinksFunction } from "@remix-run/node";
+ 
+ import stylesheet from "./tailwind.css?url";
+ 
+ export const links: LinksFunction = () => [
+  { rel: "stylesheet", href: stylesheet },
+ ]
  • Button コンポーネント追加
npx shadcn-ui@latest add button
  • root ファイルに追加して、表示確認
app/routes/_index.tsx
      </ul>
+      <Button>Click me</Button>
    </div>

正常に表示されることを確認

  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/70065f875ac1dc36e79eebbf3809be8ad29f7ea9

その他

  • Remix公式の手順ではエラー
npm run build

> build
> remix vite:build

vite v5.2.10 building for production...
✓ 94 modules transformed.
x Build failed in 363ms
Error [RollupError]: app/root.tsx (11:7): "default" is not exported by "app/globals.css", imported by "app/root.tsx".
file: /Users/hirosue/private/workspace/work/my-remix-app/app/root.tsx:11:7
 9: import type { LinksFunction } from "@remix-run/node";
10:
11: import styles from "./globals.css"
エラーの全量
npm run build

> build
> remix vite:build

vite v5.2.10 building for production...
✓ 94 modules transformed.
x Build failed in 363ms
Error [RollupError]: app/root.tsx (11:7): "default" is not exported by "app/globals.css", imported by "app/root.tsx".
file: /Users/hirosue/private/workspace/work/my-remix-app/app/root.tsx:11:7
 9: import type { LinksFunction } from "@remix-run/node";
10:
11: import styles from "./globals.css"
           ^
12:
13: export const links: LinksFunction = () => [
    at getRollupError (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/parseAst.js:394:41)
    at error (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/parseAst.js:390:42)
    at Module.error (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/node-entry.js:13852:16)
    at Module.traceVariable (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/node-entry.js:14300:29)
    at ModuleScope.findVariable (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/node-entry.js:11981:39)
    at ReturnValueScope.findVariable (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/node-entry.js:7432:38)
    at FunctionBodyScope.findVariable (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/node-entry.js:7432:38)
    at Identifier.bind (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/node-entry.js:6908:40)
    at Property.bind (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/node-entry.js:4775:23)
    at ObjectExpression.bind (file:///Users/hirosue/private/workspace/work/my-remix-app/node_modules/rollup/dist/es/shared/node-entry.js:4771:28) {
  binding: 'default',
  code: 'MISSING_EXPORT',
  exporter: '/Users/hirosue/private/workspace/work/my-remix-app/app/globals.css',
  id: '/Users/hirosue/private/workspace/work/my-remix-app/app/root.tsx',
  url: 'https://rollupjs.org/troubleshooting/#error-name-is-not-exported-by-module',
  pos: 138,
  loc: {
    column: 7,
    file: '/Users/hirosue/private/workspace/work/my-remix-app/app/root.tsx',
    line: 11
  },
  frame: ' 9: import type { LinksFunction } from "@remix-run/node";\n' +
    '10: \n' +
    '11: import styles from "./globals.css"\n' +
    '           ^\n' +
    '12: \n' +
    '13: export const links: LinksFunction = () => [',
  watchFiles: [
    '/Users/hirosue/private/workspace/work/my-remix-app/app/entry.client.tsx',
    '/Users/hirosue/private/workspace/work/my-remix-app/app/routes/_index.tsx',
    '/Users/hirosue/private/workspace/work/my-remix-app/app/root.tsx',
    '/Users/hirosue/private/workspace/work/my-remix-app/package.json',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react/jsx-runtime.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react-dom/client.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/components.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/scroll-restoration.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/browser.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/server.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react-router-dom/dist/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react-dom/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react/cjs/react-jsx-runtime.production.min.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react/cjs/react.production.min.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/app/components/ui/button.tsx',
    '/Users/hirosue/private/workspace/work/my-remix-app/app/globals.css',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/_virtual/_rollupPluginBabelHelpers.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/errorBoundaries.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/routes.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/single-fetch.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/cookies.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/formData.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/responses.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/server.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react-router-dom/server.mjs',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/sessions/cookieStorage.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/single-fetch.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/upload/memoryUploadHandler.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/sessions/memoryStorage.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/upload/errors.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/sessions.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/dev.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/routeModules.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/links.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/data.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/fallback.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/invariant.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/markup.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/app/lib/utils.ts',
    '/Users/hirosue/private/workspace/work/my-remix-app/tailwind.config.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/turbo-stream/dist/turbo-stream.mjs',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/router/dist/router.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/cookie/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/warnings.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@web3-storage/multipart-parser/esm/src/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/entry.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/headers.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/invariant.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/mode.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/routeMatching.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/routes.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/serverHandoff.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/routeModules.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/errors.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@web3-storage/multipart-parser/esm/src/search.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@web3-storage/multipart-parser/esm/src/utils.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/set-cookie-parser/lib/set-cookie.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/data.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/server-runtime/dist/esm/markup.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@remix-run/react/dist/esm/errors.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react-router/dist/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/react-dom/cjs/react-dom.production.min.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/scheduler/index.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@radix-ui/react-slot/dist/index.mjs',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/class-variance-authority/dist/index.mjs',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/scheduler/cjs/scheduler.production.min.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@babel/runtime/helpers/esm/extends.js',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/@radix-ui/react-compose-refs/dist/index.mjs',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/class-variance-authority/node_modules/clsx/dist/clsx.mjs',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/clsx/dist/clsx.mjs',
    '/Users/hirosue/private/workspace/work/my-remix-app/node_modules/tailwind-merge/dist/bundle-mjs.mjs'
  ],
  [Symbol(augmented)]: true
}
  • tailwind 公式の通り末尾に ?url を付与すると解消
app/root.tsx
- import stylesheet from "./tailwind.css";
+ import stylesheet from "./tailwind.css?url";
ThirosueThirosue

Git hookで 各種設定(フォーマット、lint)をフックするために、公式の通り、huskyをインストールする

https://typicode.github.io/husky/get-started.html

  • husky install
npm install --save-dev husky
  • husky settings
npx husky init
  • test はまだ設定してないため、エラー
git commit -m "Update package.json and package-lock.json with husky dependency"
npm ERR! Missing script: "test"
npm ERR!
npm ERR! To see a list of scripts, run:
npm ERR!   npm run

npm ERR! A complete log of this run can be found in: /Users/hirosue/.npm/_logs/2024-04-30T04_51_44_180Z-debug-0.log
husky - pre-commit script failed (code 1)
  • ログ出力に変更
.husky/pre-commit
- npm test
+ echo "husky: pre-commit hook started"
  • 設定完了
git commit -m "Update package.json and package-lock.json with husky dependency"
husky: pre-commit hook started
[main d423397] Update package.json and package-lock.json with husky dependency
 3 files changed, 20 insertions(+), 1 deletion(-)
 create mode 100644 .husky/pre-commit
  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/d423397ff0a8fddde6563497bb4910090d693da2

ThirosueThirosue

一旦、全ファイルフォーマット

  • do format
npx prettier . --write
  • log
npx prettier . --write
.eslintrc.cjs 25ms (unchanged)
.prettierrc 7ms (unchanged)
app/components/ui/button.tsx 77ms
app/entry.client.tsx 4ms
app/lib/utils.ts 2ms
app/root.tsx 4ms
app/routes/_index.tsx 5ms
app/tailwind.css 14ms
components.json 1ms
doc/20240429131741.md 10ms
env.d.ts 1ms (unchanged)
package-lock.json 62ms (unchanged)
package.json 15ms (unchanged)
postcss.config.js 1ms
README.md 5ms (unchanged)
tailwind.config.js 3ms
tsconfig.json 16ms (unchanged)
vite.config.ts 1ms (unchanged)
  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/27bec0d2f5cf7c80ab395e8ff5debec0c1d0e1ef

ThirosueThirosue

lint-staged を公式の通り導入して、lint error などが発生した際、コミット不可とできるようにします。

https://github.com/lint-staged/lint-staged?tab=readme-ov-file#installation-and-setup

  • install
npm install --save-dev lint-staged
  • package.jsonに設定を追加します
package.json
  },
+  "lint-staged": {
+    "*.{ts,tsx}": [
+      "prettier --write"
+    ]
+  },
  "dependencies": {
  • pre-commit設定を修正
.husky/pre-commit
- echo "husky: pre-commit hook started"
+ npx lint-staged
  • テストのため、app/entry.client.tsx を修正し、コミット対象に含める

コミット時、フォーマットされることを確認

  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/096b09c00abe8068eb14f9097f78ef5b06df7c71

ThirosueThirosue

以下の記事を参考に、remixのESLintの設定をFlat Configで記述し直す

https://qiita.com/KokiSakano/items/e4cd51b85ca0be3ef574

  • app/routes/hoge.tsx を記事の通り作成し、チェック
npm run lint

> lint
> eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .

=============

WARNING: You are currently running a version of TypeScript which is not officially supported by @typescript-eslint/typescript-estree.

You may find that it works just fine, or you may not.

SUPPORTED TYPESCRIPT VERSIONS: >=4.3.5 <5.4.0

YOUR TYPESCRIPT VERSION: 5.4.5

Please only submit bug reports when using the officially supported version.

=============

/Users/hirosue/private/workspace/remix-spa-shadcn-dashboard-sample/app/routes/hoge.tsx
   5:1  error  The update clause in this loop moves the variable in the wrong direction                                                                                                                                                                                                                                                                                                  for-direction
  14:9  error  Missing "key" prop for element in iterator                                                                                                                                                                                                                                                                                                                                react/jsx-key
  24:5  error  React Hook "useEffect" is called conditionally. React Hooks must be called in the exact same order in every component render                                                                                                                                                                                                                                              react-hooks/rules-of-hooks
  33:7  error  'App' is assigned a value but never used                                                                                                                                                                                                                                                                                                                                  @typescript-eslint/no-unused-vars
  36:7  error  The href attribute is required for an anchor to be keyboard accessible. Provide a valid, navigable address as the href value. If you cannot provide an href, but still need the element to resemble a link, use a button and change it with appropriate styles. Learn more: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/anchor-is-valid.md  jsx-a11y/anchor-is-valid
  44:1  error  The array literal notation [] is preferable                                                                                                                                                                                                                                                                                                                               @typescript-eslint/no-array-constructor

✖ 6 problems (6 errors, 0 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.
  • 記事の通り、eslint.config.js を修正し、再度チェック
npm run lint

> lint
> eslint --cache --cache-location ./node_modules/.cache/eslint .


/Users/hirosue/private/workspace/remix-spa-shadcn-dashboard-sample/app/routes/hoge.tsx
   5:1  error  The update clause in this loop moves the variable in the wrong direction                                                                                                                                                                                                                                                                                                  for-direction
  14:9  error  Missing "key" prop for element in iterator                                                                                                                                                                                                                                                                                                                                react/jsx-key
  24:5  error  React Hook "useEffect" is called conditionally. React Hooks must be called in the exact same order in every component render                                                                                                                                                                                                                                              react-hooks/rules-of-hooks
  33:7  error  'App' is assigned a value but never used                                                                                                                                                                                                                                                                                                                                  @typescript-eslint/no-unused-vars
  36:7  error  The href attribute is required for an anchor to be keyboard accessible. Provide a valid, navigable address as the href value. If you cannot provide an href, but still need the element to resemble a link, use a button and change it with appropriate styles. Learn more: https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/HEAD/docs/rules/anchor-is-valid.md  jsx-a11y/anchor-is-valid
  44:1  error  The array literal notation [] is preferable                                                                                                                                                                                                                                                                                                                               @typescript-eslint/no-array-constructor

✖ 6 problems (6 errors, 0 warnings)
  1 error and 0 warnings potentially fixable with the `--fix` option.

同じエラーが出力されることを確認し、app/routes/hoge.tsxを削除

  • 現在の状態でエラーが存在しないことを確認
npm run lint

> lint
> eslint --cache --cache-location ./node_modules/.cache/eslint .
  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/427a64351c7606f3325005da32f76a74d581a385

ThirosueThirosue

eslint に no-cosole を追加してみる。

  • ルール追加
eslint.config.js
    rules: {
      ...reactRecommended.rules,
      ...reactJSXRuntime.rules,
+      "no-console": "error",
    },
  • app/root.tsx を修正
app/root.tsx
export function Layout({ children }: { children: React.ReactNode }) {
+  console.log("Layout");

  return (
    <html lang="en">
  • check

エラーとなることを確認

npx eslint app/root.tsx
=============

WARNING: You are currently running a version of TypeScript which is not officially supported by @typescript-eslint/typescript-estree.

You may find that it works just fine, or you may not.

SUPPORTED TYPESCRIPT VERSIONS: >=4.3.5 <5.4.0

YOUR TYPESCRIPT VERSION: 5.4.5

Please only submit bug reports when using the officially supported version.

=============

/Users/hirosue/private/workspace/remix-spa-shadcn-dashboard-sample/app/root.tsx
  18:3  error  Unexpected console statement  no-console

✖ 1 problem (1 error, 0 warnings)

app/root.tsxを元に戻して(git checkout app/root.tsx)、コミット

  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/20230495b081653a34ed725b7146bf6507343041

ThirosueThirosue

Dependabot を設定します。

https://docs.github.com/ja/code-security/supply-chain-security/understanding-your-software-supply-chain/about-supply-chain-security#what-is-dependabot

いつもは、Renovate ですが、GitHubの機能で設定してみます。

全て、Enableで設定。

  • .github/dependabot.yml を追加
.github/dependabot.yml
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file

version: 2
updates:
  - package-ecosystem: "npm" # npm で設定
    directory: "/" # Location of package manifests
    schedule:
      interval: "weekly"
  • GitHub Actionsが走って、PRが作成される

  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/13a85dc022daead4993830b03b566efaacf5621d

ThirosueThirosue

ログイン画面を作成していきます。

  • 作成する画面

UIは以下レポジトリを参照にして作成します。

https://github.com/Kiranism/next-shadcn-dashboard-starter

https://github.com/Thirosue/next-shadcn-sample

shadcn-ui で 必要なコンポーネント群を追加します。

  • card
npx shadcn-ui@latest add card
  • input
npx shadcn-ui@latest add input
  • form
npx shadcn-ui@latest add form
  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/e4ac2aa25715ed5d161edc9a4c600633aab9e47f

ThirosueThirosue

ログインフォームのpassword入力エリアを作成します。

  • 作成するもの

input typeをtextとpasswordでトグルさせます。

app/components/password-input.tsx
import * as React from "react";
import { EyeNoneIcon, EyeOpenIcon } from "@radix-ui/react-icons";

import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
import { Input, type InputProps } from "~/components/ui/input";

const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
  ({ className, ...props }, ref) => {
    const [showPassword, setShowPassword] = React.useState(false);
    return (
      <div className="relative">
        <Input
          type={showPassword ? "text" : "password"}
          className={cn("pr-10", className)}
          ref={ref}
          autoComplete="current-password"
          {...props}
        />
        <Button
          type="button"
          variant="ghost"
          size="sm"
          className="absolute right-0 top-0 h-full px-3 py-1 hover:bg-transparent"
          onClick={() => setShowPassword((prev) => !prev)}
          disabled={props.value === "" || props.disabled}
        >
          {showPassword ? (
            <EyeNoneIcon className="size-4" aria-hidden="true" />
          ) : (
            <EyeOpenIcon className="size-4" aria-hidden="true" />
          )}
          <span className="sr-only">
            {showPassword ? "Hide password" : "Show password"}
          </span>
        </Button>
      </div>
    );
  },
);
PasswordInput.displayName = "PasswordInput";

export { PasswordInput };
  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/e76292cb7d162f8e2efc9a010bbdfba7f376ac13

ThirosueThirosue

UIパーツの作成が完了したため、公式の通り、レイアウトファイルを作成して、ログイン画面を作成します。
フォルダによる切り分けに慣れているのですが、一旦、フォルダを利用しない従来の方式で作成します。

https://remix.run/docs/en/main/discussion/routes#conventional-route-configuration

  • Layout
app/routes/auth.tsx
import { Outlet, Link } from "@remix-run/react";
import { siteConfig } from "~/config/site";
import { Icons } from "~/components/icons";

export default function App() {
  return (
    <div className="flex min-h-screen items-center justify-center">
      <Link
        to="/"
        className="absolute left-8 top-6 z-20 flex items-center text-lg font-bold tracking-tight"
      >
        <Icons.logo className="mr-2 size-6" aria-hidden="true" />
        <span>{siteConfig.name}</span>
      </Link>
      <main className="container">
        <Outlet />
      </main>
    </div>
  );
}
  • Page
app/routes/auth.signin.tsx
import type { MetaFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";

import {
  Card,
  CardContent,
  CardFooter,
  CardHeader,
  CardTitle,
} from "~/components/ui/card";
import { Shell } from "~/components/shell";
import { SignInForm } from "~/components/auth/signin-form";

export const meta: MetaFunction = () => {
  return [
    { title: "Sign In" },
    { name: "description", content: "Sign in to your account" },
  ];
};

export default function SignInPage() {
  return (
    <Shell className="max-w-lg">
      <Card>
        <CardHeader className="space-y-1">
          <CardTitle className="text-2xl">Sign in</CardTitle>
        </CardHeader>
        <CardContent className="grid gap-4">
          <SignInForm />
        </CardContent>
        <CardFooter className="flex flex-wrap items-center justify-between gap-2">
          <div className="text-sm text-muted-foreground">
            <span className="mr-1 hidden sm:inline-block">
              Don&apos;t have an account?
            </span>
            <Link
              aria-label="Sign up"
              to="/auth/signup"
              className="text-primary underline-offset-4 transition-colors hover:underline"
            >
              Sign up
            </Link>
          </div>
          <Link
            aria-label="Reset password"
            to="/auth/signin/reset-password"
            className="text-sm text-primary underline-offset-4 transition-colors hover:underline"
          >
            Reset password
          </Link>
        </CardFooter>
      </Card>
    </Shell>
  );
}
  • Page Compornents
app/components/auth/signin-form.tsx
import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import type { z } from "zod";

import { authSchema } from "~/lib/validations/auth";
import { Button } from "~/components/ui/button";
import {
  Form,
  FormControl,
  FormField,
  FormItem,
  FormLabel,
  FormMessage,
} from "~/components/ui/form";
import { Input } from "~/components/ui/input";
import { PasswordInput } from "~/components/password-input";

type Inputs = z.infer<typeof authSchema>;

export function SignInForm() {
  // react-hook-form
  const form = useForm<Inputs>({
    resolver: zodResolver(authSchema),
    defaultValues: {
      email: "",
      password: "",
    },
  });

  async function onSubmit(data: Inputs) {}

  return (
    <Form {...form}>
      <form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>
        <FormField
          control={form.control}
          name="email"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Email</FormLabel>
              <FormControl>
                <Input
                  type="text"
                  placeholder="rodneymullen180@gmail.com"
                  autoComplete="username"
                  {...field}
                />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <FormField
          control={form.control}
          name="password"
          render={({ field }) => (
            <FormItem>
              <FormLabel>Password</FormLabel>
              <FormControl>
                <PasswordInput placeholder="**********" {...field} />
              </FormControl>
              <FormMessage />
            </FormItem>
          )}
        />
        <Button type="submit">
          Sign in
          <span className="sr-only">Sign in</span>
        </Button>
      </form>
    </Form>
  );
}
  • 参考:フォルダ構成

https://makerkit.dev/docs/remix-fire/application-structure

  • Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/0cc623aea4b9bbd837b3096b7ea5c968c21d0a11

ThirosueThirosue

ログイン画面を実装していきます。

実装する動き

考察

Remixのactions(SSR)を利用する場合、Conformを利用するのが、Remixのサーバーサイドとクライアントサイドの処理をシームレスに連携できるため、採用するメリットが大きそうです。

しかし、Spaモードで利用する場合、クライアントバリデーションとサーバーサイドのバリデーションを相互に実装する必要があるため、以下の点を考慮して、馴染みのある shadcn-ui の Form (React-Hook-Form) を利用していきます。

方法 メリット デメリット
Conform - 公式のリファレンス実装通りで統合性が高い
- クライアントとサーバーで同じバリデーションロジックを利用可能(※Spaモードでは無効)
- 複雑なフォームに対するコード量が増加する可能性がある
React-Hook-Form - コードの量が少なくシンプル
- 外部バリデーションライブラリとの統合が容易
- Remixとの統合が直感的ではない

サーバとのデータのやり取りは、非同期の状態管理ライブラリであるTanStack Queryを利用します。

install

npm i @tanstack/react-query

バックエンドは、以前作成したダミーのAPIを利用します。

http POST https://next-typescript-sample-mu.vercel.app/api/auth id=test@test.com password=admin
HTTP/1.1 200 OK
Access-Control-Allow-Headers: Content-Type, Authorization
Access-Control-Allow-Methods: GET, POST, OPTIONS
Access-Control-Allow-Origin: *
Cache-Control: public, max-age=0, must-revalidate
Connection: keep-alive
Content-Length: 201
Content-Type: application/json; charset=utf-8
Date: Fri, 03 May 2024 06:11:10 GMT
Etag: "c9-PDcYRx3NdRkZNfDNysu6mbKC9u0"
Server: Vercel
Strict-Transport-Security: max-age=63072000; includeSubDomains; preload
X-Matched-Path: /api/auth
X-Vercel-Cache: MISS
X-Vercel-Id: hnd1::iad1::lwsgp-1714716669706-90c9ccf38d87

{
    "status": "ok",
    "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE3MTQ3MjAyNjksInBheWxvYWQiOnsidXNlciI6InRlc3RAdGVzdC5jb20ifSwiaWF0IjoxNzE0NzE2NjY5fQ.FuE9EJFyyANZb-JxgVV9m1n3z9CDktd53cbHhuGxokM"
}

Providersの設定(QueryClientProvider)

  • Providersを一元管理できるように新規ファイルを作成し、TanStackのQueryClientProviderを設定します。
app/components/layout/providers.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
  );
}
  • root layoutで利用します。
app/root.tsx
+ import { Providers } from "~/components/layout/providers";

      <body className={cn("min-h-screen bg-background font-sans antialiased")}>
+        <Providers>{children}</Providers>
        <ScrollRestoration />
        <Scripts />
      </body>

ログインフォームでmutationを作成し、submit時にコールします。

app/components/auth/signin-form.tsx
+  const mutation = useMutation({
+    mutationFn: (data: Inputs) => {
+     return postData("/api/auth", data); // fetchでpost
+    },
+    onSuccess: (data) => {
+      navigate("/");
+      alert("Sign in successful! 🎉");
+    },
+  });

+  async function onSubmit(data: Inputs) {
+    captains.log("do something with the data", data);
+    mutation.mutate(data);
+  }

  return (
    <Form {...form}>
      <form className="grid gap-4" onSubmit={form.handleSubmit(onSubmit)}>

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/c51d4ceb940276b68f228c9294f44f5cbce31f54

参考

https://azukiazusa.dev/blog/remix-spa-mode/#データのミューテーション

https://tech.dely.jp/entry/2024/03/19/164444

https://zenn.dev/taisei_13046/books/133e9995b6aadf

ThirosueThirosue

フォームのSubmitについて、Submit後にButtonをdisableにしないと、何重にもリクエストが送信されてしまいます。サーバサイドでの制御も必要ですが、クライアントサイドでもボタンをDisableにして、ローディングアイコンを表示させます。

非同期の状態管理ライブラリを利用しているので、実行中の状態(pending)は容易に取得できます。

app/components/auth/signin-form.tsx
+        <Button type="submit" disabled={mutation.isPending}>
+          {mutation.isPending && (
+            <Icons.spinner
+              className="mr-2 size-4 animate-spin"
+              aria-hidden="true"
+            />
+          )}
          Sign in
          <span className="sr-only">Sign in</span>
        </Button>

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/beb6f091791748a9d7f6dbc3b70ed50978dc1046

ThirosueThirosue

NextAuthのSessionProviderに相当する簡易実装を行います。
→バックエンドがモックAPIのため、簡易実装としています。

状態管理にはReact標準のContextを使用します。

https://next-auth.js.org/getting-started/client#sessionprovider

SessionProviderを実装します。

import React, { createContext, useContext, useState } from "react";

type SessionState = {
  name: string;
  email: string;
  image: string;
  token: string;
} | null;

type SessionValue = {
  session: SessionState;
  updateSession: (value: SessionState) => void;
  clearSession: () => void;
};

const defaultSessionValue = {
  session: null,
  updateSession: (value: SessionState) => {},
  clearSession: () => {},
};

const SessionContext = createContext(defaultSessionValue as SessionValue);

export const SessionProvider = ({
  children,
}: {
  children: React.ReactNode;
}): JSX.Element => {
  const [session, setSession] = useState<SessionState>(null);

  const clearSession = () => {
    setSession(null);
  };

  const updateSession = (value: SessionState) => {
    if (value) {
      setSession((prevSession) => ({ ...prevSession, ...value }));
    }
  };

  return (
    <SessionContext.Provider
      value={{
        session,
        updateSession,
        clearSession,
      }}
    >
      {children}
    </SessionContext.Provider>
  );
};

// カスタムフックを作成してコンテキストにアクセスできるようにする
export const useSession = () => useContext(SessionContext);

export default SessionProvider;

このSessionProviderをlayoutコンポーネントで使用します。

import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Toaster } from "~/components/ui/toaster";
import SessionProvider from "./session-provider";

const queryClient = new QueryClient();

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <SessionProvider>
        <Toaster />
        {children}
      </SessionProvider>
    </QueryClientProvider>
  );
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/f363531b3b7d6d091f04069e3ec60d919a91766e

ThirosueThirosue

前述のSessionProvider/Contextを利用して、ログアウトの簡易実装を行います。

トップ画面の修正

app/routes/_index.tsx
+ const { session, clearSession } = useSession();
+ const navigate = useNavigate();
+ const { toast } = useToast();

  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.8" }}>
+     {session && <h1>Welcome to {session.name}!</h1>}
      <ul>

// その他のコード

      <Button
+       onClick={() => {
+         clearSession();
+         navigate("/auth/signin");
+         toast({
+           description: "Logged out successfully!",
+         });
+       }}
      >
        Log Out
      </Button

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/83461afe9b23afafac47ebcd0f61eab277b05b55

ThirosueThirosue

管理ダッシュボードのlayoutを作成します。
作成する画面イメージは以下です。

必要なコンポーネントの追加

shadcn-ui ライブラリから必要なコンポーネントを追加します。

  • sheet
npx shadcn-ui@latest add sheet

✔ Done.
  • avatar
npx shadcn-ui@latest add avatar

✔ Done.
  • dropdown-menu
npx shadcn-ui@latest add dropdown-menu

✔ Done.

レイアウトファイルの作成

app/routes/dashboard.tsx
import { Outlet } from "@remix-run/react";
import Header from "~/components/layout/header";
import Sidebar from "~/components/layout/sidebar";

export default function App() {
  return (
    <>
      <Header />
      <div className="flex h-screen">
        <Sidebar />
        <main className="w-full pt-16">
          <Outlet />
        </main>
      </div>
    </>
  )
}

その他、HeaderやSidebarコンポーネントの説明は省略します。
詳細は、以下のコミットを確認ください。

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/edd9189a2cd6e039de60ba384a1d01ba9b0c4fb3

ThirosueThirosue

新規に商品一覧ページを実装します。
まず商品一覧ページの追加のみ行います。一覧テーブル表示は後ほど実装します。
仮実装する内容は以下です。

商品一覧ページの追加

app/routes/dashboard.product.tsx
import type { MetaFunction } from "@remix-run/node";
import { Shell } from "~/components/shell";
import { getData } from "~/lib/fetch";
import { useLoaderData } from "@remix-run/react";

const captains = console;

export const meta: MetaFunction = () => {
  return [
    { title: "DashBoard - Product" },
    { name: "description", content: "DashBoard Product" },
  ];
};

// SPAモードのため、clientLoaderを利用します。
export async function clientLoader() {
  // During client-side navigations, we hit our exposed API endpoints directly
  const data = await getData("/api/products?page=0&rows=5");
  return data;
}

export default function Product() {
  const { count, data } = useLoaderData<typeof clientLoader>();
  captains.log("Product Page Result size: ", count, data);
  return (
    <Shell className="max-w-lg">
      Product Page Result size: {count}
    </Shell>
  );
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/b3a657d52c123c154b84d009dcb8d3a192d8c437

ThirosueThirosue

次に商品一覧に必要なコンポーネントを準備します。
shadcn-ui ライブラリのコンポーネント、および少し拡張したコンポーネントを追加します。

必要なコンポーネントの追加

  • checkbox
npx shadcn-ui@latest add checkbox

✔ Done.
  • select
npx shadcn-ui@latest add select

✔ Done.
  • table
npx shadcn-ui@latest add table

✔ Done.
  • scroll-area
npx shadcn-ui@latest add scroll-area

✔ Done.
  • alert-dialog
npx shadcn-ui@latest add alert-dialog

✔ Done.
  • dialog
npx shadcn-ui@latest add dialog

✔ Done.
  • separator
npx shadcn-ui@latest add separator

✔ Done.

必要なライブラリの追加

  • @tanstack/react-table
npm install @tanstack/react-table


added 2 packages, and audited 914 packages in 3s

285 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

拡張したコンポーネント

確認モーダル

アイテム削除時などに表示する確認モーダルのコンポーネントを追加します。

  • Modal
app/components/ui/modal.tsx
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
} from "~/components/ui/dialog";

interface ModalProps {
  title: string;
  description: string;
  isOpen: boolean;
  onClose: () => void;
  children?: React.ReactNode;
}

export const Modal: React.FC<ModalProps> = ({
  title,
  description,
  isOpen,
  onClose,
  children,
}) => {
  const onChange = (open: boolean) => {
    if (!open) {
      onClose();
    }
  };

  return (
    <Dialog open={isOpen} onOpenChange={onChange}>
      <DialogContent>
        <DialogHeader>
          <DialogTitle>{title}</DialogTitle>
          <DialogDescription>{description}</DialogDescription>
        </DialogHeader>
        <div>{children}</div>
      </DialogContent>
    </Dialog>
  );
};
  • AlertModal
app/components/modal/alert-modal.tsx
import { useEffect, useState } from "react";

import { Button } from "~/components/ui/button";
import { Modal } from "~/components/ui/modal";

interface AlertModalProps {
  isOpen: boolean;
  onClose: () => void;
  onConfirm: () => void;
  loading: boolean;
}

export const AlertModal: React.FC<AlertModalProps> = ({
  isOpen,
  onClose,
  onConfirm,
  loading,
}) => {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    setIsMounted(true);
  }, []);

  if (!isMounted) {
    return null;
  }

  return (
    <Modal
      title="Are you sure?"
      description="This action cannot be undone."
      isOpen={isOpen}
      onClose={onClose}
    >
      <div className="flex w-full items-center justify-end space-x-2 pt-6">
        <Button disabled={loading} variant="outline" onClick={onClose}>
          Cancel
        </Button>
        <Button disabled={loading} onClick={onConfirm}>
          Continue
        </Button>
      </div>
    </Modal>
  );
};

パンくず

パンくずリスト表示用のコンポーネントを追加します。

  • breadcrumb
app/components/breadcrumb.tsx
import React from "react";
import { Link } from "@remix-run/react";
import { ChevronRightIcon } from "@radix-ui/react-icons";

import { cn } from "~/lib/utils";

type BreadCrumbType = {
  title: string;
  link: string;
};

type BreadCrumbPropsType = {
  items: BreadCrumbType[];
};

export default function BreadCrumb({ items }: BreadCrumbPropsType) {
  return (
    <div className="mb-4 flex items-center space-x-1 text-sm text-muted-foreground">
      <Link
        to={"/dashboard/home"}
        className="overflow-hidden text-ellipsis whitespace-nowrap"
      >
        Dashboard
      </Link>
      {items?.map((item: BreadCrumbType, index: number) => (
        <React.Fragment key={item.title}>
          <ChevronRightIcon className="h-4 w-4" />
          <Link
            to={item.link}
            className={cn(
              "font-medium",
              index === items.length - 1
                ? "pointer-events-none text-foreground"
                : "text-muted-foreground",
            )}
          >
            {item.title}
          </Link>
        </React.Fragment>
      ))}
    </div>
  );
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/267b7329fd647cb52dce7c79d6abb647856f7798

ThirosueThirosue

新規に商品一覧ページを実装します。(続き)
必要なコンポーネントの追加は完了したため、loaderで取得したデータを一覧表示します。
作成する画面イメージは以下です。
※ページング制御、ローディング制御、検索パラメータの制御等の調整は後ほど実施します。

一覧表示に必要なコンポーネントの追加

shadcn-uiおよびTanStack Tableのドキュメントを参考に、ページング可能なテーブルコンポーネントを作成します。

https://ui.shadcn.com/docs/components/data-table

https://tanstack.com/table/latest

  • ページング可能なテーブルコンポーネント
app/components/ui/pageable-table.tsx
// jsxのみ記載。詳細は公式ドキュメントおよびCommitを参照
  return (
    <>
      <ScrollArea className="h-[calc(50vh-220px)] rounded-md border">
        <Table className="relative">
          <TableHeader>
            {table.getHeaderGroups().map((headerGroup) => (
              <TableRow key={headerGroup.id}>
                {headerGroup.headers.map((header) => {
                  return (
                    <TableHead key={header.id}>
                      <div
                        className="flex items-center"
                        onClick={header.column.getToggleSortingHandler()}
                      >
                        {header.isPlaceholder
                          ? null
                          : flexRender(
                              header.column.columnDef.header,
                              header.getContext(),
                            )}
                        {{
                          asc: <TriangleUpIcon />,
                          desc: <TriangleDownIcon />,
                        }[header.column.getIsSorted() as string] ?? null}
                      </div>
                    </TableHead>
                  );
                })}
              </TableRow>
            ))}
          </TableHeader>
          <TableBody>
            {table.getRowModel().rows?.length ? (
              table.getRowModel().rows.map((row) => (
                <TableRow
                  key={row.id}
                  data-state={row.getIsSelected() && "selected"}
                >
                  {row.getVisibleCells().map((cell) => (
                    <TableCell key={cell.id}>
                      {flexRender(
                        cell.column.columnDef.cell,
                        cell.getContext(),
                      )}
                    </TableCell>
                  ))}
                </TableRow>
              ))
            ) : (
              <TableRow>
                <TableCell
                  colSpan={columns.length}
                  className="h-24 text-center"
                >
                  No results.
                </TableCell>
              </TableRow>
            )}
          </TableBody>
        </Table>
        <ScrollBar orientation="horizontal" />
      </ScrollArea>

      <div className="flex flex-col items-center justify-end gap-2 space-x-2 py-4 sm:flex-row">
        <div className="flex w-full items-center justify-between">
          <div className="flex-1 text-sm text-muted-foreground">
            Showing {startRow}-{endRow} of {totalCount} rows.
          </div>
          <div className="flex flex-col items-center gap-4 sm:flex-row sm:gap-6 lg:gap-8">
            <div className="flex items-center space-x-2">
              <p className="whitespace-nowrap text-sm font-medium">
                Rows per page
              </p>
              <Select
                value={`${table.getState().pagination.pageSize}`}
                onValueChange={(value) => {
                  table.setPageSize(Number(value));
                }}
              >
                <SelectTrigger className="h-8 w-[70px]">
                  <SelectValue
                    placeholder={table.getState().pagination.pageSize}
                  />
                </SelectTrigger>
                <SelectContent side="top">
                  {pageSizeOptions.map((pageSize) => (
                    <SelectItem key={pageSize} value={`${pageSize}`}>
                      {pageSize}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
            </div>
          </div>
        </div>
        <div className="flex w-full items-center justify-between gap-2 sm:justify-end">
          <div className="flex w-[100px] items-center justify-center text-sm font-medium">
            Page {table.getState().pagination.pageIndex + 1} of{" "}
            {table.getPageCount()}
          </div>
          <div className="flex items-center space-x-2">
            <Button
              aria-label="Go to first page"
              variant="outline"
              className="hidden h-8 w-8 p-0 lg:flex"
              onClick={() => table.setPageIndex(0)}
              disabled={!table.getCanPreviousPage()}
            >
              <DoubleArrowLeftIcon className="h-4 w-4" aria-hidden="true" />
            </Button>
            <Button
              aria-label="Go to previous page"
              variant="outline"
              className="h-8 w-8 p-0"
              onClick={() => table.previousPage()}
              disabled={!table.getCanPreviousPage()}
            >
              <ChevronLeftIcon className="h-4 w-4" aria-hidden="true" />
            </Button>
            <Button
              aria-label="Go to next page"
              variant="outline"
              className="h-8 w-8 p-0"
              onClick={() => table.nextPage()}
              disabled={!table.getCanNextPage()}
            >
              <ChevronRightIcon className="h-4 w-4" aria-hidden="true" />
            </Button>
            <Button
              aria-label="Go to last page"
              variant="outline"
              className="hidden h-8 w-8 p-0 lg:flex"
              onClick={() => table.setPageIndex(table.getPageCount() - 1)}
              disabled={!table.getCanNextPage()}
            >
              <DoubleArrowRightIcon className="h-4 w-4" aria-hidden="true" />
            </Button>
          </div>
        </div>
      </div>
    </>
  );

商品一覧ページの修正

  • 商品一覧

上記テーブルコンポーネントを利用して、商品一覧画面のコンポーネントを作成します。
商品一覧コンポーネントには、パンくず、商品検索フォーム、商品一覧表示テーブルを含みます。

app/routes/dashboard.product.tsx
export default function Product() {
  const { count, data } = useLoaderData<typeof clientLoader>();
  captains.log("Product Page Result size: ", count, data);
+  const isPending = false;
+  const searchParams: ProductSearchFormValues = {
+    page: 0,
+    limit: 5,
+    sort: "name,asc",
+  };

  return (
    <Shell variant="sidebar">
+      <BreadCrumb items={breadcrumbItems} />
+      <ProductTableHeader isPending={isPending} totalCount={count} />
+      <Separator />
+      <ProductSearchForm searchParams={searchParams} />
+      <Separator />
+      {isPending ? (
+       <Skeleton className="h-[calc(65vh-220px)] rounded-md border" />
+      ) : (
+        <PageableTable
+          pageNo={searchParams.page!}
+          columns={columns}
+          totalCount={count}
+          data={data}
+          initailSort={parseSortQueryParam(searchParams.sort)}
+          pageCount={Math.ceil(count / searchParams.limit!)}
+        />
+      )}
    </Shell>
  );
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/159d94bc56b3670c65c1eb341cf8b41b184fe7d0

ThirosueThirosue

新規に商品一覧ページを実装します。(続き)
商品一覧ページの初期レンダリング(Suspense)制御を実施します。
作成する画面イメージは以下です。

以下、issueを参考に、初期レンダリング時のローディング制御を実装します。

https://github.com/remix-run/remix/discussions/9159

商品一覧ページの修正

loaderでPromiseを返すように修正、かつ非同期処理中はfallbackの上、Skeletonを返すように一覧ページを修正します。

  • 商品一覧ページ
app/routes/dashboard.product.tsx
// function that will execute on the client.
export function clientLoader() {
+  // promiseを返すように修正
+  const loaderPromise = getData("/api/products?page=0&rows=5");
+  return defer({ loaderPromise });
}

export default function Product() {
+ // loaderからPromiseを受けとるように修正
+  const { loaderPromise } = useLoaderData<typeof clientLoader>();
  const isPending = false;
  const searchParams: ProductSearchFormValues = {
    page: 0,
    limit: 5,
    sort: "name,asc",
  };

  return (
    <Shell variant="sidebar">
      <BreadCrumb items={breadcrumbItems} />
+    {/* 非同期処理中は、Skeletonを表示 */}
+    <Suspense
+      fallback={
+        <Skeleton className="h-[calc(75vh-220px)] rounded-md border" />
+      }
+    >
+        <>
+          {/* here is where Remix awaits the promise */}
+          <Await resolve={loaderPromise}>
+            {/* now you have the resolved value */}
+            {({ count, data }) => (
+              <>
                  <ProductTableHeader
                    isPending={isPending}
                    totalCount={count}
                  />
                  <Separator />
                  <ProductSearchForm searchParams={searchParams} />
                  <Separator />
                  {isPending ? (
                    <Skeleton className="h-[calc(65vh-220px)] rounded-md border" />
                  ) : (
                    <PageableTable
                      pageNo={searchParams.page!}
                      columns={columns}
                      totalCount={count}
                      data={data}
                      initailSort={parseSortQueryParam(searchParams.sort)}
                      pageCount={Math.ceil(count / searchParams.limit!)}
                    />
                  )}
                </>
              )}
+            </Await>
+          </>
+        )}
+      </Suspense>
    </Shell>
  );
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/14071b3975849778084230bcab1229be02b5ec2c

ThirosueThirosue

新規に商品一覧ページを実装します。(続き)
次に商品一覧ページの初期レンダリング(Suspense)後のローディング制御を実装します。
具体的には、ページングや検索フォームSubmit時などに検索状態(ローディング状態)になる制御を実装します。
作成する画面イメージは以下です。

公式チュートリアルのGlobal Pending UIの通り実装します。

https://remix.run/docs/en/main/start/tutorial#global-pending-ui

商品一覧ページの修正

useNavigationフックを利用し、loading状態を判定し、非同期処理中はSkeletonを返すように一覧ページを修正します。

  • 商品一覧ページ
app/routes/dashboard.product.tsx
// function that will execute on the client.
export function clientLoader({
+  request, // 
+ }: ClientLoaderFunctionArgs) {
  // During client-side navigations, we hit our exposed API endpoints directly
+  // クエリからpageを取得して、APIのパラメータに設定
+  const url = new URL(request.url);
+  const page = parseInt(url.searchParams.get("page") ?? "1") - 1;

  captains.log("clientLoader start", new Date().toISOString());
+  const loaderPromise = getData(`/api/products?page=${page}&rows=5`);
  return defer({ loaderPromise });
}

export default function Product() {
  const { loaderPromise } = useLoaderData<typeof clientLoader>();
+  const navigation = useNavigation();
+  const isPending = navigation.state === "loading";
  const searchParams: ProductSearchFormValues = {
    page: 0,
    limit: 5,
    sort: "name,asc",
  };

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/4467ec7f2e2b22b2fde55ce1bff46915a805f94c

ThirosueThirosue

新規に商品一覧ページを実装します。(続き)
次に検索フォームを実装します。
検索フォームはRemix標準のForm、及びclientActionで処理します。
作成する画面イメージは以下です。

https://remix.run/docs/en/main/route/client-action

https://remix.run/docs/en/main/components/form

商品一覧ページの修正

検索前の状態を一部引き継ぎたいため、postで検索フォームの値を受け付けた後、検索前の状態をURLから復元の上、状態をマージして、リダイレクトします。
リダイレクト後は、loaderで再検索を実施します。

  • 商品一覧ページ
app/routes/dashboard.product.tsx
+ // クライアントアクションで検索フォームの値を処理します。検索前の状態を復元したいため、postを用いています
+ export async function clientAction({ request }: ClientActionFunctionArgs) {
+  const body = await request.formData();
+  const url = new URL(request.url);
+  const { limit } = parseUrl(url); // 検索前の状態を復元
+  const name = body.get("name");

+  const params = new URLSearchParams({
+    page: "1", // 検索フォーム検索時は、1ページ目に戻す
+    limit: limit.toString(),
+  })
+  if (name) {
+    params.append("name", name.toString());
+  }

+  return redirect(`/dashboard/product?${params.toString()}`); // リダイレクトして、loaderで再検索を実施
+ }

// function that will execute on the client.
export function clientLoader({ request }: ClientLoaderFunctionArgs) {
  // During client-side navigations, we hit our exposed API endpoints directly
  const url = new URL(request.url);
+  const { page, limit, name } = parseUrl(url);

  captains.log("clientLoader start", new Date().toISOString());

+  const params = new URLSearchParams({
+    page: (page - 1).toString(),
+    rows: limit.toString(),
+  })
+  if (name) {
+    params.append("name", name);
+  }

+  const loaderPromise = getData(`/api/products?${params.toString()}`);

+  return defer({ loaderPromise, page, limit, name: name ?? "" });
}

export default function Product() {
+  const { loaderPromise, name, page, limit } = useLoaderData<typeof clientLoader>();
  const navigation = useNavigation();
  const isPending = navigation.state === "loading";
  const searchParams: ProductSearchFormValues = {
+    name,
    page,
    limit,
    sort: "",
  };

  return (
    <Shell variant="sidebar">
      <BreadCrumb items={breadcrumbItems} />
      <Suspense
        fallback={
          <Skeleton className="h-[calc(75vh-220px)] rounded-md border" />
        }
      >
        <>
          {/* here is where Remix awaits the promise */}
          <Await resolve={loaderPromise}>
            {/* now you have the resolved value */}
            {({ count, data }) => (
              <>
                <ProductTableHeader isPending={isPending} totalCount={count} />
                <Separator />
+                {/* 一覧表示件数(limit)等は検索前の状態を引き継ぐため、postで処理します */}
+                <Form method="post">
+                  <ProductSearchForm searchParams={searchParams} />
+                </Form>

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/9466ec66d29d80322a654c828727b1eaf0bca606

ThirosueThirosue

新規に商品一覧ページを実装します。(続き)
次にテーブルソート(APIを利用したサーバデータのソート)を実装します。
モックで用意しているAPIは、商品名(name)と商品説明(description)のみ対応しているため、これらの値でソートできるように修正します。
作成する画面イメージは以下です。

商品一覧ページの修正

  • 商品一覧ページ
app/routes/dashboard.product.tsx
// function that will execute on the client.
export function clientLoader({ request }: ClientLoaderFunctionArgs) {
  // During client-side navigations, we hit our exposed API endpoints directly
  const url = new URL(request.url);
+  const { page, limit, name, sort } = parseUrl(url);

  captains.log("clientLoader start", new Date().toISOString());

  const params = new URLSearchParams({
    page: (page - 1).toString(),
    rows: limit.toString(),
  });
  if (name) {
    params.append("name", name);
  }
+ // APIの仕様に応じて、ソート用のクエリを組み立て
+  if (sort) {
+    const { id, desc } = parseSortQueryParam(sort)[0]
+    params.append("orderBy", id)
+    params.append("order", desc ? "desc" : "asc")
+  }

  const loaderPromise = getData(`/api/products?${params.toString()}`);

+  return defer({ loaderPromise, page, limit, name: name ?? "", sort: sort ?? "" });
}

export default function Product() {
+  const { loaderPromise, name, page, limit, sort } =
    useLoaderData<typeof clientLoader>();
  const navigation = useNavigation();
  const isPending = navigation.state === "loading";
  const searchParams: ProductSearchFormValues = {
    name,
    page,
    limit,
+    sort,
  };

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/ed9de32111432f79b0fb564a0efdca99fa5401ee

ThirosueThirosue

新規に商品登録ページを実装します。
商品一覧ページから遷移する商品登録ページを実装します。
作成する画面イメージは以下です。

商品登録ページの追加

  • 商品登録フォームコンポーネントの追加

ブラウザ側でのバリデーションを実装したいため、Formはreact-hook-formを利用します。

app/components/product/product-form.tsx
type ProductFormValues = z.infer<typeof productUpsertSchema>

interface ProductFormProps {
  initialData: ProductFormValues & {
    version?: number
  } | null
  _csrf: string
}

export const ProductForm: React.FC<ProductFormProps> = ({
  initialData,
}) => {
  const title = initialData ? "Edit product" : "Create product"
  const description = initialData ? "Edit a product." : "Add a new product."
  const toastMessage = initialData ? "Product updated." : "Product created."
  const action = initialData ? "Save changes" : "Create"

  const defaultValues = initialData
    ? initialData
    : {
        name: "",
        description: "",
        quantity: 0,
      }

  const form = useForm<ProductFormValues>({
    resolver: zodResolver(productUpsertSchema),
    defaultValues: {
      ...defaultValues,
    },
  })

  return (
    <>
      <div className="flex items-center justify-between">
        <Heading title={title} description={description} />
        {initialData && (
          <Button variant="destructive" size="sm" onClick={onDelete}>
            <Trash className="h-4 w-4" />
          </Button>
        )}
      </div>
      <Separator />
      <Form {...form}>
        <form
          onSubmit={form.handleSubmit(onSubmit)}
          className="w-full space-y-8"
        >
          <div className="gap-8 md:grid md:grid-cols-3">
            <FormField
              control={form.control}
              name="name"
              render={({ field }) => (
                <FormItem>
                  <FormLabel>Name</FormLabel>
                  <FormControl>
                    <Input
                      placeholder="Product name"
                      {...field}
                    />
                  </FormControl>
                  <FormMessage />
                </FormItem>
              )}
            />

{/* 省略*/}

          <Button className="ml-auto" type="submit">
            {action}
          </Button>
        </form>
      </Form>
    </>
  )
}
  • 登録ページのルートファイル

作成したフォームコンポーネントを利用します。

const breadcrumbItems = [
  { title: "Product", link: "/dashboard/products" },
  { title: "Create", link: "/dashboard/product/new" },
]

export default function ProductNew() {
  return (
    <Shell variant="sidebar">
      <BreadCrumb items={breadcrumbItems} />
      <ProductForm
        initialData={null}
        _csrf={"dummy-csrf-token"}
      />
    </Shell>
  );
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/583560edf004f06d5876c2f049bdd91afbf010f7

ThirosueThirosue

新規に商品登録ページを実装します。(続き)
商品登録フォームのロジック部分を実装します。

商品登録ページの修正

  • 商品登録フォームコンポーネント

@tanstack/react-queryを利用して、mutationを定義し、submit時にコールします。

app/components/product/product-form.tsx
+  const mutation = useMutation({
+    mutationFn: (data: ProductFormValues) => {
+      return postData("/api/products/post", data);
+    },
+    onSuccess: () => {
+      navigate("/dashboard/products");
+      toast({
+        description: toastMessage,
+      });
+    },
+    onError: (error) => {
+      toast({
+        description: error.message,
+        variant: "destructive"
+      });
+    }
+  });

  const onSubmit = async (data: ProductFormValues) => {
+    if (!mutation.isPending) {
+      captains.log("do something with the data", data);
+      mutation.mutate(data);  
+    }
  };

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/ec24f7f3e3e6e07adf491f3468d2bc11e8f68c6b

ThirosueThirosue

新規に商品登録ページを実装します。(続き)
商品登録ページにCSRF対策を実装します。
ページ遷移時にトークンを発行し、商品登録フォームに渡すように修正します。

商品登録ページの修正

  • 登録ページのルートファイル

loaderでトークンを発行し、フォールバック中はスケルトンを表示するように修正します。

app/routes/dashboard.product.new.tsx
+ export function clientLoader() {
+  // During client-side navigations, we hit our exposed API endpoints directly
+  const tokenPromise: Promise<string> = new Promise((resolve, _) => {
+    setTimeout(() => {
+        resolve("dummy-csrf-token")
+    },1000)
+  })
+
+  return defer({
+    tokenPromise
+  })
+ }

export default function ProductNew() {
+  const { tokenPromise } = useLoaderData<typeof clientLoader>();

  return (
    <Shell variant="sidebar">
      <BreadCrumb items={breadcrumbItems} />
+      <Suspense
+        fallback={
+          <Skeleton className="h-[calc(35vh-220px)] rounded-md border" />
+       }
+      >
+        {/* here is where Remix awaits the promise */}
+        <Await resolve={tokenPromise}>
+          {/* now you have the resolved value */}
+          {(token) => (
+            <ProductForm initialData={null} _csrf={token} />
+          )}
+        </Await>
+      </Suspense>
    </Shell>
  );
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/b42ab701bf597b6bafc936f1e55f1eeac377fabb

ThirosueThirosue

新規に商品編集ページを実装します。
RemixのDynamic segmentsを利用して、商品IDを動的に取得します。
loaderを用いて、取得した商品IDを元に該当の商品取得します。

https://remix.run/docs/en/main/file-conventions/routes#dynamic-segments

商品編集ページの追加

  • 編集ページのルートファイル

dashboard.product.$id.tsxとして$idで商品IDに任意の値を引き受けます。

app/routes/dashboard.product.$id.tsx
// クライアント側で実行される関数
export function clientLoader({ params }: LoaderFunctionArgs) {
  // パラメータから商品IDを取得
  const id = params.id;
  console.log("productId", id)

  // 商品データの取得
  const loaderPromise = getData(`/api/products/get?id=${id}`);
  // ダミーのCSRFトークンを生成(実際には適切な方法で取得してください)
  const tokenPromise: Promise<string> = new Promise((resolve, _) => {
    setTimeout(() => {
      resolve("dummy-csrf-token");
    }, 1000);
  });

  // Promise.allで両方のPromiseが解決されるのを待つ
  return defer({
    id,
    loaderPromise: Promise.all([loaderPromise, tokenPromise]),
  });
}

// 商品更新ページのコンポーネント
export default function ProductUpdate() {
  // Loaderからデータを取得
  const { loaderPromise, id } = useLoaderData<typeof clientLoader>();

  // パンくずリストの項目を定義
  const breadcrumbItems = [
    { title: "Product", link: "/dashboard/products" },
    { title: "Update", link: `/dashboard/product/${id}` },
  ];

  return (
    <Shell variant="sidebar">
      <BreadCrumb items={breadcrumbItems} />
      <Suspense
        fallback={
          <Skeleton className="h-[calc(35vh-220px)] rounded-md border" />
        }
      >
        {/* Suspenseの中でAwaitを使用し、Promiseが解決されるのを待つ */}
        <Await resolve={loaderPromise}>
          {([data, token]) => (
            // 商品フォームコンポーネントに初期データとCSRFトークンを渡す
            <ProductForm initialData={data} _csrf={token} />
          )}
        </Await>
      </Suspense>
    </Shell>
  );
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/b0e3099af36cf22fca560ad58aa7dee5b4825281

ThirosueThirosue

新規に商品編集ページを実装します。(続き)
商品登録フォームのロジック部分を実装します。

商品登録ページの修正

  • 商品登録フォームコンポーネント

削除および更新のmutationを定義して、Submit時にアクションに応じたmutaionを呼び出すように修正します。

app/components/product/product-form.tsx
+  const isUpdate = initialData ? true : false;

+  const updateProduct = useMutation({
+    mutationFn: (data: ProductFormValues) => {
+      return putData(`/api/products/put?id=${initialData?.id!}`, data);
+    },
+    onSuccess: () => {
+      navigate("/dashboard/products?page=1&limit=5");
+      toast({
+        description: toastMessage,
+      });
+    },
+    onError: (error) => {
+      toast({
+        description: error.message,
+        variant: "destructive",
+      });
+    },
+  });

+  const mutation = isUpdate ? updateProduct : createProduct;

  const onSubmit = async (data: ProductFormValues) => {
    if (!mutation.isPending) {
      captains.log("do something with the data", data);
      mutation.mutate(data);
    }
  };

+  const deleteProduct = useMutation({
+    mutationFn: (id: number) => {
+      return deleteData(`/api/products/delete?id=${id}`);
+    },
+    onSuccess: () => {
+      navigate("/dashboard/products?page=1&limit=5");
+      toast({
+        description: "Product deleted.",
+      });
+    },
+    onError: (error) => {
+      toast({
+        description: error.message,
+        variant: "destructive",
+      });
+    },
+  });

  const onDelete = async () => {
+    if (!deleteProduct.isPending) {
+      deleteProduct.mutate(initialData?.id!);
+    }
  };

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/c9fcec8f8e1c46ea8f88888ac646ad5bce342bf4

ThirosueThirosue

認証チェック処理を追加実装します。

認証処理として追加実装する内容は以下2点です。

  • 認証が必要な画面遷移ごとにセッションのチェックを行う
  • リロード対策として、セッションが有効な場合はセッションを復元する(F5対策)

セッションプロバイダの修正

セッションプロバイダに以下のロジックを追加します。

  • リロード対策として、セッションが有効な場合はセッションを復元する
app/components/layout/session-provider.tsx
export const SessionProvider = ({
  children,
}: {
  children: React.ReactNode;
}): JSX.Element => {
  const [session, setSession] = useState<SessionState>(null);

+  const checkAndRefreshToken = async () => {
+    try {
+      const { status, token } = await callRefreshTokenEndpoint();
+      if (status === "ok" && token) {
+        const payload = decodeToken(token);
+        setSession({
+          name: "John Doe", // dummy data
+          email: payload.email,
+          image: "https://avatars.githubusercontent.com/u/14899056?v=4", // dummy data
+          token,
+        });
+        captains.log("Refreshed token:", token);
+      }
+    } catch (error) {
+      console.error("Failed to refresh token:", error);
+      setSession(null);
+    }
+  };
+
+  // 画面リフレッシュ対応: 初回レンダリング時にセッションを復元
+  useEffect(() => {
+    captains.log("SessionProvider mounted");
+    checkAndRefreshToken();
+  }, []);

  const clearSession = async () => {
    try {
      await deleteData("/api/auth/signout");
    } finally {
      setSession(null);
    }
  };

ダッシュボードレイアウトの修正

ダッシュボードレイアウト(認証ページのレイアウトファイル)に以下認証チェック処理を追加実装します。

  • 認証が必要な画面遷移ごとにセッションのチェックを行う

これにより、認証が必要なページへの遷移時に認証チェックがフックされます。
セッションが切れた場合、ユーザーを該当ページにリダイレクトし、再ログインを促します。

app/routes/dashboard.tsx
import { Outlet, ClientActionFunction, redirect } from "@remix-run/react";
import Header from "~/components/layout/header";
import Sidebar from "~/components/layout/sidebar";
import { callRefreshTokenEndpoint } from "~/components/layout/session-provider";

+ // 認証を必要とする共通のloader高階関数
+ // これは他のloader関数に認証チェックを追加するために使用されます。
+ export function withAuthLoader(loaderFn: ClientActionFunction): ClientActionFunction {
+  return async (args) => {
+    try {
+      // リフレッシュトークンエンドポイントを呼び出し、ステータスをチェック
+      const { status } = await callRefreshTokenEndpoint();
+
+      // 認証に失敗した場合、セッション切れのページにリダイレクト
+      if (status !== "ok") {
+        return redirect("/session-expired");
+      }
+
+      // 認証に成功した場合、元のloader関数を実行
+      return loaderFn(args);
+    } catch (error) {
+      // エラーハンドリング: 認証チェック中にエラーが発生した場合、セッション切れのページにリダイレクト
+      console.error("Error during authentication check:", error);
+      return redirect("/session-expired");
+    }
+  };
+ }

+ // クライアント側で実行されるloader関数
+ // 認証チェックが必要なすべてのクライアント側ナビゲーションで使用されます。
+ export const clientLoader = withAuthLoader(async () => {
+  // クライアント側のナビゲーション中に直接APIエンドポイントにアクセス
+  return {};
+ });

// アプリケーションの主要なレイアウトコンポーネント
// すべての認証が必要なページで使用されます。
export default function App() {
  return (
    <>
      <Header />
      <div className="flex h-screen">
        <Sidebar />
        <main className="w-full pt-16">
          <Outlet />
        </main>
      </div>
    </>
  );

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/07b3c13df5e60b1834ce9e752ad3be00cf7eeec4

ThirosueThirosue

プログラマブルなモーダル

次にプログラマブルなモーダルを実施します。
特定のイベントや状態に応じてモーダルを動的に制御することで、ユーザー体験を向上させることができます。この例では、確認ダイアログを実装し、ユーザーに対して操作の確認を求めることを可能にします。

実装イメージ

マスタデータ削除時などにユーザに操作の確認を求めた上で、実施にデータの削除を行います。

解決したい課題

  1. 柔軟なユーザーインターフェースの実現: アプリケーション内の様々な場所で、状況に応じてモーダルを表示する必要があります。
  2. コードの重複と複雑性の削減: 一貫した方法でモーダルを管理することで、コードの重複を減らし、メンテナンスを容易にします。
  3. 状態管理の一元化: モーダルの状態管理を一元化することで、アプリケーション全体で一貫性のあるユーザー体験を提供します。
  4. テンプレートの修正不要: テンプレートを変更することなく、コードのみでモーダルの表示を切り替えられることにより、開発の効率化を図ります。

この方法を導入することで、ユーザー体験の向上とコードベースの簡素化が期待できます。

実装方法

ステップ1: ConfirmProviderの作成

まず、確認ダイアログの状態を管理するConfirmProviderコンポーネントを作成します。
このプロバイダは、アプリケーションのどこからでもモーダルを呼び出せるようにします。

app/components/layout/confirm-provider.tsx
import React, { createContext, useCallback, useContext, useState } from "react";

import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from "~/components/ui/alert-dialog";

// ダイアログのオプションの型定義
interface DialogOptions {
  title: string;
  description: string;
  alert?: boolean;
}

// ダイアログのデフォルトオプション
const DEFAULT_OPTIONS: DialogOptions = {
  title: '',
  description: '',
  alert: false,
};

// Confirmコンテキストの型定義
interface ConfirmContextProps {
  confirm: (options: DialogOptions) => Promise<void>;
}

// Confirmコンテキストの作成
const ConfirmContext = createContext<ConfirmContextProps | undefined>(undefined);

export const ConfirmProvider = ({ children }: { children: React.ReactNode }): JSX.Element => {
  // ダイアログのオプションを管理するstate
  const [options, setOptions] = useState<DialogOptions>({ ...DEFAULT_OPTIONS });
  // ダイアログのresolveとrejectを管理するstate
  const [resolveReject, setResolveReject] = useState<[() => void, () => void] | []>([]);
  const [resolve, reject] = resolveReject;

  // confirm関数: ダイアログを表示し、Promiseを返す
  const confirm = useCallback((options: DialogOptions): Promise<void> => {
    return new Promise<void>((resolve, reject) => {
      setOptions({ ...DEFAULT_OPTIONS, ...options });
      setResolveReject([resolve, reject]);
    });
  }, []);

  // ダイアログを閉じるハンドラー
  const handleClose = useCallback(() => {
    setResolveReject([]);
  }, []);

  // キャンセルボタンのハンドラー
  const handleCancel = useCallback(() => {
    if (reject) reject();
    handleClose();
  }, [reject, handleClose]);

  // 確認ボタンのハンドラー
  const handleConfirm = useCallback(() => {
    if (resolve) resolve();
    handleClose();
  }, [resolve, handleClose]);

  return (
    <>
      {/* ダイアログのレンダリング */}
      <AlertDialog open={resolveReject.length === 2}>
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>{options.title}</AlertDialogTitle>
            <AlertDialogDescription>
              {options.description}
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel onClick={handleCancel}>
              {options.alert ? "Close" : "Cancel"}
            </AlertDialogCancel>
            {!options.alert && (
              <AlertDialogAction onClick={handleConfirm}>
                Continue
              </AlertDialogAction>
            )}
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
      {/* Confirmコンテキストの提供 */}
      <ConfirmContext.Provider value={{ confirm }}>
        {children}
      </ConfirmContext.Provider>
    </>
  );
};

// useConfirmフック: Confirmコンテキストを利用するためのフック
export const useConfirm = (): ((options: DialogOptions) => Promise<void>) => {
  const { confirm } = useContext(ConfirmContext);
  return confirm;
};

export default ConfirmProvider;

ステップ2: Providersコンポーネントの作成と利用

次に、Providersコンポーネントを修正し、ConfirmProviderをアプリケーション全体に適用します。

app/components/layout/providers.tsx
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
      <SessionProvider>
+        <ConfirmProvider>
+          <Toaster />
+          {children}
+        </ConfirmProvider>
      </SessionProvider>
    </QueryClientProvider>
  );
}

ステップ3: ConfirmProviderの利用

最後に、Providersコンポーネントを使用してアプリケーション全体にConfirmProviderを適用し、モーダルを使用するコンポーネントでuseConfirmフックを利用します。

app/components/product/product-form.tsx
  const navigate = useNavigate();
  const { toast } = useToast();
+  const confirm = useConfirm();

  const onSubmit = async (data: ProductFormValues) => {
    if (!mutation.isPending) {
      captains.log("do something with the data", data);
+      confirm({
+        title: "Check Updates",
+        description: "Are you sure you want to update this user?",
+      }).then(() => mutation.mutate(data));
    }
  };

  const onDelete = async () => {
    if (!deleteProduct.isPending) {
+      confirm({
+        title: "Check Delete",
+        description: "Are you sure you want to delete this user?",
+      }).then(() => deleteProduct.mutate(initialData?.id!));
    }
  };

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/910167cd3b1f90218765ea4e728db8219dee2a9b

ThirosueThirosue

ログのマスキング

ログマスキングは、セキュリティ対策として重要な機能です。
ログに出力される情報には機密性の高いデータが含まれる場合があり、これを保護するためにマスキング処理を行います。

ログマスキングの導入手順

ログマスキングを導入する際の手順は以下の通りです:

  1. pinoを利用してログを出力する:pinoはNode.jsの高速ログライブラリで、カスタマイズ可能なシリアライザを提供しています。これを使って、ログデータの中の機密情報をマスキングします。

  2. カスタムログメッセージの型定義を行う:アプリケーションで扱うログメッセージの形式を定義し、どのデータをマスキングする必要があるかを明確にします。

実装例

以下のコマンドを使用して、pinoをインストールします:

npm install pino

以下は、pinoを使用してログマスキングを行うための基本的な設定例です。
この例では、ログオブジェクトの中の特定のフィールドにマスキングを適用しています。

src/lib/logger.ts
import pino from "pino"

// カスタムログメッセージの型定義
interface CustomLogMessage {
  object?: any
  message: string
}

// マスキングに使用する文字列
const Hidden = "[******]"

// 指定されたフィールドをマスキングする関数
function maskObject(obj: any) {
  const fieldsToMask = ['id', 'name', 'email', 'token', 'refreshToken', 'password', 'tel'];
  const maskedObj = { ...obj };

  fieldsToMask.forEach(field => {
    if (field in maskedObj) {
      maskedObj[field] = Hidden;
    }
  });

  return maskedObj;
}

// Pinoロガーの設定
const logger = pino({
  serializers: {
    // objectフィールドのマスキング設定
    object: (obj) => maskObject(obj),
  },
  browser: {
    serialize: ['object']
  }
})

// ログを出力する関数(カスタム型を使用)
function logMessage(logData: CustomLogMessage) {
  logger.info(logData)
}

export { logMessage }

この設定により、ログ出力時に指定されたフィールドはマスキングされ、外部に漏れるリスクがある情報が保護されます。

ロガーの利用方法

アプリケーション内でのログ管理にはカスタムロガーを利用し、console.logの使用を避けることで、ログの一元管理とセキュリティの向上を図ります。以下、カスタムロガーを使用する実装例を示します。

app/components/product/product-form.tsx
// カスタムロガーのインポート
import { logMessage } from "@/lib/logger"

  const onSubmit = async (data: ProductFormValues) => {
    if (!mutation.isPending) {
-      captains.log("do something with the data", data);
+      logMessage({ message: "Product form submitted", object: data });
      confirm({
        title: "Check Updates",
        description: "Are you sure you want to update this user?",
      }).then(() => mutation.mutate(data));
    }
  };

no-consoleの設定

プロジェクトのESLint設定にno-consoleルールを追加することで、console.logやその他のコンソールメソッドの使用を禁止します。この設定を実施することで、コードレビューの負担を軽減可能です。

eslint.config.js
{
  "files": ["**/*.{js,jsx,ts,tsx}"],
  "rules": {
    "no-console": "error"
  },
  "plugins": ["react", "jsx-a11y"],
  "extends": [
    "plugin:react/recommended",
    "plugin:jsx-a11y/recommended"
  ],
  "settings": {
    "react": {
      "version": "detect"
    },
    "import/resolver": {
      "typescript": {}
    }
  }
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/fb39eeae0b8277d6a62ccc27cbcfa06e55b30a09

ThirosueThirosue

認可処理の追加実装

認可処理として以下の2つの機能を追加実装します。

  1. メニューの表示制御
  2. 画面遷移時の権限チェック

まず、1のメニューの表示制御を実装していきます。

実装方法

ステップ1: MenuProviderの作成

ログインユーザーが表示可能なメニューを取得します。
サーバー側の処理では、ユーザーに紐づくロールに基づいて、認可のスコープを返却する処理を想定しています。

app/components/layout/menu-provider.tsx
import React, { createContext, useContext, useState, useEffect } from "react";
import { getData } from "~/lib/fetch";
import { logMessage } from "~/lib/logger";
import { NavItem } from "~/types";
import { navItems } from "~/constants/data";

// 初期値を設定したMenuContextを作成します。
const defaultSessionValue = {
  navItems: [] as NavItem[],
  updateNaviItems: async (token: string) => {},
};

const MenuContext = createContext(defaultSessionValue);

// サーバーからパーミッションを取得する関数
export const fetchPermissions = async (
  token: string,
): Promise<{
  status: "ok" | "error";
  navItems?: NavItem[];
}> => {
  try {
    // トークンを使って認可情報を取得します。
    const { status, permissions } = await getData("/api/auth/permissions", {
      Authorization: `Bearer ${token}`,
    });

    if (status === "ok" && permissions) {
      // フィルタリングされたナビゲーション項目を返します。
      const filteredNavItems = navItems.filter((item) => {
        if (item.alwaysShow) {
          return true;
        }
        // パーミッションに含まれる名前空間がタイトルに含まれているかどうかをチェックします。
        return permissions.some((permission: { namespace: string }) =>
          item.title.toLowerCase().includes(permission.namespace.toLowerCase()),
        );
      });
      return { status: "ok", navItems: filteredNavItems };
    }

    return { status: "error" };
  } catch (error) {
    logMessage({ message: "Failed to fetch permissions", object: error });
    sessionStorage.removeItem("refreshToken");
    return { status: "error" };
  }
};

// MenuProviderコンポーネントを作成して、メニュー項目の状態管理を行います。
export const MenuProvider = ({
  children,
}: {
  children: React.ReactNode;
}): JSX.Element => {
  const [navItems, setNavItems] = useState<NavItem[]>([]);

  return (
    <MenuContext.Provider
      value={{
        navItems,
        updateNaviItems: async (token: string) => {
          const { navItems, status } = await fetchPermissions(token);
          if (status === "ok") {
            setNavItems(navItems!);
          }
        },
      }}
    >
      {children}
    </MenuContext.Provider>
  );
};

// カスタムフックを作成してコンテキストにアクセスできるようにする
export const useMenu = () => useContext(MenuContext);

export default MenuProvider;

ステップ2: Providersコンポーネントの修正と利用

次に、Providersコンポーネントを修正し、MenuProviderをアプリケーション全体に適用します。

app/components/layout/providers.tsx
export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <QueryClientProvider client={queryClient}>
+      <MenuProvider>
        <SessionProvider>
          <ConfirmProvider>
            <Toaster />
            {children}
          </ConfirmProvider>
        </SessionProvider>
+      </MenuProvider>
    </QueryClientProvider>
  );
}

ステップ3: 表示可能なメニューの取得

以下のタイミングで表示可能なメニューを取得します。

  • ログイン
  • 初回レンダリング時(F5対策)

ログイン時の実装修正

ログイン時にメニューを更新するようにします。

app/components/auth/signin-form.tsx
const { updateNaviItems } = useMenu();

const mutation = useMutation({
  mutationFn: (data: Inputs) => {
    return postData("/api/auth", data);
  },
  onSuccess: (data) => {
    const decoded = jwtDecode(data.token) as CustomJwtPayload;
    updateSession({
      name: "John Doe", // dummy data
      email: decoded.payload.user,
      image: "https://avatars.githubusercontent.com/u/14899056?v=4", // dummy data
      token: data.token,
      refreshToken: data.refreshToken,
    });
+    // トークンを用いてメニューを更新
+    updateNaviItems(data.token);
    navigate("/dashboard/home");
    toast({
      description: "Sign in successful! 🎉",
    });
  },
});

初回レンダリング時の実装修正

SessionProviderを修正し、リフレッシュトークン更新時に有効なメニューを同期するようにします。

app/components/layout/session-provider.tsx
const { updateNaviItems } = useMenu();

const checkAndRefreshToken = async () => {
  try {
    const { status, token } = await callRefreshTokenEndpoint();
    if (status === "ok" && token) {
      const payload = decodeToken(token);
      setSession({
        name: "John Doe", // dummy data
        email: payload.email,
        image: "https://avatars.githubusercontent.com/u/14899056?v=4", // dummy data
        token,
      });
+      // トークンを用いてメニューを更新
+      updateNaviItems(token);
      logMessage({ message: "Refreshed token", object: { token } });
    }
  } catch (error) {
    logMessage({ message: "Failed to refresh token", object: error });
    setSession(null);
  }
};

// 初回レンダリング時にセッションを復元
useEffect(() => {
  logMessage({ message: "SessionProvider mounted" });
  checkAndRefreshToken();
}, []);

ステップ4: 表示可能なメニューの表示

メニュー表示テンプレートでカスタムフックで取得したメニューを表示するように修正します。

app/components/layout/sidebar.tsx
- import { navItems } from "~/constants/data";
import { cn } from "~/lib/utils";
import { DashboardNav } from "~/components/dashboard-nav";
+ import { useMenu } from "./menu-provider";

export default function Sidebar() {
+  const { navItems } = useMenu();

// テンプレートは修正なし

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/d34d9987cb194e2c3edeeab3c6ebb5f15e14087b

ThirosueThirosue

認可処理の追加実装(続き)

認可処理として以下の2つの機能を追加実装します。

  1. メニューの表示制御
  2. 画面遷移時の権限チェック

次に、2の画面遷移時の権限チェックを実装していきます。

実装方法

レイアウトファイルを修正して、画面遷移時の権限チェックを追加します。

app/routes/dashboard.tsx
export function withAuthLoader(
  loaderFn: ClientActionFunction,
): ClientActionFunction {
  return async (args) => {
    try {
      logMessage({
        message: `Checking authentication... at ${new Date().toISOString()}`,
      });

+      // リフレッシュトークンエンドポイントを呼び出し、ステータスをチェック
+      const { status, token } = await callRefreshTokenEndpoint();
+      // 認証に失敗した場合、セッション切れのページにリダイレクト
+      if (status !== "ok") {
+        return redirect("/session-expired");
+      }

+      // ナビゲーションアイテムとパーミッションを更新
+      const { status: menuStatus, navItems, permissions } = await fetchPermissions(token);
+      // ナビゲーションアイテムの取得に失敗した場合、リダイレクト
+      if (menuStatus !== "ok") {
+        return redirect("/not-found");
+      }

+      // 現在のページのパスを取得
+      const url = new URL(args.request.url);
+      // 現在のページのパスが許可されたメニューに含まれているかをチェック
+      const currentNavItem = navItems.find(item => item.href === url.pathname);
+      // 現在のページのパスが許可されたパーミッションに含まれているかをチェック
+      const hasPermission = permissions.some(permission =>
+        url.pathname.toLowerCase().includes(permission.namespace.toLowerCase())
+      );

+      // ナビゲーションアイテムが存在せず、かつパーミッションがない場合はリダイレクト
+      if (!currentNavItem && !hasPermission) {
+        return redirect("/not-found");
+      }

      // 認証に成功した場合、元のloader関数を実行
      return loaderFn(args);
    } catch (error) {
      // エラーハンドリング: 認証チェック中にエラーが発生した場合、セッション切れのページにリダイレクト
      logMessage({ message: `Error during authentication check: ${error}` });
      return redirect("/session-expired");
    }
  };
}

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/14913040c5db57c5480a08b7b5911375c5cdf96e

ThirosueThirosue

ハイドレーション(コンソール)エラー対応

RemixをSpaモードで作成、起動した直後から、以下のエラーが発生。
本エラーについて、対応する

chunk-NEIDWZIT.js?v=3d8052c8:9489 Uncaught Error: Hydration failed because the initial UI does not match what was rendered on the server.
    at throwOnHydrationMismatch (chunk-NEIDWZIT.js?v=3d8052c8:9489:17)
    at tryToClaimNextHydratableInstance (chunk-NEIDWZIT.js?v=3d8052c8:9510:15)
    at updateHostComponent (chunk-NEIDWZIT.js?v=3d8052c8:14812:13)
    at beginWork (chunk-NEIDWZIT.js?v=3d8052c8:15953:22)
    at HTMLUnknownElement.callCallback2 (chunk-NEIDWZIT.js?v=3d8052c8:3672:22)
    at Object.invokeGuardedCallbackDev (chunk-NEIDWZIT.js?v=3d8052c8:3697:24)
    at invokeGuardedCallback (chunk-NEIDWZIT.js?v=3d8052c8:3731:39)
    at beginWork$1 (chunk-NEIDWZIT.js?v=3d8052c8:19791:15)
    at performUnitOfWork (chunk-NEIDWZIT.js?v=3d8052c8:19224:20)
    at workLoopConcurrent (chunk-NEIDWZIT.js?v=3d8052c8:19215:13)

公式Issue

https://github.com/remix-run/remix/issues/2947

https://github.com/remix-run/remix/issues/2947#issuecomment-2071160125

上記コメントの通り、React 19にバージョンアップすると、解消することを確認

対応手順

ステップ1: Remix公式の通りハイドレーションのスコープをdivのみに変更

https://remix.run/docs/en/main/guides/spa-mode#hydrating-a-div-instead-of-the-full-document

修正内容は公式の通り

ステップ2: head(CSS読み込み/タイトル等)の制御の修正

  • react-helmet-asyncインストール
npm i react-helmet-async
  • entry.client.tsxの修正
app/entry.client.tsx
import * as HelmetAsync from 'react-helmet-async';
const { HelmetProvider } = HelmetAsync;

startTransition(() => {
  hydrateRoot(
    document.querySelector("#app")!,
    <StrictMode>
+      <HelmetProvider>
        <RemixBrowser />
+      </HelmetProvider>
    </StrictMode>,
  );
});
  • app/root.tsxの修正

tailwindcssのロード部分を修正する

app/root.tsx
+ import * as HelmetAsync from 'react-helmet-async';
+ const { Helmet } = HelmetAsync;

- export const links: LinksFunction = () => [
-  { rel: "stylesheet", href: stylesheet },
- ];

export default function Component() {
  return (
+    <>
-    <html lang="en">
-      <head>
-       <meta charSet="utf-8" />
-        <meta name="viewport" content="width=device-width, initial-scale=1" />
-        <Meta />
-        <Links />
-      </head>
+      <Helmet>
+        <link rel="stylesheet" href={stylesheet} />
+      </Helmet>
      <div className={cn("min-h-screen bg-background font-sans antialiased")}>
        <Providers>
          <Outlet />
        </Providers>
        <ScrollRestoration />
        <Scripts />
      </div>
+    </>
-    </html>
  );
}
  • 各ルートファイルの修正

タイトル設定部分を修正する

app/routes/auth.signin.tsx
+ import * as HelmetAsync from 'react-helmet-async';
+ const { Helmet } = HelmetAsync;

export default function SignInPage() {
  return (
+    <>
+      <Helmet>
+        <title>Sign In</title>
+      </Helmet>

// その他変更前のコードのまま

+    </>

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/bc6e057831b0c22fc37b61df2b14739c0b361d2a

ThirosueThirosue

スロットリング対応

初回レンダリング時のセッション復元処理が2回コールされていることを確認したため、対応を検討。

事象

対象のロジック

app/components/layout/session-provider.tsx
  // 画面リフレッシュ対応: 初回レンダリング時にセッションを復元
  useEffect(() => {
    const checkAndRefreshToken = async () => {
      try {
        const { status, token } = await callRefreshTokenEndpoint();
        if (status === "ok" && token) {
          const payload = decodeToken(token);
          setSession({
            name: "John Doe", // dummy data
            email: payload.email,
            image: "https://avatars.githubusercontent.com/u/14899056?v=4", // dummy data
            token,
          });
          updateNaviItems(token);
          logMessage({ message: "Refreshed token", object: { token } });
        }
      } catch (error) {
        logMessage({ message: "Failed to refresh token", object: error });
        setSession(null);
      }
    };

    logMessage({ message: "SessionProvider mounted" });
    checkAndRefreshToken();
  }, [updateNaviItems]); //

原因

ReactのStrict Modeで発生する模様

React Strict Modeの影響

ReactのStrict Modeでは、開発モードでuseEffectが2回呼ばれることがあります。
これは、クリーンアップ処理が正しく行われることを保証し、潜在的なバグを検出するための開発ツールです。

Strict Modeでは以下のような挙動が発生します:

  1. 初回レンダリング時にuseEffectが呼ばれる。
  2. コンポーネントのクリーンアップが実行される。
  3. 再度レンダリングが行われ、useEffectが再び呼ばれる。

事象解消の確認

プレビュー表示では、1回しか発生しないことを確認。

ThirosueThirosue

認証認可対応(APIリクエスト)

認証が必要なページのAPIリクエストにアクセストークンを付与します。
アクセストークンは、メモリ(Context)に保存しているため、ClientLoaderでの処理は検討する必要があります。

実装としては、以下の3パターンが考えられます。

  1. アクセストークンをHttp Only Cookieに保存する
  2. ClientLoaderを利用しない(react-query等で対応)
  3. アクセストークンをグローバル変数/ストレージに保存する

本サンプルでは、3のアクセストークンをグローバル変数に保存する方式で実装しますが、本番環境に適応する際は、実装を再検討する必要がある事項です。

修正内容

セッションプロバイダの修正

ClientLoaderでは、Hookが利用できないため、グローバル変数にアクセストークンを保持します。

app/components/layout/session-provider.tsx
+ export let accessToken: string | null = null; // client-side navigation(clientLoader)で使用するためexport

export const SessionProvider = ({
  children,
}: {
  children: React.ReactNode;
}): JSX.Element => {

// その他の処理

  const updateSession = (value: SessionState) => {
    if (value) {
      const newSession = { ...session, ...value };
      setSession(newSession);
      sessionStorage.setItem("refreshToken", newSession.refreshToken!);
+      accessToken = newSession.token;
    }
  };

ClientLoaderの修正

アクセストークンをAuthorizationヘッダに付与するようにロジックを修正します。

app/routes/dashboard.product.$id.tsx
+ import { accessToken } from "~/components/layout/session-provider";

// function that will execute on the client.
export function clientLoader({ params }: LoaderFunctionArgs) {
  // During client-side navigations, we hit our exposed API endpoints directly
  const id = params.id;
  logMessage({ message: `product detail id = ${id}` });

  const loaderPromise = getData(
    `/api/products/get?id=${id}`,
+    { Authorization: `Bearer ${accessToken!}` }, // TODO: HttpOnlyクッキーの利用を検討
  );
  const tokenPromise: Promise<string> = new Promise((resolve) => {
    setTimeout(() => {
      resolve("dummy-csrf-token");
    }, 1000);
  });

  return defer({
    id,
    loaderPromise: Promise.all([loaderPromise, tokenPromise]),
  });
}

@tanstack/react-queryの修正

Contextからアクセストークンを取得して、Authorizationヘッダに付与するようにロジックを修正します。

app/components/product/product-form.tsx
+ const { session } = useSession();

  const createProduct = useMutation({
    mutationFn: (data: ProductFormValues) => {
      // TODO: 必要に応じて _csrf トークンを利用し、CSRF対策を実施する
+      return postData("/api/products/post", data, {
+        Authorization: `Bearer ${session?.token}`,
+      });
    },
    onSuccess: () => {
      navigate("/dashboard/products?page=1&limit=5");
      toast({
        description: toastMessage,
      });
    },
    onError: (error) => {
      toast({
        description: error.message,
        variant: "destructive",
      });
    },
  });

Commit

https://github.com/Thirosue/remix-spa-shadcn-dashboard-sample/commit/b2464892e9b07f460037d0bbade00d3ed98bf806