2022/09/02

Next.js + TailwindCSS + Reduxのプロジェクト立ち上げ時のメモ

nextjsreactjsredux

Next.js + Tailwind CSS + Redux のプロジェクト作成時の備忘録です。

前回のものを少し改良しています。
(前回はRecoilを使ったバージョンですが・・結構凝ったアプリはReduxの方が制御しやすかったので、Reduxを使ったバージョンです。)

末尾に、(例えばフォームsubmit時などに便利な)ページ全体をローディング中にするコンポーネントをreduxで作ったものを掲載しています。

環境

% node -v
v18.8.0
% yarn -v
1.22.19
% npx -v
8.18.0

プロジェクトの作成・Tailwind CSSの初期化

# create app
% npx create-next-app@latest --ts your-project-name
% cd your-project-name

# add .idea to .gitignore(jetbrain社製のIDE"webstorm"を使う方用)
% cat <<EOF >> .gitignore

# ide
.idea/
EOF

# src directory
% cat <<'EOF' > tsconfig.json
{
  "compilerOptions": {
    "target": "es5",
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "baseUrl": ".",
    "paths": {
      "@/*": ["src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
  "exclude": ["node_modules"]
}
EOF
% mkdir src && \
    mv pages src && \
    mv styles src

# edit next.config.js
% cat <<'EOF' > next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
  reactStrictMode: true,
  experimental: {
    newNextLinkBehavior: true,
    scrollRestoration: true,
    images: {
      allowFutureImage: true,
    },
  },
}

module.exports = nextConfig
EOF

# introduce tailwind and some utilities
% yarn add --dev \
    tailwindcss \
    @tailwindcss/forms \
    @tailwindcss/typography \
    postcss \
    autoprefixer \
    sass \
    styled-components \
    @types/styled-components
% yarn add \
    @headlessui/react \
    @heroicons/react \
    clsx \
    focus-visible \
    postcss-focus-visible \
    use-debounce \
    framer-motion
% npx tailwindcss init -p

tailwind.config.jsを編集

/** @type {import('tailwindcss').Config} */
module.exports = {
  content: ['./src/**/*.{js,ts,jsx,tsx}'],
  theme: {
    extend: {
      colors: ({ colors }) => ({
        primary: colors.rose,
      }),
      fontFamily: {
        inter: ['Inter', 'sans-serif'],
      },
    },
  },
  plugins: [require('@tailwindcss/forms'), require('@tailwindcss/typography')],
}

global.cssを編集

@import url('https://rsms.me/inter/inter.css');

@tailwind base;
@tailwind components;
@tailwind utilities;

prettierを導入

% yarn add --dev \
    prettier \
    eslint-config-prettier \
    eslint-plugin-unused-imports \
    prettier-plugin-tailwindcss

.eslintrc.jsonを以下のように編集

{
  "extends": ["next/core-web-vitals", "prettier"],
  "plugins": ["unused-imports"],
  "rules": {
    "@typescript-eslint/no-unused-vars": "off",
    "unused-imports/no-unused-imports": "error",
    "unused-imports/no-unused-vars": "off"
  }
}

.prettierignore ファイルを追加

cat <<'EOF' > .prettierignore
###
# Place your Prettier ignore content here

###
# .gitignore content is duplicated here due to https://github.com/prettier/prettier/issues/8506

# Created by .ignore support plugin (hsz.mobi)
### Node template
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Runtime data
pids
*.pid
*.seed
*.pid.lock

# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov

# Coverage directory used by tools like istanbul
coverage

# nyc test coverage
.nyc_output

# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)
.grunt

# Bower dependency directory (https://bower.io/)
bower_components

# node-waf configuration
.lock-wscript

# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release

# Dependency directories
node_modules/
jspm_packages/

# TypeScript v1 declaration files
typings/

# Optional npm cache directory
.npm

# Optional eslint cache
.eslintcache

# Optional REPL history
.node_repl_history

# Output of 'npm pack'
*.tgz

# Yarn Integrity file
.yarn-integrity

# dotenv environment variables file
.env

# parcel-bundler cache (https://parceljs.org/)
.cache

# next.js build output
.next

# nuxt.js build output
.nuxt

# Nuxt generate
dist

# vuepress build output
.vuepress/dist

# Serverless directories
.serverless

# IDE / Editor
.idea

# Service worker
sw.*

# macOS
.DS_Store

# Vim swap files
*.swp

# static resources
public
EOF

prettierの設定ファイルを追加

cat <<'EOF' > prettier.config.js
module.exports = {
  singleQuote: true,
  semi: false,
  tabWidth: 2,
  useTabs: false,
  plugins: [require('prettier-plugin-tailwindcss')],
}
EOF

package.jsonファイルにprettierのコマンド追加

{
  "scripts": {
+   "lintfix": "prettier --write --list-different .",
  }
}
# 以下のコマンドを実行して、prettierを実行してコードの整形が可能になりました。
% yarn lintfix

husky/commitlintを導入

% yarn add --dev \
    husky \
    lint-staged \
    @commitlint/{cli,config-conventional}
% husky install

% cat <<'EOF' > .husky/commit-msg
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
. "$(dirname "$0")/common.sh"

yarn commitlint --edit $1
EOF

% cat <<'EOF' > .husky/common.sh
command_exists () {
  command -v "$1" >/dev/null 2>&1
}

# Workaround for Windows 10, Git Bash and Yarn
if command_exists winpty && test -t 1; then
  exec < /dev/tty
fi
EOF

% cat <<'EOF' > .husky/pre-commit
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
. "$(dirname "$0")/common.sh"

yarn lint-staged
EOF

% cat <<'EOF' > commitlint.config.js
module.exports = {
  extends: ['@commitlint/config-conventional'],
}
EOF

% chmod +x .husky/commit-msg
% chmod +x .husky/common.sh
% chmod +x .husky/pre-commit

package.jsonにlint-stagedを追加

  "lint-staged": {
    "**/*.{js,jsx,ts,tsx}": [
      "eslint"
    ],
    "*.**": [
      "prettier --check --ignore-unknown"
    ]
  },

Reduxの導入

% yarn add \
    @reduxjs/toolkit \
    react-redux \
    next-redux-wrapper

% mkdir -p src/store/app
% cat <<'EOF' > src/store/index.ts
import { Action, ThunkAction, configureStore } from '@reduxjs/toolkit'
import { appLoadingSlice } from '@/store/app/loading'
import { createWrapper } from 'next-redux-wrapper'

const makeStore = () =>
  configureStore({
    reducer: {
      [appLoadingSlice.name]: appLoadingSlice.reducer,
    },
    devTools: true,
  })

export type AppStore = ReturnType<typeof makeStore>
export type AppState = ReturnType<AppStore['getState']>
export type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  AppState,
  unknown,
  Action
>

export const wrapper = createWrapper<AppStore>(makeStore)

EOF

% cat <<'EOF' > src/store/app/loading.ts
import { createSlice } from '@reduxjs/toolkit'
import { AppState } from '@/store'
import { HYDRATE } from 'next-redux-wrapper'

export const appLoadingSlice = createSlice({
  name: 'appLoading',
  initialState: {
    loading: false,
  } as any,
  reducers: {
    setAppLoading(state, action) {
      state.loading = action.payload
    },
  },
  extraReducers: {
    [HYDRATE]: (state, action) => {
      console.log('HYDRATE', state, action.payload)
      return {
        ...state,
        ...action.payload.appLoading,
      }
    },
  },
})

export const { setAppLoading } = appLoadingSlice.actions

export const selectAppLoading = (state: AppState) => state.appLoading.loading

export default appLoadingSlice.reducer
EOF

% cat <<'EOF' > src/pages/_app.tsx
import '@/styles/globals.css'
import { wrapper } from '@/store'
import type { AppProps } from 'next/app'

function App({ Component, pageProps }: AppProps) {
  return <Component {...pageProps} />
}

export default wrapper.withRedux(App)
EOF

% mkdir -p src/components/loadings
% cat <<'EOF' > src/components/loadings/AppLoading.tsx
import styled, { keyframes } from 'styled-components'
import { selectAppLoading } from '@/store/app/loading'
import { useSelector } from 'react-redux'

const AppLoading = () => {
  const isAppLoading = useSelector(selectAppLoading)
  if (isAppLoading) {
    return (
      <div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-black bg-opacity-25">
        <Spinner className="loader mb-4 h-24 w-24 rounded-full border-4 border-t-4 border-gray-200 ease-linear"></Spinner>
      </div>
    )
  } else {
    return null
  }
}

export default AppLoading

const spinAnimation = keyframes`
  0% {transform: rotate(0deg);}
  100% {transform: rotate(360deg);}
`

const Spinner = styled.div`
  border-top-color: #f97316;
  -webkit-animation: ${spinAnimation} 1.5s linear infinite;
  animation: ${spinAnimation} 1.5s linear infinite;
`

EOF

% cat <<'EOF' > src/pages/index.tsx
import type { NextPage } from 'next'
import AppLoading from '@/components/loadings/AppLoading'
import { setAppLoading } from '@/store/app/loading'
import { useDispatch } from 'react-redux'

const Home: NextPage = () => {
  const dispatch = useDispatch()
  const turnLoadingOn = async () => {
    dispatch(setAppLoading(true))
    try {
      await new Promise((resolve) => setTimeout(resolve, 1000))
    } finally {
      dispatch(setAppLoading(false))
    }
  }
  return (
    <>
      <div className="bg-white font-inter tracking-tight text-gray-900 antialiased">
        <main className="relative flex h-screen overflow-hidden bg-gray-50">
          <div className="flex h-full w-full items-center justify-center">
            <button
              className="inline-flex justify-center rounded-lg border border-primary-300 py-[calc(theme(spacing.2)-1px)] px-[calc(theme(spacing.3)-1px)] text-sm text-primary-700 outline-2 outline-offset-2 transition-colors hover:border-primary-400 active:bg-primary-100 active:text-primary-700/80"
              onClick={turnLoadingOn}
            >
              <span>App Loading</span>
            </button>
          </div>
        </main>
      </div>
      <AppLoading></AppLoading>
    </>
  )
}

export default Home

EOF

References