Remix SPAモードで管理画面を作成する
SPA Mode templateでプロジェクトを新規作成
npx create-remix@latest --template remix-run/remix/templates/spa
- Commit
最近お気に入りの shadcn-ui
をUI libraryとして使おうと、公式の通り実施するもエラー。
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を先にセットアップせよとのことなので、公式に乗っ取りセットアップする。
- setup
tailwindcss
npm install -D tailwindcss
npx tailwindcss init
- Commit
改めて 公式に手順に乗っ取り shadcn-ui
をセットアップ
SPAなので、React Server Components
は、no にする。
- 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
公式に乗っ取り shadcn-ui
の Button を追加してみる
-
postcss.config.js
を追加
touch postcss.config.js
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
-
tailwind.css
を Layout に設定する
+ 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 ファイルに追加して、表示確認
</ul>
+ <Button>Click me</Button>
</div>
正常に表示されることを確認
- Commit
その他
- 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
を付与すると解消
- import stylesheet from "./tailwind.css";
+ import stylesheet from "./tailwind.css?url";
Git hookで 各種設定(フォーマット、lint)をフックするために、公式の通り、huskyをインストールする
- 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)
- ログ出力に変更
- 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
フォーマッタとして、prettier を公式の通り、インストールする。
- install
npm install --save-dev --save-exact prettier
- create config
node --eval "fs.writeFileSync('.prettierrc','{}\n')"
- Commit
一旦、全ファイルフォーマット
- 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
lint-staged
を公式の通り導入して、lint error などが発生した際、コミット不可とできるようにします。
- install
npm install --save-dev lint-staged
- package.jsonに設定を追加します
},
+ "lint-staged": {
+ "*.{ts,tsx}": [
+ "prettier --write"
+ ]
+ },
"dependencies": {
- pre-commit設定を修正
- echo "husky: pre-commit hook started"
+ npx lint-staged
- テストのため、
app/entry.client.tsx
を修正し、コミット対象に含める
コミット時、フォーマットされることを確認
- Commit
以下の記事を参考に、remixのESLintの設定をFlat Configで記述し直す
-
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
eslint に no-cosole を追加してみる。
- ルール追加
rules: {
...reactRecommended.rules,
...reactJSXRuntime.rules,
+ "no-console": "error",
},
-
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
Dependabot を設定します。
いつもは、Renovate ですが、GitHubの機能で設定してみます。
全て、Enableで設定。
-
.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
code scanning
も追加してみる。
- GitHub Actions が走って、
security alert
をチェックします。
作成したばかりなので、セキュリティアラートがないことを確認。
ログイン画面を作成していきます。
- 作成する画面
UIは以下レポジトリを参照にして作成します。
shadcn-ui
で 必要なコンポーネント群を追加します。
- card
npx shadcn-ui@latest add card
- input
npx shadcn-ui@latest add input
- form
npx shadcn-ui@latest add form
- Commit
ログインフォームのpassword入力エリアを作成します。
- 作成するもの
input type
をtextとpasswordでトグルさせます。
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
UIパーツの作成が完了したため、公式の通り、レイアウトファイルを作成して、ログイン画面を作成します。
フォルダによる切り分けに慣れているのですが、一旦、フォルダを利用しない従来の方式で作成します。
- Layout
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
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'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
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>
);
}
- 参考:フォルダ構成
- Commit
ログイン画面を実装していきます。
実装する動き
考察
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を設定します。
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で利用します。
+ 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時にコールします。
+ 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
参考
フォームのSubmitについて、Submit後にButtonをdisableにしないと、何重にもリクエストが送信されてしまいます。サーバサイドでの制御も必要ですが、クライアントサイドでもボタンをDisableにして、ローディングアイコンを表示させます。
非同期の状態管理ライブラリを利用しているので、実行中の状態(pending
)は容易に取得できます。
+ <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
NextAuthのSessionProviderに相当する簡易実装を行います。
→バックエンドがモックAPIのため、簡易実装としています。
状態管理にはReact標準のContextを使用します。
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
前述のSessionProvider/Contextを利用して、ログアウトの簡易実装を行います。
トップ画面の修正
+ 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
管理ダッシュボードの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.
レイアウトファイルの作成
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
新規に商品一覧ページを実装します。
まず商品一覧ページの追加のみ行います。一覧テーブル表示は後ほど実装します。
仮実装する内容は以下です。
商品一覧ページの追加
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
次に商品一覧に必要なコンポーネントを準備します。
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
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
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
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
新規に商品一覧ページを実装します。(続き)
必要なコンポーネントの追加は完了したため、loaderで取得したデータを一覧表示します。
作成する画面イメージは以下です。
※ページング制御、ローディング制御、検索パラメータの制御等の調整は後ほど実施します。
一覧表示に必要なコンポーネントの追加
shadcn-ui
およびTanStack Table
のドキュメントを参考に、ページング可能なテーブルコンポーネントを作成します。
- ページング可能なテーブルコンポーネント
// 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>
</>
);
商品一覧ページの修正
- 商品一覧
上記テーブルコンポーネントを利用して、商品一覧画面のコンポーネントを作成します。
商品一覧コンポーネントには、パンくず、商品検索フォーム、商品一覧表示テーブルを含みます。
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
新規に商品一覧ページを実装します。(続き)
商品一覧ページの初期レンダリング(Suspense)制御を実施します。
作成する画面イメージは以下です。
以下、issueを参考に、初期レンダリング時のローディング制御を実装します。
商品一覧ページの修正
loaderでPromiseを返すように修正、かつ非同期処理中はfallbackの上、Skeletonを返すように一覧ページを修正します。
- 商品一覧ページ
// 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
新規に商品一覧ページを実装します。(続き)
次に商品一覧ページの初期レンダリング(Suspense)後のローディング制御を実装します。
具体的には、ページングや検索フォームSubmit時などに検索状態(ローディング状態)になる制御を実装します。
作成する画面イメージは以下です。
公式チュートリアルのGlobal Pending UI
の通り実装します。
商品一覧ページの修正
useNavigationフックを利用し、loading状態を判定し、非同期処理中はSkeletonを返すように一覧ページを修正します。
- 商品一覧ページ
// 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
新規に商品一覧ページを実装します。(続き)
次に検索フォームを実装します。
検索フォームはRemix標準のForm、及びclientActionで処理します。
作成する画面イメージは以下です。
商品一覧ページの修正
検索前の状態を一部引き継ぎたいため、postで検索フォームの値を受け付けた後、検索前の状態をURLから復元の上、状態をマージして、リダイレクトします。
リダイレクト後は、loaderで再検索を実施します。
- 商品一覧ページ
+ // クライアントアクションで検索フォームの値を処理します。検索前の状態を復元したいため、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
新規に商品一覧ページを実装します。(続き)
次にテーブルソート(APIを利用したサーバデータのソート)を実装します。
モックで用意しているAPIは、商品名(name)と商品説明(description)のみ対応しているため、これらの値でソートできるように修正します。
作成する画面イメージは以下です。
商品一覧ページの修正
- 商品一覧ページ
// 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
新規に商品登録ページを実装します。
商品一覧ページから遷移する商品登録ページを実装します。
作成する画面イメージは以下です。
商品登録ページの追加
- 商品登録フォームコンポーネントの追加
ブラウザ側でのバリデーションを実装したいため、Formはreact-hook-form
を利用します。
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
新規に商品登録ページを実装します。(続き)
商品登録フォームのロジック部分を実装します。
商品登録ページの修正
- 商品登録フォームコンポーネント
@tanstack/react-query
を利用して、mutationを定義し、submit時にコールします。
+ 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
新規に商品登録ページを実装します。(続き)
商品登録ページにCSRF対策を実装します。
ページ遷移時にトークンを発行し、商品登録フォームに渡すように修正します。
商品登録ページの修正
- 登録ページのルートファイル
loaderでトークンを発行し、フォールバック中はスケルトンを表示するように修正します。
+ 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
新規に商品編集ページを実装します。
RemixのDynamic segments
を利用して、商品IDを動的に取得します。
loaderを用いて、取得した商品IDを元に該当の商品取得します。
商品編集ページの追加
- 編集ページのルートファイル
dashboard.product.$id.tsx
として$id
で商品IDに任意の値を引き受けます。
// クライアント側で実行される関数
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
新規に商品編集ページを実装します。(続き)
商品登録フォームのロジック部分を実装します。
商品登録ページの修正
- 商品登録フォームコンポーネント
削除および更新のmutationを定義して、Submit時にアクションに応じたmutaionを呼び出すように修正します。
+ 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
認証チェック処理を追加実装します。
認証処理として追加実装する内容は以下2点です。
- 認証が必要な画面遷移ごとにセッションのチェックを行う
- リロード対策として、セッションが有効な場合はセッションを復元する(F5対策)
セッションプロバイダの修正
セッションプロバイダに以下のロジックを追加します。
- リロード対策として、セッションが有効な場合はセッションを復元する
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);
}
};
ダッシュボードレイアウトの修正
ダッシュボードレイアウト(認証ページのレイアウトファイル)に以下認証チェック処理を追加実装します。
- 認証が必要な画面遷移ごとにセッションのチェックを行う
これにより、認証が必要なページへの遷移時に認証チェックがフックされます。
セッションが切れた場合、ユーザーを該当ページにリダイレクトし、再ログインを促します。
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
プログラマブルなモーダル
次にプログラマブルなモーダルを実施します。
特定のイベントや状態に応じてモーダルを動的に制御することで、ユーザー体験を向上させることができます。この例では、確認ダイアログを実装し、ユーザーに対して操作の確認を求めることを可能にします。
実装イメージ
マスタデータ削除時などにユーザに操作の確認を求めた上で、実施にデータの削除を行います。
解決したい課題
- 柔軟なユーザーインターフェースの実現: アプリケーション内の様々な場所で、状況に応じてモーダルを表示する必要があります。
- コードの重複と複雑性の削減: 一貫した方法でモーダルを管理することで、コードの重複を減らし、メンテナンスを容易にします。
- 状態管理の一元化: モーダルの状態管理を一元化することで、アプリケーション全体で一貫性のあるユーザー体験を提供します。
- テンプレートの修正不要: テンプレートを変更することなく、コードのみでモーダルの表示を切り替えられることにより、開発の効率化を図ります。
この方法を導入することで、ユーザー体験の向上とコードベースの簡素化が期待できます。
実装方法
ステップ1: ConfirmProviderの作成
まず、確認ダイアログの状態を管理するConfirmProvider
コンポーネントを作成します。
このプロバイダは、アプリケーションのどこからでもモーダルを呼び出せるようにします。
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
をアプリケーション全体に適用します。
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
<SessionProvider>
+ <ConfirmProvider>
+ <Toaster />
+ {children}
+ </ConfirmProvider>
</SessionProvider>
</QueryClientProvider>
);
}
ステップ3: ConfirmProviderの利用
最後に、Providers
コンポーネントを使用してアプリケーション全体にConfirmProvider
を適用し、モーダルを使用するコンポーネントでuseConfirm
フックを利用します。
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
ログのマスキング
ログマスキングは、セキュリティ対策として重要な機能です。
ログに出力される情報には機密性の高いデータが含まれる場合があり、これを保護するためにマスキング処理を行います。
ログマスキングの導入手順
ログマスキングを導入する際の手順は以下の通りです:
-
pinoを利用してログを出力する:pinoはNode.jsの高速ログライブラリで、カスタマイズ可能なシリアライザを提供しています。これを使って、ログデータの中の機密情報をマスキングします。
-
カスタムログメッセージの型定義を行う:アプリケーションで扱うログメッセージの形式を定義し、どのデータをマスキングする必要があるかを明確にします。
実装例
以下のコマンドを使用して、pinoをインストールします:
npm install pino
以下は、pinoを使用してログマスキングを行うための基本的な設定例です。
この例では、ログオブジェクトの中の特定のフィールドにマスキングを適用しています。
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
の使用を避けることで、ログの一元管理とセキュリティの向上を図ります。以下、カスタムロガーを使用する実装例を示します。
// カスタムロガーのインポート
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
やその他のコンソールメソッドの使用を禁止します。この設定を実施することで、コードレビューの負担を軽減可能です。
{
"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
認可処理の追加実装
認可処理として以下の2つの機能を追加実装します。
- メニューの表示制御
- 画面遷移時の権限チェック
まず、1のメニューの表示制御を実装していきます。
実装方法
ステップ1: MenuProviderの作成
ログインユーザーが表示可能なメニューを取得します。
サーバー側の処理では、ユーザーに紐づくロールに基づいて、認可のスコープを返却する処理を想定しています。
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
をアプリケーション全体に適用します。
export function Providers({ children }: { children: React.ReactNode }) {
return (
<QueryClientProvider client={queryClient}>
+ <MenuProvider>
<SessionProvider>
<ConfirmProvider>
<Toaster />
{children}
</ConfirmProvider>
</SessionProvider>
+ </MenuProvider>
</QueryClientProvider>
);
}
ステップ3: 表示可能なメニューの取得
以下のタイミングで表示可能なメニューを取得します。
- ログイン
- 初回レンダリング時(F5対策)
ログイン時の実装修正
ログイン時にメニューを更新するようにします。
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
を修正し、リフレッシュトークン更新時に有効なメニューを同期するようにします。
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: 表示可能なメニューの表示
メニュー表示テンプレートでカスタムフックで取得したメニューを表示するように修正します。
- 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
認可処理の追加実装(続き)
認可処理として以下の2つの機能を追加実装します。
- メニューの表示制御
- 画面遷移時の権限チェック
次に、2の画面遷移時の権限チェックを実装していきます。
実装方法
レイアウトファイルを修正して、画面遷移時の権限チェックを追加します。
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
ハイドレーション(コンソール)エラー対応
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
上記コメントの通り、React 19にバージョンアップすると、解消することを確認
対応手順
ステップ1: Remix公式の通りハイドレーションのスコープをdivのみに変更
修正内容は公式の通り
ステップ2: head(CSS読み込み/タイトル等)の制御の修正
- react-helmet-asyncインストール
npm i react-helmet-async
-
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のロード部分を修正する
+ 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>
);
}
- 各ルートファイルの修正
タイトル設定部分を修正する
+ import * as HelmetAsync from 'react-helmet-async';
+ const { Helmet } = HelmetAsync;
export default function SignInPage() {
return (
+ <>
+ <Helmet>
+ <title>Sign In</title>
+ </Helmet>
// その他変更前のコードのまま
+ </>
Commit
スロットリング対応
初回レンダリング時のセッション復元処理が2回コールされていることを確認したため、対応を検討。
事象
対象のロジック
// 画面リフレッシュ対応: 初回レンダリング時にセッションを復元
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では以下のような挙動が発生します:
- 初回レンダリング時に
useEffect
が呼ばれる。 - コンポーネントのクリーンアップが実行される。
- 再度レンダリングが行われ、
useEffect
が再び呼ばれる。
事象解消の確認
プレビュー表示では、1回しか発生しないことを確認。
認証認可対応(APIリクエスト)
認証が必要なページのAPIリクエストにアクセストークンを付与します。
アクセストークンは、メモリ(Context)に保存しているため、ClientLoaderでの処理は検討する必要があります。
実装としては、以下の3パターンが考えられます。
- アクセストークンをHttp Only Cookieに保存する
- ClientLoaderを利用しない(
react-query
等で対応) - アクセストークンをグローバル変数/ストレージに保存する
本サンプルでは、3のアクセストークンをグローバル変数に保存する方式で実装しますが、本番環境に適応する際は、実装を再検討する必要がある事項です。
修正内容
セッションプロバイダの修正
ClientLoaderでは、Hookが利用できないため、グローバル変数にアクセストークンを保持します。
+ 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
ヘッダに付与するようにロジックを修正します。
+ 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
ヘッダに付与するようにロジックを修正します。
+ 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